JVM虚拟延迟内存分配机制源码剖析
VM虚拟延迟内存分配机制源码剖析
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
JVM虚拟延迟内存分配机制源码剖析
作为软件开发工程师,分析 JVM(以 OpenJDK8为例)的虚拟内存管理,本质上是解构 HotSpot 运行时环境(Runtime)与 Linux 内核虚拟内存子系统(VMS)的协同演进过程。
在 Linux 进程模型中,JVM 视角下的 Reserved(预留) 和 Committed(提交),分别对应了操作系统层面的 分配 VMA 线性区 与 改变页面访问权限/触发物理内存承诺。而真正的物理内存换入,则是通过延迟分配(Lazy Allocation)机制机制下的缺页中断(Page Fault)实现的。
架构演进全景
在深入源码前,需明确 JVM 虚拟内存的三阶段演进状态:
- Reserved(预留状态):JVM 向内核申请一段连续的虚拟地址空间。内核仅在进程的
mm_struct中新建或划分一段vm_area_struct(VMA),其页表项(PTE)尚未建立,且访问权限通常设为PROT_NONE。此时不占用任何物理内存与 Swap 空间。 - Committed(提交状态):JVM 声明将要使用某段已预留的地址空间。通过内核系统调用修改 VMA 的权限为
PROT_READ | PROT_WRITE。此时内核会进行 Overcommit 审计,并在逻辑上承诺这段内存可用,但依然没有分配物理内存页帧(Page Frame)。 - Physical Mapped(物理就绪状态):当 JVM 线程首次向该内存区域执行写指令时,CPU 的 MMU 发现页表项无效,触发缺页中断(Page Fault)。内核接管中断后,从伙伴系统申请物理页(Page Frame),将其填入页表并建立映射。此时才真正消耗物理内存。
第一阶段:Reserved 状态的诞生(分配 VMA 线性区)
在 JVM 启动时(例如初始化 Java 堆),ReservedSpace 类负责向操作系统预留整块连续的虚拟地址空间(大小由 -Xmx 等参数决定)。
1. JVM 层的调用轨迹
源码位于 hotspot/src/share/vm/runtime/virtualspace.cpp:
// ReservedSpace 的核心初始化逻辑
void ReservedSpace::initialize(size_t size, size_t alignment, bool large,
char* requested_address,
const size_t noaccess_prefix,
bool executable) {
// ... 省略对齐与大页(Large Pages)的条件判断 ...
// 调用平台相关的操作系统接口预留内存
char* base = os::reserve_memory(size, requested_address, alignment);
// ... 省略异常检查与 Native Memory Tracking (NMT) 的注册 ...
// 此时 NMT 会将这块内存标记为 Tracking Level: Reserved
_base = base;
_size = size;
}
2. Linux 平台的具体实现
os::reserve_memory 最终路由到 hotspot/src/os/linux/vm/os_linux.cpp 中的 os::anon_mmap 函数:
char* os::anon_mmap(char* fixed_addr, size_t size, size_t alignment, bool transferable) {
// 设定标准匿名映射标记
// MAP_PRIVATE: 创建一个私有的写时复制(COW)映射
// MAP_ANONYMOUS: 匿名映射,不映射任何文件,内容初始化为 0
int flags = MAP_PRIVATE | MAP_ANONYMOUS;
if (fixed_addr != NULL) {
flags |= MAP_FIXED; // 如果指定了目标地址(如压缩指针基址优化),则强制覆盖该地址
}
// 【核心系统调用】
// prot 传入 PROT_NONE(无权限:不可读、不可写、不可执行)
// 这意味着进程此时如果访问该段虚拟地址,会被 CPU 直接拦截并抛出 SIGSEGV
char* base = (char*)mmap(fixed_addr, size, PROT_NONE, flags, -1, 0);
if (base == MAP_FAILED) {
return NULL; // 预留失败,可能触发 Native OOM
}
return base;
}
3. Linux 内核视角
当 mmap(..., PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, ...) 执行时:
- 内核在进程的全局内存描述符
mm_struct中查找一块空闲的虚拟地址。 - 创建一个新的红黑树节点——
vm_area_struct(VMA 线性区),记录vm_start和vm_end。 - 将该 VMA 的
vm_flags设为VM_NONE(对应PROT_NONE)。 - 结果:内核仅在内核空间里记录了“该进程占有了这段虚拟地址”,页表(Page Table)中没有任何对应的实体映射。
第二阶段:Committed 状态的转换(修改 VMA 权限)
当 JVM 需要实际使用内存时(例如根据 -Xms 初始化堆大小,或运行时堆动态扩容),VirtualSpace 会驱动内存从 Reserved 走向 Committed。
1. JVM 层的调用轨迹
源码位于 hotspot/src/share/vm/runtime/virtualspace.cpp:
bool VirtualSpace::expand_by(size_t bytes) {
// ... 边界与对齐检查 ...
// 计算当前 committed 边界(high)及需要新提交的大小
// 调用 os::commit_memory 转换为 Committed 状态
if (!os::commit_memory(high(), bytes, alignment_hint, _executable)) {
return false; // 提交失败,抛出 OutOfMemoryError
}
// 成功后移动高位指针,扩大可用边界
_high += bytes;
return true;
}
2. Linux 平台的具体实现
在 hotspot/src/os/linux/vm/os_linux.cpp 中,os::commit_memory 最终交付给 os::linux::commit_memory_impl:
bool os::linux::commit_memory_impl(char* addr, size_t size, int exec) {
// 决定转换后的权限:如果是代码缓存(CodeCache)则需要可执行权限,堆内存则为可读写
int prot = exec ? (PROT_READ | PROT_WRITE | PROT_EXEC) : (PROT_READ | PROT_WRITE);
// 【核心系统调用】
// 利用 mprotect 改变该段已有 VMA 的访问权限
// 从原先的 PROT_NONE 改变为 PROT_READ | PROT_WRITE
if (mprotect(addr, size, prot) == 0) {
return true;
}
// 兜底策略:如果 mprotect 因为特殊的 VMA 拆分限制失败,
// 则利用 MAP_FIXED 显式重新进行底层覆盖映射
int flags = MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED;
char* res = (char*)mmap(addr, size, prot, flags, -1, 0);
return res != MAP_FAILED;
}
3. Linux 内核视角与 Overcommit 审计
当 mprotect(..., PROT_READ | PROT_WRITE) 被调用时,内核会执行以下动作:
-
找到对应的
vm_area_struct,将其vm_flags修改为VM_READ | VM_WRITE。 -
根据系统的
/proc/sys/vm/overcommit_memory配置进行内存记账审核: -
如果设置为
2(不允许超发),内核会计算当前系统已 Commit 的总内存是否超过了(RAM * 比例 + Swap)。若超过,mprotect直接返回-ENOMEM(即 JVM 报出著名的errno=12 Cannot allocate memory错误)。 -
结果:此时内核在逻辑上通过了记账,认可了这笔内存申请,但依旧没有为该进程分配任何物理内存页帧(Page Frame),页表项(PTE)内依然是空的或无效的。
第三阶段:物理内存的真正换入(触发缺页中断)
从 JVM 的 Native Memory Tracking(NMT)指标来看,第二阶段完成后内存已经是 Committed 了。但在 OS 层面,真正的物理内存换入发生在 JVM 首次对该地址进行读写操作时。
1. 触发时机与硬件捕获
假设 JVM 开始在刚刚 Commit 的堆内存上分配对象,或者垃圾回收器进行内存置零初始化:
; 伪汇编代码:JVM 线程尝试向 Committed 区域写入数据
MOV [rax], rbx ; rax 存储的是刚被 commit 但从未读写的虚拟地址
- MMU 拦截:CPU 的内存管理单元(MMU)拦截到这条写入指令,尝试通过多级页表(PGD -> PUD -> PMD -> PTE)将
rax中的虚拟地址翻译为物理地址。 - 触发异常:MMU 发现该虚拟地址对应的页表项(PTE)的
Present标志位为0(表示该页不在物理内存中)。 - 硬件中断:CPU 挂起当前用户态指令,向内核抛出一个 Page Fault(缺页中断,中断向量号 14),并将触发异常的虚拟地址存入
CR2寄存器。
2. Linux 内核的缺页中断处理流程
内核捕获中断后,进入异常处理程序(X86 架构下为 do_page_fault):
// 简化版的 Linux 内核缺页处理伪代码逻辑
__visible void __kprobes do_page_fault(struct pt_regs *regs, unsigned long error_code) {
unsigned long address = read_cr2(); // 从 CR2 寄存器读取引发缺页的虚拟地址
struct task_struct *tsk = current;
struct mm_struct *mm = tsk->mm;
// 1. 在进程的全局红黑树中查找该地址所属的 VMA
struct vm_area_struct *vma = find_vma(mm, address);
// 2. 权限校验
// 此时因为第二阶段执行了 mprotect,vma->vm_flags 已经是 VM_WRITE
// 校验通过,否则会报 SIGSEGV(段错误)
if (error_code & X86_PF_WRITE) {
if (!(vma->vm_flags & VM_WRITE)) goto bad_area;
}
// 3. 调用核心分配机制
handle_mm_fault(vma, address, flags);
}
深入到 handle_mm_fault -> __handle_mm_fault -> handle_pte_fault。由于这是由 MAP_ANONYMOUS 创建的匿名映射,内核最终会调用 do_anonymous_page:
static int do_anonymous_page(struct mm_struct *mm, struct vm_area_struct *vma,
unsigned long address, pte_t *page_table, pmd_t *pmd,
unsigned int flags) {
struct page *page;
// 如果是读缺页,为了节省内存,内核会直接将其映射到一个全局只读的“零页”(Zero Page)
if (!(flags & FAULT_FLAG_WRITE)) {
entry = pte_mkspecial(pfn_to_pte(my_zero_pfn(address)));
// ... 填入页表项 ...
return 0;
}
// JVM 执行的是写操作,必须分配真实的物理页帧
// 从伙伴系统(Buddy System)的高端或普通内存区申请一个全零的物理页框(Page Frame)
page = alloc_zeroed_user_highpage_movable(vma, address);
if (!page) return VM_FAULT_OOM; // 系统物理内存耗尽,触发 OS 级别的 OOM Killer
// 将物理页的页框号(PFN)和 VMA 的 RW 权限结合,生成标准的页表项(PTE)
pte_t entry = mk_pte(page, vma->vm_page_prot);
entry = pte_mkdirty(pte_mkwrite(entry)); // 标记为脏页、可写
// 【核心动作】将生成的 PTE 写入底层硬件页表
set_pte_at(mm, address, page_table, entry);
// 递增进程的 RSS(Resident Set Size,常驻内存集)计数
inc_mm_counter(mm, MM_ANONPAGES);
return 0;
}
当 do_anonymous_page 返回后,内核将 CPU 切回用户态,重新执行刚才失败的 MOV [rax], rbx 指令。此时 MMU 能够顺着页表完美查找到物理内存页帧,转换宣告完成。
关键参数对转换行为的干预
系统工程师在调优 JVM 时,有两个参数深刻改变了上述的动态转换链路:
1. -Xms 与 -Xmx 对齐
- 不相等(如
-Xms1g -Xmx4g):
启动时,JVM 会调用anon_mmap(PROT_NONE)预留 4 GB 4\text{GB} 4GB 的 Reserved 空间。接着,对其中 1 GB 1\text{GB} 1GB 的空间调用mprotect(PROT_READ|PROT_WRITE)转换为 Committed。随着运行期对象增多,触发堆扩容,动态对剩余的 3 GB 3\text{GB} 3GB 空间按需分批执行mprotect(Committed),并伴随高频的缺页中断换入物理内存。 - 相等(如
-Xms4g -Xmx4g):
启动时,直接一步到位将 4 GB 4\text{GB} 4GB 全部转为 Committed 状态,避免了运行期由于扩容引发mprotect系统调用的上下文切换开销。
2. -XX:+AlwaysPreTouch 的降维打击
即便是 -Xms4g -Xmx4g,启动后通过 top 命令查看进程的 RES(物理常驻内存)依然可能只有几百兆,因为 OS 的 Lazy Allocation 机制仍在生效,缺页中断被延迟到了运行期。
为了彻底消除运行期物理页换入导致的瞬时抖动(STW 期间或对象分配分配时的微小延迟),可以使用 -XX:+AlwaysPreTouch。其源码位于 hotspot/src/os/linux/vm/os_linux.cpp:
void os::pretouch_memory(char* start, char* end, size_t page_size) {
for (char* p = start; p < end; p += page_size) {
// 强行对每一个内存页的第一个字节写入一个 0
// 该赋值操作在原子层面是一条写指令,直接在强行触发操作系统的 do_page_fault
*p = 0;
}
}
后果:如果在启动参数中开启该参数,JVM 会在启动阶段的 VirtualSpace::expand_by 内部同步循环遍历所有 Committed 内存页并写入 0。这意味着在 JVM 启动完成的那一刻,所有的虚拟内存就已经完成了从 Reserved -> Committed -> 物理页映射的完整闭环,运行期的物理内存开销一步到位,彻底抹平了后续的缺页中断隐患。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)