堆内存本质剖析
- 前言
- 堆内存本质剖析
-
- [一、 为什么是 `mmap` 而不是 `brk`?](#一、 为什么是
mmap而不是brk?) - [二、 OpenJDK 8堆内存的两层抽象](#二、 OpenJDK 8堆内存的两层抽象)
- [三、 OpenJDK 8核心源码深度剖析](#三、 OpenJDK 8核心源码深度剖析)
-
- [1. 虚拟内存空间的初始化:`ReservedSpace::initialize`](#1. 虚拟内存空间的初始化:
ReservedSpace::initialize) - [2. Linux 底层的映射实现:`os::Linux::reserve_memory_impl`](#2. Linux 底层的映射实现:
os::Linux::reserve_memory_impl) - [3. 激活虚拟内存:`os::commit_memory`](#3. 激活虚拟内存:
os::commit_memory)
- [1. 虚拟内存空间的初始化:`ReservedSpace::initialize`](#1. 虚拟内存空间的初始化:
- [四、 从系统工程师视角透视:内核中发生了什么?](#四、 从系统工程师视角透视:内核中发生了什么?)
- [一、 为什么是 `mmap` 而不是 `brk`?](#一、 为什么是
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
堆内存本质剖析
作为一名软件工程师,从操作系统的底层视角来看,JVM 堆内存(Heap)的本质并不是物理内存的直接分配,而是一段连续的虚拟地址空间(Virtual Address Space)的进入与管理状态。 JVM 通过向内核发起系统调用(主要是 mmap,而非 brk),在进程的虚拟地址空间中"圈地"。这块空间在初始时只是操作系统的虚拟内存区域(VMA, Virtual Memory Area)的一项记录,只有在真正写入数据触发缺页异常(Page Fault)时,操作系统才会将物理内存页(Physical Page Frames)映射过去。
一、 为什么是 mmap 而不是 brk?
虽然 brk 或 sbrk 可以通过移动堆顶指针(program break)来扩展内存,但它在多线程、需要精细化内存管理的现代 JVM 中存在致命缺陷:
- 单一连续性限制 :
brk只能向一个方向单向延伸或收缩,无法自由释放中间被废弃的内存块。 - 缺乏权限控制 :
brk分配的空间无法精细配置页级别的保护权限(如PROT_NONE转换为PROT_READ | PROT_WRITE)。 - 无法实现对齐优化 :JVM 堆往往需要垃圾回收器(GC)进行大页(Huge Pages)对齐或卡表(Card Table)对齐,
mmap可以在任意合法的虚拟地址处映射,并天然支持复杂的对齐逻辑。
因此,OpenJDK 8的核心内存分配完全基于匿名内存映射:mmap(..., MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)。
二、 OpenJDK 8堆内存的两层抽象
在 OpenJDK 8源码中,虚拟内存的申请被封装在两个高频出现的 C++ 类中:
ReservedSpace(保留空间) :对应系统的mmap(..., PROT_NONE)。它在进程的虚拟地址空间中占据一段指定大小的连续区域,防止其他线程或malloc占用,此时不消耗任何物理内存(RES/RSS)。VirtualSpace(虚拟空间) :建立在ReservedSpace之上,负责将其中的某一段或全部虚拟地址"提交"(Commit)。对应系统的mprotect(..., PROT_READ | PROT_WRITE)或重新mmap,此时该段虚拟内存允许读写,随时准备接受物理页的映射。
三、 OpenJDK 8核心源码深度剖析
以下源码源自 OpenJDK 8中控制虚拟内存分配的核心文件:hotspot/src/share/vm/runtime/virtualspace.cpp 和 hotspot/src/os/linux/vm/os_linux.cpp。
1. 虚拟内存空间的初始化:ReservedSpace::initialize
当 JVM 启动,垃圾回收器(如 ParallelScavengeHeap 或 G1CollectedHeap)初始化时,会调用 ReservedSpace 的构造函数。
cpp
// 源码路径:hotspot/src/share/vm/runtime/virtualspace.cpp
void ReservedSpace::initialize(size_t size, size_t alignment, bool large,
char* requested_address,
const size_t noaccess_prefix,
bool executable) {
const size_t granularity = os::vm_allocation_granularity();
assert((size & (granularity - 1)) == 0, "size not aligned to vm granularity");
assert((alignment & (granularity - 1)) == 0, "alignment not aligned to vm granularity");
assert((alignment & (page_size - 1)) == 0, "alignment not aligned to page size");
// 1. 计算总申请大小,包括可能需要的无访问前缀(用于压缩指针等优化)
size_t base_alignment = ~(alignment - 1);
bool use_large_pages = large && os::can_execute_large_page_code();
char* base = NULL;
// 2. 如果指定了固定地址(通常为了零基压缩指针 Zero-Based Compressed Oops)
if (requested_address != NULL) {
base = os::attempt_reserve_memory_at(size, requested_address);
}
if (base == NULL) {
// 3. 通用路径:调用底层 OS 接口申请虚拟内存映射
if (use_large_pages) {
// 如果启用了大页优化 (-XX:+UseLargePages)
base = os::reserve_memory_special(size, alignment, requested_address, executable);
} else {
// 标准页分配路径,底层最终调用 Linux 的 mmap
base = os::reserve_memory(size, requested_address, alignment);
}
}
if (base == NULL) {
vm_exit_during_initialization("Could not reserve enough space for object heap");
}
// 4. 检查分配出的内存地址是否符合对齐要求
if (((uintptr_t)base & (alignment - 1)) != 0) {
// 如果没有对齐,JVM 会整体申请一块更大的空间,然后人工将其裁剪对齐,释放多余的边界虚拟内存
...
}
// 5. 正式对成员变量赋值,此时该段虚拟地址已被 JVM 独占
_base = base;
_size = size;
_alignment = alignment;
_special = use_large_pages;
}
2. Linux 底层的映射实现:os::Linux::reserve_memory_impl
os::reserve_memory 在 Linux 操作系统下的具象化实现位于 os_linux.cpp。在这里,我们能直接看到向 Linux 内核递交的 mmap 系统调用。
cpp
// 源码路径:hotspot/src/os/linux/vm/os_linux.cpp
char* os::Linux::reserve_memory_impl(size_t bytes, char* requested_addr, uintptr_t alignment) {
// 1. 组装 Linux mmap 所需的 flags
// MAP_PRIVATE: 建立一个写入时复制(Copy-on-Write)的私有映射,对该区域的更改不会影响其他进程
// MAP_ANONYMOUS: 匿名映射,不映射任何文件,fd 传入 -1。此时内存初始被清零(Zero-filled)
int flags = MAP_PRIVATE | MAP_ANONYMOUS;
if (requested_addr != NULL) {
// 注意:这里没有使用 MAP_FIXED。
// 因为 MAP_FIXED 如果遇到地址冲突,会粗暴地覆盖掉原有的映射(比如覆盖掉 libc 或是现有的线程栈)。
// JVM 采用"建议地址"的方式,如果内核无法在此地址映射,会返回其他地址,由 JVM 自行判断。
flags |= 0;
}
// 2. 注意底层的权限设置:PROT_NONE
// 核心考量:此时只在进程的进程控制块(task_struct->mm_struct)中分配红黑树节点(vm_area_struct),
// 但禁止任何读、写、执行操作(PROT_NONE)。
// 这样做极度轻量,完全没有物理内存开销,同时防止了指针越界访问。
char* addr = anon_mmap(requested_addr, bytes, PROT_NONE, flags);
if (addr == NULL) {
return NULL;
}
// 3. 校验对齐
if (alignment > 0 && ((uintptr_t)addr & (alignment - 1)) != 0) {
// 如果返回的地址未对齐,立即释放,并重新执行带有对齐填充的分配算法
unmap_region(addr, bytes);
return reserve_memory_aligned(bytes, alignment);
}
return addr;
}
// 实际调用标准 C 库的 mmap 接口
static char* anon_mmap(char* requested_addr, size_t bytes, int prot, int flags) {
// sys_mmap 系统调用
char* addr = (char*)::mmap(requested_addr, bytes, prot, flags, -1, 0);
if (addr == MAP_FAILED) {
return NULL;
}
return addr;
}
3. 激活虚拟内存:os::commit_memory
当 JVM 决定真正使用某一段堆内存(例如扩容,或者初始化新生代/老年代的空间)时,它会将对应的 ReservedSpace 片段交给 VirtualSpace 拓展,并调用 os::commit_memory。
cpp
// 源码路径:hotspot/src/os/linux/vm/os_linux.cpp
bool os::commit_memory(char* addr, size_t size, bool executable) {
// 1. 此时将权限修正为可读、可写 (PROT_READ | PROT_WRITE)
int prot = PROT_READ | PROT_WRITE | (executable ? PROT_EXEC : 0);
// 2. 底层通过 mprotect 系统调用改变这块虚拟地址的 VMA 权限限制
// 也有部分配置下,JVM 会通过重新 mmap (MAP_FIXED) 来覆盖原来的 PROT_NONE 区域
if (::mprotect(addr, size, prot) == 0) {
return true;
}
// 如果 mprotect 失败(比如触及了 Linux 的 vm.max_map_count 限制)
return false;
}
四、 从系统工程师视角透视:内核中发生了什么?
当我们结合上述源码与 Linux 内核的运行机制,整个堆内存的生命周期演进如下:
[JVM 启动]
│
▼
os::Linux::reserve_memory_impl() ──> mmap(..., PROT_NONE, MAP_ANONYMOUS)
│
└─> 【内核行为】: 在内核中创建 `vm_area_struct` 结构体,插入进程的红黑树中。
此时:VIRT 增加(等于 -Xmx),RES/RSS 不变。无物理内存消耗。
│
[堆空间初始化/扩容]
│
▼
os::commit_memory() ───────────────> mprotect(..., PROT_READ|PROT_WRITE)
│
└─> 【内核行为】: 修改对应 VMA 的权限属性。
此时:该段虚拟地址合法,允许读写,但依旧没有分配物理内存页。
│
[Java对象创建/分配内存]
│
▼
第一次向该地址写入数据(如 TLAB 分配)
│
└─> 【内核行为】:
1. CPU 核心解析虚拟地址,发现页表(Page Table)中该页的 Present 位为 0。
2. 触发 缺页异常(Page Fault)。
3. 内核捕获异常,调用 `do_anonymous_page()` 分配一个物理页框(Page Frame)。
4. 将物理页的物理地址填入多级页表,刷新 TLB 缓存。
5. 恢复 Java 线程执行。
此时:RES/RSS 真正增加。
高级优化参数的底层逻辑
基于上述原理,系统工程师常常会调整以下两个 JVM 关键参数,其本质完全是对底层系统调用的控制:
-
-XX:+AlwaysPreTouch: -
现象:启动变慢,但运行期由于内存分配导致的延迟(Latency)大大降低。
-
本质 :JVM 在
commit_memory之后,会立即由内部线程对该整块空间里的每一个内存页(每 4KB 或大页的 2MB 处)**隐式地写入一个0**。这直接在启动期强制触发了所有的 Page Fault,让操作系统把物理内存全部填满并建立好页表映射,避免了运行期由 Java 业务线程承担 Page Fault 的开销。 -
-XX:+UseLargePages: -
现象:减少系统 CPU 开销,提升吞吐。
-
本质 :在
os::reserve_memory_special中改用MAP_HUGETLB或调用shmget。使得内核以 2MB(甚至 1GB)为单位分配页表。这导致页表级数变少,极大地提高了 CPU TLB(Translation Lookaside Buffer)的命中率,对于动辄几十 GB 的大堆 JVM 而言,能显著减少地址翻译的 CPU 时钟周期损耗。