目录
正在加载目录…
专栏文章
专栏文章
Java 专栏
1. Java:跨平台语言与生态全景 2. Java 并发:JMM、锁与线程池原理 3. JVM:类加载、内存、GC 与性能诊断 4. Java 性能调优:CPU、内存、锁与 IO 排查 5. Java 新特性速查:从 Lambda 到虚拟线程 6. Java 业务开发:高频陷阱与 Code Review 清单 7. Java 核心知识:集合、并发与 JVM 面试要点 8. AQS 原理:同步队列与加锁解锁全流程 9. JVM 与 Linux 内存:分区、分配与 GC 边界 10. AtomicLong 与 LongAdder:并发计数器选型 11. Java 引用与内存泄漏:六类场景与修复 12. Java 测试实践:JUnit、Mockito 与 Testcontainers 13. Java 并发入门:线程、锁与线程池全景

JVM 与 Linux 内存:分区、分配与 GC 边界

发布于 2026-06-17 14:40 · 最后编辑于 2026-07-31 15:51 · 字数 5,227 👁 284 次阅读

本文将 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 内存分区总览

../../assets/09 JVM 内存分区与 Linux 内存分配机制/file 20260605123648544

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 PagePROT_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/freeBufferCache 的容量有限,超出部分会立即释放。

存在的意义

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-nmethodsJVM 内部 stub、适配器永不回收
profiled nmethodsC1 编译的代码(含 profiling)较频繁回收
non-profiled nmethodsC2 编译的优化代码较少回收

用户侧 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/@ServiceJava 堆TLAB 指针碰撞Bean 对象本身在堆,长期存活后晋升老年代
创建 Tomcat 线程池(默认 200 线程)线程栈 × 200mmap(MAP_STACK) × 200每线程默认 512KB,200 线程约 100MB 线程栈
Spring AOP 动态生成代理类(CGLIB)元空间mmap每个被代理的 Bean 生成一个新 Class,大量 AOP 会撑大元空间
JIT 开始编译热点 Spring 框架代码JIT CodeCachemmap(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/mmapNetty(Lettuce 底层)用 DirectByteBuf 做网络 I/O
MySQL 查询(JDBC)Java 堆 + 临时堆外缓冲区TLAB + mallocResultSet 在堆,网络读取时走临时 native buffer 中转
上传/下载大文件(MultipartFile临时堆外缓冲区直接内存malloc/mmap用 HeapBuffer 走临时缓冲区;用 Netty 走直接内存

运行时持续行为

操作内存区域底层机制说明
Minor GC(Eden 满)Java 堆madvise(MADV_DONTNEED) 归还空页存活对象复制到 Survivor,死亡对象物理页归还 OS
热点方法被 C2 编译JIT CodeCachemmap(PROT_EXEC)编译后性能提升 30%+,CodeCache 占用增加
@Async 提交异步任务Java 堆(任务队列)+ 线程栈(工作线程)TLAB + 已有 mmapRunnable 对象入堆,工作线程用已分配线程栈
使用 ThreadLocalJava 堆(ThreadLocalMap)+ 线程栈(引用)TLABThreadLocalMap 在堆,泄漏时驻留老年代

一个典型请求的内存全貌

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 管理用户 APIOOM 类型
Java 堆mmap 预留 + 按需提交不稳定(GC 移动)-Xms -XmxJava heap space
元空间mmap(2MB Chunk)稳定随 ClassLoader 整体回收-XX:MaxMetaspaceSizeMetaspace
线程栈mmap(每线程独立)稳定线程退出时 munmap-XssStackOverflowError
直接内存malloc / mmap稳定Cleaner 回调触发ByteBuffer.allocateDirect / -XX:MaxDirectMemorySizeDirect buffer memory
临时堆外缓冲区malloc(线程缓存)稳定用后立即归还缓存无(JDK 内部)无专属 OOM
JIT CodeCachemmap(可执行)稳定JVM 主动清理冷代码-XX:ReservedCodeCacheSizeCodeCache is full

参考资料

← 返回列表

评论 (0)

暂无评论,来留下第一条吧。
登录注册 后才能发表评论