JVM虚拟内存状态演进剖析
JVM虚拟内存状态演进剖析
前言
本文旨在记录近期研读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 演进。
- 内核行为:
- CPU 触发缺页中断(Page Fault),硬件保存上下文并切换至内核态,调用缺页处理函数
do_anonymous_page()。 - 内核检查该虚拟地址对应的 VMA 权限,确认其具有
VM_WRITE权限。 - 内核从物理页帧分配器(Buddy System)拉取一个零初始化的 4KB/2MB 物理页帧(Page Frame)。
- 填充多级页表,将物理页帧地址写入最底层的页表项(PTE),并设置
Present=1、Writable=1。 - 刷新 CPU 的 TLB 缓存,重新执行触发缺页中断的写入指令。
- 系统指标:RSS 增加,内核维护的
minflt(Minor Page Fault)计数器加 1。
HotSpot 源代码实现分析
HotSpot JVM 在底层通过 ReservedSpace 与 VirtualSpace 对操作系统 API 进行抽象。以下代码分析基于 OpenJDK 源码(src/hotspot/os/linux/os_linux.cpp 及 src/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 RSS≤Heap Committed≤Heap Reserved (−Xmx)
- 特征:仅当对象实际写入该页,或者显式指定了
-XX:+AlwaysPreTouch时,Committed 的堆内存才会转换为 Heap RSS。未被读写过的 Eden 区或 Tenured 区页面不占用任何物理内存。
2. Metaspace RSS(元空间物理内存)
- 管理机制:Metaspace 放弃了传统的单一连续
ReservedSpace管理模式,采用基于链表的VirtualSpaceNode多块组合管理。 - 物理分配:类加载器申请元数据空间时,由
ChunkManager分配Metachunk。只有被写入Klass、Method等 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 中。由于 glibcptmalloc的内存池机制(分配区 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 Bytes≈1GB
这意味着,即使堆内一分钱数据都没放,仅仅为了维持 512GB 虚拟地址的页表映射,Linux 内核就需要消耗 ~1GB 的系统 Native 物理内存。
- 优化策略:开启静态大页(HugeTLB, 2MB/1GB 页大小)可以将页表项数量降低 512 倍,显著减少内核页表对 RSS 的无声侵占。
内存演进与诊断的量化对比表
下表列出了 JVM 各组件从申请到物理落地的内核 API、状态响应及排查观察点:
| JVM 内存区域 | 分配/管理类 | 底层 OS API | VSZ 影响 | RSS 影响触发时机 | 诊断与观测命令 |
|---|---|---|---|---|---|
| Java Heap | ReservedHeapSpaceVirtualSpace |
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.metaspacepmap -x <pid> |
| Code Cache | CodeHeapReservedSpace |
mmapmprotect |
初始化即分配预留值 | JIT 编译器把编译好的 Native 机器码写入时 |
jcmd <pid> Compiler.codeblobs |
| Thread Stack | os::create_thread |
pthread_createmprotect(PROT_NONE) |
线程数 × \times × -Xss |
方法调用深入、局部变量 压栈触碰新页时 |
jcmd <pid> Thread.print/proc/<pid>/maps |
| Direct Buffer | Unsafe.allocateMemory |
malloc (glibc)或 mmap |
动态增加 | Buffer 创建并写入数据时 | perf topvalgrind --tool=massif |
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)