前言

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

虚拟内存状态演进剖析

操作系统虚拟内存与页表管理的基础结构

Linux 内核通过 mm_struct 结构体管理进程的虚拟地址空间(Virtual Address Space, VAS)。该空间被划分为多个连续的虚拟内存区域(Virtual Memory Areas, VMA),由 vm_area_struct 链表及红黑树进行检索。

+---------------------------------------------------------------------------------+
|                                 Process mm_struct                               |
|  +-------------------+      +-------------------------------------------------+ |
|  |   mm_rb (Tree)    | ---> | vm_area_struct (VMA)                            | |
|  +-------------------+      |  - vm_start: 0x7f0000000000                     | |
|                             |  - vm_end:   0x7f0400000000                     | |
|                             |  - vm_flags: VM_READ | VM_WRITE (Committed)     | |
|                             +-------------------------------------------------+ |
+---------------------------------------------------------------------------------+
                                      |
                                      v Page Fault (Demand Paging)
+---------------------------------------------------------------------------------+
|                       x86_64 4-Level Page Table Architecture                     |
|  PGD (Page Global Directory) -> PUD (Page Upper) -> PMD (Middle) -> PTE (Entry) |
|                                                                     |           |
|                                                                     v           |
|                                                          +--------------------+ |
|                                                          | Physical Page Frame| |
|                                                          | (RAM: 4KB / 2MB)   | |
|                                                          +--------------------+ |
+---------------------------------------------------------------------------------+

硬件 MMU 在进行地址转换时,需通过多级页表(x86_64 下通常为 PGD → \to PUD → \to PMD → \to PTE 4级页表)查找虚拟地址对应的物理页帧(Page Frame)。只有当 PTE 中设置了 Present 标志位,且包含了有效的物理帧地址(PFN)时,虚拟内存才真正转换为物理内存,并计入进程的 RSS(Resident Set Size)。


内存状态演进过程

从 OS 内核与 JVM 协作的角度来看,内存状态的演进可分为以下四个阶段:

+------------------+    mmap(PROT_NONE)     +-------------------+
|     Unmapped     | ---------------------> |     Reserved      |
| (未在VMA中注册)  |                        | (VMA注册, PROT_NONE)|
+------------------+                        +-------------------+
                                                      |
                                                      | mprotect(PROT_READ|WRITE)
                                                      v
