第 16 章 · JVM:内存、GC 与性能调优
本章目标:画出 JVM 运行时内存结构并说清各区域存什么;理解对象生命周期与 GC Roots 可达性分析;掌握标记-清除/复制/标记-整理三大算法与分代假说;能为服务选型 Parallel / G1 / ZGC 收集器;读懂 GC 日志;会用 jps/jstat/jmap/jstack/JFR/Arthas 工具链;完成一次 OOM 排查演练;了解双亲委派类加载;树立「先测量、再调优」的方法论;对照 python-dev 的引用计数与分代回收。
学时建议:5~6 小时(含 2 小时实验)
前置:完成 ch01 JVM 简述、ch10 多线程、ch15 并发进阶。
16.1 场景说明:凌晨两点的告警
toolkit-demo 报表服务上线三个月,凌晨批量任务时段监控告警:CPU 飙到 90%、接口 P99 从 50ms 涨到 2s,最后 OutOfMemoryError: Java heap space 进程退出。不会 JVM 的工程师只能重启;会 JVM 的工程师能回答三个问题:
① 内存涨到哪里去了?(看堆、看 Dump)
② GC 为什么这么频繁?(看日志、看回收器)
③ 参数该不该调,调哪个?(先测量再动手)
本章把这三个问题逐项落实为可操作的技能。
说明:本章实验均在本地 ~/learn-java 进行,压测数值为教学虚构示意。
16.2 JVM 运行时内存结构
┌──────────────────────── JVM 进程 ────────────────────────┐
│ 堆 Heap(线程共享,GC 主战场) │
│ ├─ 年轻代:Eden + Survivor0 + Survivor1 │
│ └─ 老年代:长期存活对象 │
│ 元空间 Metaspace(类元数据,本地内存) │
│ 虚拟机栈(每线程一条,栈帧=方法调用:局部变量/操作数栈) │
│ 本地方法栈(native 方法) │
│ 程序计数器(每线程一条,记录执行位置,唯一不 OOM 的区域) │
│ 直接内存 Direct Memory(NIO Buffer,堆外) │
└──────────────────────────────────────────────────────────┘
| 区域 | 存什么 | 溢出形态 |
|---|---|---|
| 堆 | new 出来的对象 | OutOfMemoryError: Java heap space |
| 元空间 | 类定义、方法元数据 | OutOfMemoryError: Metaspace |
| 栈 | 方法调用的局部变量 | StackOverflowError(无限递归) |
| 直接内存 | NIO ByteBuffer.allocateDirect | OutOfMemoryError: Direct buffer memory |
16.3 对象生命周期与 GC Roots
对象从生到死的判定靠可达性分析:从 GC Roots 出发走引用链,走不到的就是垃圾:
GC Roots(起点)
├─ 虚拟机栈局部变量(正在执行的方法里的引用)
├─ 静态字段(类级别的引用)
├─ JNI 引用
└─ 活跃线程对象
│
▼ 引用链
可达对象 → 存活 不可达对象 → 回收
Product p = new Product("BK-001", "Java 基础", 59.9); // 栈上 p 引用堆中对象 → 可达
p = null; // 链断了 → 下次 GC 可回收
| 引用类型 | 强度 | 回收时机 |
|---|---|---|
| 强引用 | 默认 | 永不(除非不可达) |
软引用 SoftReference | 内存不足才回收 | 缓存场景 |
弱引用 WeakReference | 下次 GC 必回收 | ThreadLocalMap 的 key |
虚引用 PhantomReference | 仅作回收通知 | 堆外内存管理 |
16.4 三大回收算法与分代假说
| 算法 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 标记-清除 | 标记后原地删除 | 简单 | 内存碎片 |
| 复制 | 存活对象搬到另一半 | 无碎片、快 | 浪费一半空间 |
| 标记-整理 | 标记后向一端压缩 | 无碎片不浪费 | 移动成本高 |
分代假说(经验规律):绝大多数对象朝生夕死;熬过几轮的对象大概率长期存活。于是堆分年轻代/老年代:
新对象 → Eden ──Minor GC──> 存活复制到 Survivor ──熬过年齡阈值──> 老年代
(复制算法,频繁但快) (标记-整理,不频繁但慢)
16.5 主流收集器与选型
| 收集器 | 特点 | 适用 |
|---|---|---|
| Parallel(JDK 8 默认) | 多线程吞吐优先,STW 较长 | 批处理、对延迟不敏感 |
| G1(JDK 9+ 默认) | 分 Region,可设停顿目标 | 大多数服务的稳妥默认 |
| ZGC / Shenandoah | 亚毫秒停顿,并发整理 | 大堆(>16G)、低延迟交易 |
选型口诀:默认 G1 起步;堆特别大或延迟极敏感再评估 ZGC;离线批跑 Parallel 足够。切勿「听说 ZGC 快」就盲目换——停顿之外的吞吐、内存占用都要测量。
16.6 常用 JVM 参数
java -Xms512m -Xmx2g \ # 堆初始/最大(生产建议设相等,避免动态扩缩抖动)
-XX:+UseG1GC \ # 指定收集器
-XX:MaxGCPauseMillis=200 \ # G1 停顿目标
-Xlog:gc*:file=gc.log:time,level,tags \ # JDK 9+ 统一日志
-XX:+HeapDumpOnOutOfMemoryError \ # OOM 时自动 dump
-XX:HeapDumpPath=./dumps \ # dump 存放目录
-jar app.jar
| 参数 | 说明 |
|---|---|
-Xms / -Xmx | 堆初始/最大值 |
-XX:MaxMetaspaceSize | 元空间上限(防类加载泄漏撑爆本地内存) |
-XX:+HeapDumpOnOutOfMemoryError | 排障必备,生产也应打开 |
-Xlog:gc* | JDK 9+ GC 日志(替代旧的 -XX:+PrintGCDetails) |
16.7 读懂 GC 日志
一行典型的 G1 日志(JDK 21):
[2026-08-19T02:13:45.102+0800][info][gc] GC(35) Pause Young (Normal) (G1 Evacuation Pause) 512M->368M(1024M) 12.345ms
拆解:GC(35) 第 35 次;Pause Young 年轻代回收;512M->368M(1024M) 回收前 512M、回收后 368M、总堆 1024M;12.345ms 停顿耗时。
关注信号:
| 信号 | 可能问题 |
|---|---|
| Full GC 频繁(每小时多次) | 老年代泄漏或堆太小 |
| 回收后内存降幅很小 | 对象都被长引用攥着(泄漏嫌疑) |
| 单次停顿超目标值 | Region 过大 / 存活对象过多 |
| Promotion 失败 | 老年代空间不足,年轻代被迫提前晋升 |
16.8 工具链速查
| 工具 | 用途 | 示例 |
|---|---|---|
jps -l | 找进程 | jps -l |
jstat | 实时 GC 统计 | jstat -gcutil <pid> 1s(每秒刷新各代使用率) |
jmap | 堆 dump / 直方图 | jmap -dump:live,format=b,file=heap.hprof <pid> |
jstack | 线程快照 | 死锁排查(ch15) |
jcmd | 瑞士军刀 | jcmd <pid> GC.heap_info |
| JFR | 低开销飞行记录 | jcmd <pid> JFR.start duration=60s filename=rec.jfr |
| Arthas | 在线诊断(阿里开源) | dashboard、watch、trace 不重启查问题 |
| MAT / VisualVM | 图形化分析 dump | 找支配树、查泄漏 suspects |
jstat 输出解读:YGC/YGCT 年轻代次数/耗时;FGC/FGCT Full GC 次数/耗时——FGC 持续增长就是内存问题的红灯。