JVM堆内存本质剖析

堆内存本质剖析

  • 前言
  • 堆内存本质剖析
    • [一、 为什么是 `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)
    • [四、 从系统工程师视角透视:内核中发生了什么?](#四、 从系统工程师视角透视:内核中发生了什么?)

前言

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

堆内存本质剖析

作为一名软件工程师,从操作系统的底层视角来看,JVM 堆内存(Heap)的本质并不是物理内存的直接分配,而是一段连续的虚拟地址空间(Virtual Address Space)的进入与管理状态。 JVM 通过向内核发起系统调用(主要是 mmap,而非 brk),在进程的虚拟地址空间中"圈地"。这块空间在初始时只是操作系统的虚拟内存区域(VMA, Virtual Memory Area)的一项记录,只有在真正写入数据触发缺页异常(Page Fault)时,操作系统才会将物理内存页(Physical Page Frames)映射过去。


一、 为什么是 mmap 而不是 brk

虽然 brksbrk 可以通过移动堆顶指针(program break)来扩展内存,但它在多线程、需要精细化内存管理的现代 JVM 中存在致命缺陷:

  1. 单一连续性限制brk 只能向一个方向单向延伸或收缩,无法自由释放中间被废弃的内存块。
  2. 缺乏权限控制brk 分配的空间无法精细配置页级别的保护权限(如 PROT_NONE 转换为 PROT_READ | PROT_WRITE)。
  3. 无法实现对齐优化 :JVM 堆往往需要垃圾回收器(GC)进行大页(Huge Pages)对齐或卡表(Card Table)对齐,mmap 可以在任意合法的虚拟地址处映射,并天然支持复杂的对齐逻辑。

因此,OpenJDK 8的核心内存分配完全基于匿名内存映射:mmap(..., MAP_PRIVATE | MAP_ANONYMOUS, -1, 0)


二、 OpenJDK 8堆内存的两层抽象

在 OpenJDK 8源码中,虚拟内存的申请被封装在两个高频出现的 C++ 类中:

  1. ReservedSpace(保留空间) :对应系统的 mmap(..., PROT_NONE)。它在进程的虚拟地址空间中占据一段指定大小的连续区域,防止其他线程或 malloc 占用,此时不消耗任何物理内存(RES/RSS)
  2. VirtualSpace(虚拟空间) :建立在 ReservedSpace 之上,负责将其中的某一段或全部虚拟地址"提交"(Commit)。对应系统的 mprotect(..., PROT_READ | PROT_WRITE) 或重新 mmap,此时该段虚拟内存允许读写,随时准备接受物理页的映射

三、 OpenJDK 8核心源码深度剖析

以下源码源自 OpenJDK 8中控制虚拟内存分配的核心文件:hotspot/src/share/vm/runtime/virtualspace.cpphotspot/src/os/linux/vm/os_linux.cpp

1. 虚拟内存空间的初始化:ReservedSpace::initialize

当 JVM 启动,垃圾回收器(如 ParallelScavengeHeapG1CollectedHeap)初始化时,会调用 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 时钟周期损耗。