+------------------+    Write Access (#PF)   +-------------------+
|  Resident (RSS)  | <--------------------- |     Committed     |
| (PTE有效, 物理页) |                        | (VMA属性修改为RW) |
+------------------+                        +-------------------+

1. Unmapped(未映射阶段)

虚拟地址未在进程的 mm_struct 的 VMA 树中注册。访问该阶段的地址将引发硬件级异常,内核捕获后直接向进程发送 SIGSEGV 信号。

2. Reserved(预留阶段)

JVM 启动时(例如基于 -Xms-Xmx 计算出的最大堆空间),需要先锁定一段连续的虚拟地址范围。

  • 内核行为:系统调用 mmap(..., PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, ...)。内核在进程中分配并注册一个 vm_area_struct(VMA),将其访问权限 vm_page_prot 设置为无权限(PROT_NONE)。
  • 页表与物理内存不分配物理内存,不建立任何 PTE 页表映射
  • 系统指标:VSZ(虚拟内存大小)按预留尺寸增加,RSS 无任何变化,Page Fault 次数为 0。

3. Committed(提交阶段)

当 JVM 准备使用某段已被 Reserved 的内存(如堆初始化或空间扩展)时,将其转换为 Committed 状态。

  • 内核行为:系统调用 mprotect(..., PROT_READ | PROT_WRITE)。内核更新对应 VMA 的访问权限标志 (vm_flags |= VM_READ | VM_WRITE)。
  • 页表与物理内存:内核仅在此修改了 VMA 的合法性控制属性。仍未分配真正的物理页帧,PTE 依然未填充(Unpopulated/Invalid)
  • 系统指标:VSZ 保持不变,RSS 依然不会增加(在未发生实际读写前)。

4. Resident / RSS(物理驻留阶段)

JVM 真正向此内存区域写入数据时(如 GC 擦除、TLAB 内存分配初始化),触发内存状态向 Resident 演进。

  • 内核行为
  1. CPU 触发缺页中断(Page Fault),硬件保存上下文并切换至内核态,调用缺页处理函数 do_anonymous_page()
  2. 内核检查该虚拟地址对应的 VMA 权限,确认其具有 VM_WRITE 权限。
  3. 内核从物理页帧分配器(Buddy System)拉取一个零初始化的 4KB/2MB 物理页帧(Page Frame)。
  4. 填充多级页表,将物理页帧地址写入最底层的页表项(PTE),并设置 Present=1Writable=1
  5. 刷新 CPU 的 TLB 缓存,重新执行触发缺页中断的写入指令。
  • 系统指标:RSS 增加,内核维护的 minflt(Minor Page Fault)计数器加 1。

HotSpot 源代码实现分析

HotSpot JVM 在底层通过 ReservedSpaceVirtualSpace 对操作系统 API 进行抽象。以下代码分析基于 OpenJDK 源码(src/hotspot/os/linux/os_linux.cppsrc/hotspot/share/memory/virtualspace.cpp)。

1. Reserved 阶段:os::pd_reserve_memory

在 Linux 环境下,os::pd_reserve_memory 负责调用 mmap 分配初始连续虚拟空间:

// 源码路径: src/hotspot/os/linux/os_linux.cpp
char* os::pd_reserve_memory(size_t bytes, char* requested_addr, size_t alignment_hint) {
  
  // 标志位设定:
  // MAP_PRIVATE: 私有写时复制(COW)映射,不与其他进程共享
  // MAP_ANONYMOUS: 匿名映射,不关联磁盘文件描述符,直接由 OS 零页充当
  // MAP_NORESERVE: 关键标志!告诉内核不要为此映射预留 Swap 空间(允许 Overcommit)
  int flags = MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE;

  // 【内核动作】:PROT_NONE 标志指示该段虚拟内存不允许读、写、执行。
  // 内核只需在 mm_struct 的红黑树中插入一个新的 vm_area_struct (VMA)。
  // 此时没有任何物理 Page Frame 被绑定,页表项 (PTE) 完全未创建。
  void* p = ::mmap(requested_addr, bytes, PROT_NONE, flags, -1, 0);

  if (p == MAP_FAILED) {
    return NULL;
  }

  // 校验返回的虚拟地址是否符合 JVM 要求的对齐规则(如 Transparent Huge Pages 要求的 2MB 对齐)
  if (alignment_hint > 0 && ((uintptr_t)p % alignment_hint) != 0) {
    // 若不对齐,JVM 会做 unmap 并重新按对齐边界切割申请
    // 此处省略对齐纠偏逻辑...
  }

  return (char*)p; // 返回申请成功但不可读写的连续虚拟地址首地址
}

2. Committed 阶段:os::pd_commit_memory

当 JVM 需要使用部分已预留的内存时,通过 os::pd_commit_memory 调用 mprotect 修改 VMA 访问权限:

// 源码路径: src/hotspot/os/linux/os_linux.cpp
bool os::pd_commit_memory(char* addr, size_t size, size_t alignment_hint, bool exec) {
  
  // 根据入参确定申请的内存权限:通常为可读可写 (PROT_READ | PROT_WRITE)
  int prot = exec ? (PROT_READ | PROT_WRITE | PROT_EXEC) : (PROT_READ | PROT_WRITE);

  // 【内核动作】:修改对应虚拟地址区间 (addr ~ addr+size) 的 VMA 属性。
  // 使得该区域对 CPU 读写指令合法。
  // 注意:mprotect 返回 0 仅代表 VMA 标志更新成功,
  // 物理内存(RAM)仍然没有分配,PTE 依然全空。此时 RSS 并不会增加!
  int res = ::mprotect(addr, size, prot);
  if (res == 0) {
    return true;
  }

  return false;
}

3. VirtualSpace 的管理与 Pre-touch 机制

HotSpot 内部使用 VirtualSpace 跟踪管理已 Reserved 内存中的 Committed 边界(即 _low_high 指针):

// 源码路径: src/hotspot/share/memory/virtualspace.cpp
bool VirtualSpace::expand_by(size_t bytes, bool pre_touch) {
  if (bytes == 0) return true;

  char* uncommitted_high = high(); // 当前已经 Committed 区域的上限首地址
  char* new_high = uncommitted_high + bytes; // 扩展后的新 Committed 上限

  // 1. 调用底层 OS API 修改 VMA 权限,将 [uncommitted_high, new_high) 设为 Committed
  if (!os::commit_memory(uncommitted_high, bytes, _alignment, _executable)) {
    return false;
  }

  // 2. 更新 VirtualSpace 内部的高位指针
  _high = new_high;

  // 3. 【强制物理映射 (Pre-Touch)】:
  // 若 JVM 启动参数开启了 -XX:+AlwaysPreTouch 或显式指定了 pre_touch,
  // JVM 将主动向新 Committed 的虚拟地址逐页写入 0,触发缺页中断。
  if (pre_touch || AlwaysPreTouch) {
    os::touch_memory(uncommitted_high, bytes, _alignment);
  }

  return true;
}

// 逐页写入触发缺页中断的底层实现
void os::touch_memory(char* start, size_t size, size_t page_size) {
  char* end = start + size;
  // 按照 page_size (例如 4KB 或 2MB) 为步长进行循环
  for (char* p = start; p < end; p += page_size) {
    // 【缺页中断触发点】:
    // 强制执行一次原子的写入/读取操作。CPU 在执行此指令时发现该虚拟地址
    // 对应的 PTE invalid,从而触发硬件缺页异常 (#PF)。
    // Linux 内核捕获后分配真实的物理 Page Frame,填充 PTE,并清零物理页。
    // 只有在此操作完成后,该页才真正被打入进程的 RSS!
    *p = 0; 
  }
}

4. 内存归还阶段:os::pd_uncommit_memory

当 GC 决定将闲置内存归还给操作系统时(例如 G1/ZGC 在 GC 后的内存收缩),会触发 uncommit 操作:

// 源码路径: src/hotspot/os/linux/os_linux.cpp
bool os::pd_uncommit_memory(char* addr, size_t size) {
  
  // 方案 A:直接修改 VMA 权限为 PROT_NONE
  // 效果:将地址空间置回 Reserved 状态,如果后续访问会重新报 SIGSEGV
  ::mprotect(addr, size, PROT_NONE);

  // 方案 B(优化路径):调用 madvise 告知内核物理页不再需要
  // 【内核动作】:MADV_DONTNEED 标记告诉内核“我已经用完这块物理内存了”。
  // 内核会立即清空该段虚拟地址对应的 PTE,解绑物理页帧,将其释放回 Buddy System。
  // 效果:该段内存的 RSS 瞬间降低,但虚拟地址空间(VSZ)以及 VMA 结构仍然保留!
  // 当 JVM 再次读写该内存时,会自动重新触发缺页中断并分配全新的物理页帧。
  ::madvise(addr, size, MADV_DONTNEED);

  return true;
}


RSS 与 堆/非堆物理占用的精确拆解

在应用监控中,常用 top 中的 RES(即 RSS)来评估 JVM 物理内存占用。RSS 并非仅由 JVM Java 堆组成,它是所有堆内、堆外以及 JVM 引擎自身基础设施所占用的物理内存之和:

RSS Total = RSS Heap + RSS Metaspace + RSS CodeCache + RSS ThreadStack + RSS DirectBuffer + RSS GC_Overhead + RSS PageTable \text{RSS}_{\text{Total}} = \text{RSS}_{\text{Heap}} + \text{RSS}_{\text{Metaspace}} + \text{RSS}_{\text{CodeCache}} + \text{RSS}_{\text{ThreadStack}} + \text{RSS}_{\text{DirectBuffer}} + \text{RSS}_{\text{GC\_Overhead}} + \text{RSS}_{\text{PageTable}} RSSTotal=RSSHeap+RSSMetaspace+RSSCodeCache+RSSThreadStack+RSSDirectBuffer+RSSGC_Overhead+RSSPageTable

+-----------------------------------------------------------------------------------+
|                                 Total Process RSS                                 |
+-------------------+--------------------+-------------------+----------------------+
|     Java Heap     |  Native Metaspace  |     Code Cache    |   Thread Native Stacks|
| (Eden/Surv/Tenured| (Klass/Method Meta)| (JIT Compiled Code| (pthread, Guard Pages|
|  Pre-touched/Fault|  ChunkManager/Node)|  nmethod allocation|  Frame Allocation)   |
+-------------------+--------------------+-------------------+----------------------+
| DirectByteBuffers | GC Data Structures | Page Table Cost   |  glibc Alloc Arena   |
| (Unsafe/malloc    | (CardTable, RSet,  | (PTEs in kernel:  | (ptmalloc arenas,    |
|  native memory)   |  Mark Bitmap)      |  8 Bytes per PTE) |  fragmentation)      |
+-------------------+--------------------+-------------------+----------------------+

1. Heap RSS(堆物理内存占用)

  • 计算关系 Heap RSS ≤ Heap Committed ≤ Heap Reserved  ( − X m x ) \text{Heap RSS} \le \text{Heap Committed} \le \text{Heap Reserved } (-Xmx) Heap RSSHeap CommittedHeap Reserved (Xmx)
  • 特征:仅当对象实际写入该页,或者显式指定了 -XX:+AlwaysPreTouch 时,Committed 的堆内存才会转换为 Heap RSS。未被读写过的 Eden 区或 Tenured 区页面不占用任何物理内存

2. Metaspace RSS(元空间物理内存)

  • 管理机制:Metaspace 放弃了传统的单一连续 ReservedSpace 管理模式,采用基于链表的 VirtualSpaceNode 多块组合管理。
  • 物理分配:类加载器申请元数据空间时,由 ChunkManager 分配 Metachunk。只有被写入 KlassMethod 等 C++ 对象结构的元空间页面才会引发 Page Fault 并被计入 RSS。

3. CodeCache RSS(代码缓存区)

  • 组成:存储 JIT 编译器(C1/C2)编译生成的 Native 机器码(nmethod)以及 JNI 存根。
  • 物理分配:默认情况下,CodeCache 在预留虚拟空间后,随着 JIT 编译任务的递增,逐步以 CodeBuffer 为单位 Commit 并触发 Page Fault 计入 RSS。

4. Thread Native Stack RSS(线程栈物理内存)

  • 组成:每个 Java 线程由 OS 底层 pthread_create 创建,包含线程的 Native 栈空间。
  • 物理分配:通过 -Xss(例如 1MB)指定的参数是虚拟地址空间预留值(Reserved)
  • JVM 会在栈底通过 mprotect(PROT_NONE) 保护性设置 1~2 个页作为 Guard Page(用于捕获 StackOverflowError)。
  • 实际的线程栈 RSS 仅仅取决于当前线程调用栈的最大深度所触碰过的物理页数量,并非只要创建一个线程就立刻扣除 1MB 物理内存。

5. Direct ByteBuffers / Unsafe Native Memory

  • 组成:通过 ByteBuffer.allocateDirect()Unsafe.allocateMemory() 分配的堆外内存。
  • 物理分配:直接调用 C 运行库的 malloc() 或 Linux 的 mmap()。这部分内存完全绕过 JVM 的 VirtualSpace 统计,但会直接反映在 Linux 系统的 RSS 中。由于 glibc ptmalloc 的内存池机制(分配区 Arenas),大量小额 Native 分配容易产生内存碎片,导致 RSS 高企却无法释放。

6. GC 内部数据结构开销(GC Overhead)

  • 组成:GC 算法维护的辅助物理数据结构,包括但不限于:

  • Card Table(卡表):老年代跨代引用标记,大小固定为堆大小的 1 / 512 1/512 1/512

  • Remembered Sets (RSet):G1 收集器中记录 Region 间引用关系的 Hash 结构。

  • Mark Bitmap(标记位图):并发标记阶段用于记录对象存活状态的位图,通常占用堆大小的 1 / 64 1/64 1/64 1 / 32 1/32 1/32

  • 物理分配:这些数据结构在 GC 初始化时即进行承诺与预读写,大部分会直接全额打入 RSS

7. 页表自身的物理消耗(Page Table Overhead)

  • 原理:在 x86_64 体系下,每个 PTE(页表项)占用 8 字节。映射 4KB 的物理页需要一层 PTE。
  • 计算:当 JVM 申请了 512GB 的超大堆空间且采用默认 4KB 页大小时:

PTE 数量 = 512 × 1024 × 1024 × 1024  Bytes 4096  Bytes = 134 , 217 , 728  个 \text{PTE 数量} = \frac{512 \times 1024 \times 1024 \times 1024 \text{ Bytes}}{4096 \text{ Bytes}} = 134,217,728 \text{ 个} PTE 数量=4096 Bytes512×1024×1024×1024 Bytes=134,217,728 

仅最底层页表占用物理内存 = 134 , 217 , 728 × 8  Bytes = 1 , 073 , 741 , 824  Bytes ≈ 1 GB \text{仅最底层页表占用物理内存} = 134,217,728 \times 8 \text{ Bytes} = 1,073,741,824 \text{ Bytes} \approx 1\text{GB} 仅最底层页表占用物理内存=134,217,728×8 Bytes=1,073,741,824 Bytes1GB

这意味着,即使堆内一分钱数据都没放,仅仅为了维持 512GB 虚拟地址的页表映射,Linux 内核就需要消耗 ~1GB 的系统 Native 物理内存

  • 优化策略:开启静态大页(HugeTLB, 2MB/1GB 页大小)可以将页表项数量降低 512 倍,显著减少内核页表对 RSS 的无声侵占。

内存演进与诊断的量化对比表

下表列出了 JVM 各组件从申请到物理落地的内核 API、状态响应及排查观察点:

JVM 内存区域 分配/管理类 底层 OS API VSZ 影响 RSS 影响触发时机 诊断与观测命令
Java Heap ReservedHeapSpace
VirtualSpace
mmap(PROT_NONE)
mprotect(PROT_RW)
初始化即全额增加 (-Xmx) 对象写入/TLAB 分配或 -XX:+AlwaysPreTouch /proc/<pid>/smaps jcmd <pid> VM.native_memory
Metaspace MetaspaceArena``VirtualSpaceNode mmap``mprotect 动态递增(受限于 MaxMetaspaceSize) 类加载、字节码解析写入 Klass 结构体时 jcmd <pid> VM.metaspace
pmap -x <pid>
Code Cache CodeHeap
ReservedSpace
mmap
mprotect
初始化即分配预留值 JIT 编译器把编译好的
Native 机器码写入时
jcmd <pid> Compiler.codeblobs
Thread Stack os::create_thread pthread_create
mprotect(PROT_NONE)
线程数 × \times × -Xss 方法调用深入、局部变量
压栈触碰新页时
jcmd <pid> Thread.print
/proc/<pid>/maps
Direct Buffer Unsafe.allocateMemory malloc (glibc)
mmap
动态增加 Buffer 创建并写入数据时 perf top
valgrind --tool=massif
Logo

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

更多推荐