JVM 与 Linux 内存:分区、分配与 GC 边界
本文将 JVM 的每个内存分区与 Linux 底层的内存分配机制一一对应,说清楚每个区域用了哪个系统调用、地址是否稳定、GC 能不能管,以及用户侧有哪些 API 可以干预。
目录
| 章节 | 说明 |
|---|---|
| Linux 内存分配基础 | mmap / malloc / brk 的原理与区别 |
| JVM 内存分区总览 | 各分区与 Linux 机制的对应关系 |
| Java 堆(Heap) | mmap 预留 + 按需提交,GC 管理 |
| 元空间(Metaspace) | mmap 分配类元数据,不受堆大小限制 |
| 线程栈 | mmap 独立映射,固定大小 |
| 直接内存(Direct Memory) | malloc / mmap,用户可控,GC 不直接管 |
| 临时堆外缓冲区 | 隐藏在 JDK 内部,用户不可见 |
| JIT 代码缓存(CodeCache) | mmap 可执行段,存放编译后机器码 |
| 大页优化(HugePage) | 减少 TLB Miss,需 Linux 特殊配置 |
| SpringBoot 应用的内存操作全景 | 日常业务操作落到哪个内存区域 |
| 内存分配对比总览 | 各区域横向对比一览表 |
Linux 内存分配基础
JVM 是一个运行在 Linux 上的普通进程,它对内存的所有操作最终都通过 Linux 的三种核心机制完成。
mmap(内存映射)
# 系统调用签名
mmap(addr, length, prot, flags, fd, offset)
MAP_ANONYMOUS | MAP_PRIVATE 组合表示:向 OS 申请一块与任何文件无关的私有虚拟地址空间。
关键特性:
- 申请的是虚拟地址,不是物理内存;物理页在第一次读写时才由 OS 通过缺页中断分配
- 地址在
munmap之前永久固定,GC 不会移动它 - 可通过
madvise(MADV_DONTNEED)提示 OS 回收物理页(虚拟地址保留,物理页释放) - G1、ZGC、Shenandoah 都大量依赖 mmap 管理堆内存
malloc / free(C 堆)
JVM 的 C++ 运行时组件(解释器、内部数据结构、JNI 层)通过标准 C 库 malloc 分配。glibc 的 ptmalloc 在内部:
- 小块(< 128KB):复用
brk扩展的堆顶空间 - 大块(≥ 128KB):直接调用
mmap(MAP_ANONYMOUS)独立映射,释放时立即munmap
brk / sbrk(数据段扩展)
早期 Unix 用法,移动进程数据段的上边界来分配内存。现代 JVM 不直接调用,由 glibc 内部使用。
JVM 内存分区总览
Java 堆(Heap)
分配机制
JVM 启动时用 mmap 一次性预留整个堆的虚拟地址空间(由 -Xmx 决定),然后根据实际需要逐步提交(让 OS 映射物理页)。
启动时:mmap(NULL, Xmx, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0)
→ 仅占虚拟地址,不消耗物理内存
初始提交:mprotect(addr, Xms, PROT_READ|PROT_WRITE)
→ 触发缺页中断,OS 分配物理页
GC 与物理内存归还
GC 完成后,JVM 可以把空闲的物理页归还给 OS(虚拟地址段保留):
madvise(addr, length, MADV_DONTNEED)
→ OS 立即回收物理页,下次访问重新触发缺页中断
G1 在 Full GC 后默认执行此操作;ZGC 更激进,每轮 GC 后都会归还。
对象分配路径
线程本地分配(TLAB): 指针碰撞,无锁,极快
↓ TLAB 用满
Eden 区全局分配: CAS 操作,有轻微竞争
↓ Eden 满
Minor GC → Survivor → 老年代晋升
用户侧 API
# 堆大小
-Xms4g -Xmx4g # 建议相等,避免扩缩容时的 mmap/madvise 开销
# TLAB 调优
-XX:TLABSize=512k # 固定 TLAB 大小(默认自适应)
-XX:+PrintTLAB # 打印 TLAB 分配统计
# 物理内存归还行为
-XX:+ZUncommit # ZGC:启用物理内存归还(默认开启)
-XX:ZUncommitDelay=300 # ZGC:归还延迟秒数(默认 300s)
-XX:-ShrinkHeapInSteps # G1:Full GC 后立即归还(默认分步归还)
元空间(Metaspace)
为什么不在堆里
Java 8 之前是 PermGen(永久代),放在堆内,大小固定,容易 OOM。Java 8 改为元空间,移到 native heap,用 mmap 分配,理论上只受系统物理内存限制。
分配机制
类加载时:
MetaspaceArena → Metachunk → 底层 VirtualSpaceNode (mmap)
粒度:2MB 的 VirtualSpaceNode,内部按 Chunk 细分
类卸载时:
ClassLoader 被回收 → 对应的所有 Metachunk 整体释放
→ madvise(MADV_DONTNEED) 归还物理页
关键:元空间的回收粒度是 ClassLoader,不是单个类。单独卸载一个类不会释放任何元空间内存。
用户侧 API
-XX:MetaspaceSize=256m # 触发第一次 GC 的元空间阈值(不是初始大小)
-XX:MaxMetaspaceSize=512m # 上限(不设则无上限,存在 OOM 风险)
-XX:MinMetaspaceFreeRatio=40 # GC 后空闲率低于此值则扩容
-XX:MaxMetaspaceFreeRatio=70 # GC 后空闲率高于此值则缩容
# 诊断
jcmd <pid> VM.metaspace # 查看元空间使用详情
线程栈
分配机制
每创建一个 Java 线程,JVM 调用 pthread_create,OS 为该线程单独 mmap 一块虚拟地址作为栈空间:
mmap(NULL, stack_size, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0)
栈顶附近会有一个 Guard Page(PROT_NONE),访问时触发 SIGSEGV,JVM 捕获后抛出 StackOverflowError。
线程退出时调用 munmap 释放。
地址稳定性
线程栈的地址在线程生命周期内完全固定。轻量级锁正是利用这一点:将 Mark Word 备份到栈帧中的锁记录,以栈地址作为锁的临时持有凭证。
用户侧 API
-Xss512k # 每个线程栈大小(默认 512k~1m,视平台)
# Java 21+ 虚拟线程
Thread.ofVirtual().start(...) # 虚拟线程不占用 OS 线程栈,栈帧存在堆上
虚拟线程(Java 21):虚拟线程的栈帧以对象形式存储在 Java 堆中,挂起时只消耗堆内存,恢复时重新映射到载体线程(OS 线程)的栈上。可支持百万级并发而不耗尽线程栈。
直接内存(Direct Memory)
是什么
ByteBuffer.allocateDirect(size) 分配的内存,在 Java heap 之外,由 malloc(小块)或 mmap(大块)分配。地址在 GC 期间永远不变,因此可以直接传给 OS 的 I/O 系统调用,无需中转。
为什么比 HeapByteBuffer 快
HeapByteBuffer 的 I/O 路径:
Java heap(地址不稳定)
↓ JVM copy 一次
临时 native buffer(地址固定)
↓ 系统调用
OS kernel buffer → 磁盘/网络
DirectByteBuffer 的 I/O 路径:
Direct native buffer(地址固定)
↓ 系统调用,无需 copy
OS kernel buffer → 磁盘/网络
省掉了一次 Java heap → native buffer 的数据 copy。
GC 与回收
DirectByteBuffer 的 Java 对象(引用、cleaner)在堆中,实际 native 内存不在堆中,GC 无法直接回收 native 内存。回收路径:
Java 对象被 GC 回收
→ PhantomReference / Cleaner 回调
→ Unsafe.freeMemory() / munmap
→ native 内存释放
这意味着:堆内存充足但 native 内存耗尽时,GC 不会主动触发,可能导致 OutOfMemoryError: Direct buffer memory。
用户侧 API
// 分配
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024);
// 手动释放(避免等 GC)
((DirectBuffer) buf).cleaner().clean();
// 或者用 Unsafe(不推荐,仅限框架代码)
// sun.misc.Unsafe.allocateMemory / freeMemory
# JVM 参数
-XX:MaxDirectMemorySize=2g # 直接内存上限(默认等于 -Xmx)
# 监控
jcmd <pid> VM.native_memory # 需要加 -XX:NativeMemoryTracking=summary 启动
// 池化复用(Netty 的做法,生产推荐)
PooledByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
ByteBuf buf = allocator.directBuffer(1024);
// ... 使用后
buf.release(); // 归还到池,不触发 free
临时堆外缓冲区
是什么
当你对 HeapByteBuffer 执行 I/O 操作(FileChannel.read(heapBuf))时,JDK 在 sun.nio.ch.Util 内部自动分配一块临时的 native buffer 作为中转,用户完全不可见、不可控。
// JDK 内部(sun.nio.ch.Util),用户不可访问
static ByteBuffer getTemporaryDirectBuffer(int size) {
BufferCache cache = bufferCache.get(); // 线程本地缓存
ByteBuffer buf = cache.get(size);
if (buf == null) {
buf = ByteBuffer.allocateDirect(size); // 缓存没有才 malloc
}
return buf;
}
线程本地缓存
每个线程维护一个 BufferCache,缓存最近使用过的临时 buffer,避免每次 I/O 都触发 malloc/free。BufferCache 的容量有限,超出部分会立即释放。
存在的意义
OS 的 I/O 系统调用(read/write)要求传入的内存地址在整个调用期间保持不变。Java 堆对象随时可能被 GC 移动,无法直接传给内核。临时 native buffer 的地址在 malloc 后不变,满足内核要求。
C 语言为什么不需要:C 程序没有 GC,
malloc出来的内存地址在free之前永远不动,可以直接把任何堆地址传给系统调用,无需中转。
如何彻底消除它
| 方法 | 说明 |
|---|---|
ByteBuffer.allocateDirect() | 直接内存,地址固定,I/O 时跳过中转 |
FileChannel.transferTo() | 零拷贝,OS 层面直接搬运,连 kernel buffer copy 也省了 |
| Netty DirectByteBuf | 池化 + 直接内存,高性能网络 I/O 的标准做法 |
JIT 代码缓存(CodeCache)
分配机制
JIT 编译器(C1/C2)将热点字节码编译为机器码后,需要存放在可执行的内存段中。JVM 用 mmap 分配带有 PROT_EXEC 权限的内存作为 CodeCache:
mmap(NULL, size, PROT_READ|PROT_WRITE|PROT_EXEC,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0)
分段结构(Java 9+)
| 段 | 内容 | 特点 |
|---|---|---|
| non-nmethods | JVM 内部 stub、适配器 | 永不回收 |
| profiled nmethods | C1 编译的代码(含 profiling) | 较频繁回收 |
| non-profiled nmethods | C2 编译的优化代码 | 较少回收 |
用户侧 API
-XX:ReservedCodeCacheSize=512m # CodeCache 最大值(默认 240m)
-XX:InitialCodeCacheSize=2m # 初始大小
-XX:+UseCodeCacheFlushing # 满时主动清理冷代码(默认开启)
# 诊断:CodeCache 满会导致 JIT 停止编译,性能严重退化
jcmd <pid> Compiler.codecache # 查看 CodeCache 使用情况
大页优化(HugePage)
为什么有效
CPU 通过 TLB(Translation Lookaside Buffer)缓存虚拟→物理地址映射。默认页大小 4KB,大堆需要大量 TLB 条目,TLB Miss 导致每次缺失都要走多级页表(慢 100 倍以上)。
Linux HugePage(2MB/1GB)让同样的 TLB 条目覆盖更大范围,大幅降低 TLB Miss 率。
两种模式
显式大页(Explicit HugePage):需要 OS 预先分配,JVM 通过 shmget + shmat 使用:
# OS 配置(需 root)
echo 512 > /proc/sys/vm/nr_hugepages # 预留 512 × 2MB = 1GB 大页
# JVM 参数
-XX:+UseLargePages
透明大页(THP,Transparent HugePage):OS 自动合并,JVM 通过 madvise(MADV_HUGEPAGE) 提示:
# OS 配置
echo always > /sys/kernel/mm/transparent_hugepage/enabled
# JVM 参数
-XX:+UseTransparentHugePages # JVM 对大内存区域发出 MADV_HUGEPAGE 提示
⚠️ THP 的
khugepaged后台合并线程可能引入不可预测的延迟抖动,低延迟服务(ZGC 场景)建议用显式大页而非 THP。
SpringBoot 应用的内存操作全景
把常见的业务操作和框架行为对应到具体内存区域,帮助快速定位内存问题。
启动阶段
| 操作 | 内存区域 | 底层机制 | 说明 |
|---|---|---|---|
| JVM 进程启动,预留堆空间 | Java 堆 | mmap(PROT_NONE) | 仅占虚拟地址,不消耗物理内存 |
加载 Spring jar 包中的 .class 文件 | 元空间 | mmap(2MB Chunk) | 类的字节码、常量池、方法元数据全部进元空间 |
实例化 Spring Bean(@Component/@Service) | Java 堆 | TLAB 指针碰撞 | Bean 对象本身在堆,长期存活后晋升老年代 |
| 创建 Tomcat 线程池(默认 200 线程) | 线程栈 × 200 | mmap(MAP_STACK) × 200 | 每线程默认 512KB,200 线程约 100MB 线程栈 |
| Spring AOP 动态生成代理类(CGLIB) | 元空间 | mmap | 每个被代理的 Bean 生成一个新 Class,大量 AOP 会撑大元空间 |
| JIT 开始编译热点 Spring 框架代码 | JIT CodeCache | mmap(PROT_EXEC) | 启动后约 30s~2min,CodeCache 快速增长 |
处理 HTTP 请求阶段
| 操作 | 内存区域 | 底层机制 | 说明 |
|---|---|---|---|
| 方法调用、局部变量、参数传递 | 线程栈(当前线程) | 已分配,栈帧压栈/出栈 | 不触发新系统调用,在已有 mmap 空间内移动栈指针 |
new 创建 DTO / VO / 各种对象 | Java 堆(Eden) | TLAB 指针碰撞 | 请求结束后大部分对象随 Minor GC 回收 |
| JSON 反序列化(Jackson) | Java 堆 | TLAB | 生成大量短命字符串和 Map,Minor GC 压力来源 |
读取本地文件(FileInputStream) | 临时堆外缓冲区 | malloc(线程缓存) | 使用 HeapByteBuffer 时 JDK 自动中转,用户不感知 |
| Spring Cache 存取(Caffeine 本地缓存) | Java 堆(老年代) | TLAB → 晋升 | 缓存对象长期存活,驻留老年代,Full GC 主要压力来源 |
| Redis 操作(Lettuce) | 直接内存 + 线程栈 | malloc/mmap | Netty(Lettuce 底层)用 DirectByteBuf 做网络 I/O |
| MySQL 查询(JDBC) | Java 堆 + 临时堆外缓冲区 | TLAB + malloc | ResultSet 在堆,网络读取时走临时 native buffer 中转 |
上传/下载大文件(MultipartFile) | 临时堆外缓冲区 或 直接内存 | malloc/mmap | 用 HeapBuffer 走临时缓冲区;用 Netty 走直接内存 |
运行时持续行为
| 操作 | 内存区域 | 底层机制 | 说明 |
|---|---|---|---|
| Minor GC(Eden 满) | Java 堆 | madvise(MADV_DONTNEED) 归还空页 | 存活对象复制到 Survivor,死亡对象物理页归还 OS |
| 热点方法被 C2 编译 | JIT CodeCache | mmap(PROT_EXEC) | 编译后性能提升 30%+,CodeCache 占用增加 |
@Async 提交异步任务 | Java 堆(任务队列)+ 线程栈(工作线程) | TLAB + 已有 mmap | Runnable 对象入堆,工作线程用已分配线程栈 |
使用 ThreadLocal | Java 堆(ThreadLocalMap)+ 线程栈(引用) | TLAB | ThreadLocalMap 在堆,泄漏时驻留老年代 |
一个典型请求的内存全貌
HTTP 请求到达 Tomcat 线程
│
├── 线程栈:方法调用链压栈(已有 mmap,移动栈指针)
│
├── Java 堆(Eden):Controller/Service/DTO new 对象 ← TLAB 指针碰撞
│
├── 临时堆外缓冲区:读 MySQL、读本地文件时的 native 中转 ← malloc
│
├── 直接内存:Lettuce 向 Redis 发送网络包 ← Netty DirectByteBuf
│
└── JIT CodeCache:热点代码已编译为机器码直接执行(无额外分配)
请求结束
└── Eden 对象 → Minor GC 回收(大部分)→ 少量晋升 Survivor/老年代
常见内存问题定位思路
| 现象 | 首先怀疑的区域 | 诊断命令 |
|---|---|---|
| 启动后内存持续增长,之后稳定 | 元空间(类加载)+ CodeCache(JIT 预热) | jcmd <pid> VM.metaspace |
| 频繁 Minor GC,老年代缓慢增长 | 堆(缓存/长生命周期对象) | jstat -gc <pid> 1s 观察 OU |
进程 RSS 远大于 -Xmx | 直接内存 / 元空间 / 线程栈 | jcmd <pid> VM.native_memory |
OutOfMemoryError: Direct buffer memory | 直接内存(Netty 池未释放) | -XX:NativeMemoryTracking=summary |
OutOfMemoryError: Metaspace | 元空间(CGLIB/动态类过多) | jcmd <pid> VM.metaspace |
| 线程数暴增,内存随之上涨 | 线程栈(每线程 512KB) | jstack <pid> | grep -c java.lang.Thread |
内存分配对比总览
| 分区 | Linux 机制 | 地址稳定性 | GC 管理 | 用户 API | OOM 类型 |
|---|---|---|---|---|---|
| Java 堆 | mmap 预留 + 按需提交 | 不稳定(GC 移动) | 是 | -Xms -Xmx | Java heap space |
| 元空间 | mmap(2MB Chunk) | 稳定 | 随 ClassLoader 整体回收 | -XX:MaxMetaspaceSize | Metaspace |
| 线程栈 | mmap(每线程独立) | 稳定 | 线程退出时 munmap | -Xss | StackOverflowError |
| 直接内存 | malloc / mmap | 稳定 | Cleaner 回调触发 | ByteBuffer.allocateDirect / -XX:MaxDirectMemorySize | Direct buffer memory |
| 临时堆外缓冲区 | malloc(线程缓存) | 稳定 | 用后立即归还缓存 | 无(JDK 内部) | 无专属 OOM |
| JIT CodeCache | mmap(可执行) | 稳定 | JVM 主动清理冷代码 | -XX:ReservedCodeCacheSize | CodeCache is full |
参考资料
- Java SE 21 虚拟机规范
- HotSpot 虚拟机垃圾回收调优指南
- Linux man page: mmap(2)
- Linux man page: madvise(2)
- 《深入理解 Java 虚拟机》(周志明著)
评论 (0)