前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

JVM虚拟延迟内存分配机制源码剖析

作为软件开发工程师,分析 JVM(以 OpenJDK8为例)的虚拟内存管理,本质上是解构 HotSpot 运行时环境(Runtime)与 Linux 内核虚拟内存子系统(VMS)的协同演进过程。

在 Linux 进程模型中,JVM 视角下的 Reserved(预留)Committed(提交),分别对应了操作系统层面的 分配 VMA 线性区改变页面访问权限/触发物理内存承诺。而真正的物理内存换入,则是通过延迟分配(Lazy Allocation)机制机制下的缺页中断(Page Fault)实现的。


架构演进全景

在深入源码前,需明确 JVM 虚拟内存的三阶段演进状态:

  1. Reserved(预留状态):JVM 向内核申请一段连续的虚拟地址空间。内核仅在进程的 mm_struct 中新建或划分一段 vm_area_struct(VMA),其页表项(PTE)尚未建立,且访问权限通常设为 PROT_NONE。此时不占用任何物理内存与 Swap 空间
  2. Committed(提交状态):JVM 声明将要使用某段已预留的地址空间。通过内核系统调用修改 VMA 的权限为 PROT_READ | PROT_WRITE。此时内核会进行 Overcommit 审计,并在逻辑上承诺这段内存可用,但依然没有分配物理内存页帧(Page Frame)
  3. 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_startvm_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 但从未读写的虚拟地址

  1. MMU 拦截:CPU 的内存管理单元(MMU)拦截到这条写入指令,尝试通过多级页表(PGD -> PUD -> PMD -> PTE)将 rax 中的虚拟地址翻译为物理地址。
  2. 触发异常:MMU 发现该虚拟地址对应的页表项(PTE)的 Present 标志位为 0(表示该页不在物理内存中)。
  3. 硬件中断: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} 4GBReserved 空间。接着,对其中 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 -> 物理页映射的完整闭环,运行期的物理内存开销一步到位,彻底抹平了后续的缺页中断隐患。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