VM虚拟延迟内存分配机制源码剖析
- 前言
- JVM虚拟延迟内存分配机制源码剖析
- 架构演进全景
- 第一阶段:Reserved 状态的诞生(分配 VMA 线性区)
- 1. JVM 层的调用轨迹
- 2. Linux 平台的具体实现
- 3. Linux 内核视角
- 第二阶段:Committed 状态的转换(修改 VMA 权限)
- 1. JVM 层的调用轨迹
- 2. Linux 平台的具体实现
- 3. Linux 内核视角与 Overcommit 审计
- 第三阶段:物理内存的真正换入(触发缺页中断)
- 1. 触发时机与硬件捕获
- 2. Linux 内核的缺页中断处理流程
- 关键参数对转换行为的干预
- 1. `-Xms` 与 `-Xmx` 对齐
- 2. `-XX:+AlwaysPreTouch` 的降维打击
前言
本文旨在记录近期研读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 的核心初始化逻辑voidReservedSpace::initialize(size_t size,size_t alignment,boollarge,char*requested_address,constsize_t noaccess_prefix,boolexecutable){// ... 省略对齐与大页(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,booltransferable){// 设定标准匿名映射标记// MAP_PRIVATE: 创建一个私有的写时复制(COW)映射// MAP_ANONYMOUS: 匿名映射,不映射任何文件,内容初始化为 0intflags=MAP_PRIVATE|MAP_ANONYMOUS;if(fixed_addr!=NULL){flags|=MAP_FIXED;// 如果指定了目标地址(如压缩指针基址优化),则强制覆盖该地址}// 【核心系统调用】// prot 传入 PROT_NONE(无权限:不可读、不可写、不可执行)// 这意味着进程此时如果访问该段虚拟地址,会被 CPU 直接拦截并抛出 SIGSEGVchar*base=(char*)mmap(fixed_addr,size,PROT_NONE,flags,-1,0);if(base==MAP_FAILED){returnNULL;// 预留失败,可能触发 Native OOM}returnbase;}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:
boolVirtualSpace::expand_by(size_t bytes){// ... 边界与对齐检查 ...// 计算当前 committed 边界(high)及需要新提交的大小// 调用 os::commit_memory 转换为 Committed 状态if(!os::commit_memory(high(),bytes,alignment_hint,_executable)){returnfalse;// 提交失败,抛出 OutOfMemoryError}// 成功后移动高位指针,扩大可用边界_high+=bytes;returntrue;}2. Linux 平台的具体实现
在hotspot/src/os/linux/vm/os_linux.cpp中,os::commit_memory最终交付给os::linux::commit_memory_impl:
boolos::linux::commit_memory_impl(char*addr,size_t size,intexec){// 决定转换后的权限:如果是代码缓存(CodeCache)则需要可执行权限,堆内存则为可读写intprot=exec?(PROT_READ|PROT_WRITE|PROT_EXEC):(PROT_READ|PROT_WRITE);// 【核心系统调用】// 利用 mprotect 改变该段已有 VMA 的访问权限// 从原先的 PROT_NONE 改变为 PROT_READ | PROT_WRITEif(mprotect(addr,size,prot)==0){returntrue;}// 兜底策略:如果 mprotect 因为特殊的 VMA 拆分限制失败,// 则利用 MAP_FIXED 显式重新进行底层覆盖映射intflags=MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED;char*res=(char*)mmap(addr,size,prot,flags,-1,0);returnres!=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 内核缺页处理伪代码逻辑__visiblevoid__kprobesdo_page_fault(structpt_regs*regs,unsignedlongerror_code){unsignedlongaddress=read_cr2();// 从 CR2 寄存器读取引发缺页的虚拟地址structtask_struct*tsk=current;structmm_struct*mm=tsk->mm;// 1. 在进程的全局红黑树中查找该地址所属的 VMAstructvm_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))gotobad_area;}// 3. 调用核心分配机制handle_mm_fault(vma,address,flags);}深入到handle_mm_fault->__handle_mm_fault->handle_pte_fault。由于这是由MAP_ANONYMOUS创建的匿名映射,内核最终会调用do_anonymous_page:
staticintdo_anonymous_page(structmm_struct*mm,structvm_area_struct*vma,unsignedlongaddress,pte_t*page_table,pmd_t*pmd,unsignedintflags){structpage*page;// 如果是读缺页,为了节省内存,内核会直接将其映射到一个全局只读的“零页”(Zero Page)if(!(flags&FAULT_FLAG_WRITE)){entry=pte_mkspecial(pfn_to_pte(my_zero_pfn(address)));// ... 填入页表项 ...return0;}// JVM 执行的是写操作,必须分配真实的物理页帧// 从伙伴系统(Buddy System)的高端或普通内存区申请一个全零的物理页框(Page Frame)page=alloc_zeroed_user_highpage_movable(vma,address);if(!page)returnVM_FAULT_OOM;// 系统物理内存耗尽,触发 OS 级别的 OOM Killer// 将物理页的页框号(PFN)和 VMA 的 RW 权限结合,生成标准的页表项(PTE)pte_tentry=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);return0;}当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:
voidos::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 -> 物理页映射的完整闭环,运行期的物理内存开销一步到位,彻底抹平了后续的缺页中断隐患。