主题 04垃圾回收与调优
GC 是八股里唯一一个「答错会被当场追问到崩」的题。因为它的每个结论都能被一行
-Xlog:gc 证伪:你说「G1 停顿短」,面试官问短多少;你说「大对象直接进老年代」,
他问那为什么 G1 还会 OOM。
这一章把 JDK 17 的四种收集器放在同一份负载下跑了一遍: 3GB 分配 + 300MB 存活。结果是 —— G1 唯一一个 OOM 的, 而它恰恰是「默认收集器」。原因在 04.4,一个 512KB 的分界线。
04.1为什么 GC 是面试分水岭
同样一句「我们线上用的是 G1」,答得好和答得差差距在能不能说出代价。 下面六个问题,每一个都能靠一次实测回答:
| 你听到的问题 | 它其实在问 | 说错的代价 |
|---|---|---|
| 「G1 和 CMS 有什么区别」 | Region 化分代 vs 物理连续分代 | 只会说「G1 是新一代」→ 追问「那它为什么能预测停顿」 |
| 「Young GC 和 Full GC 差在哪」 | 回收范围 + 是否 STW + 触发条件 | 答「一个是新生代一个是全部」→ 说不出 Full GC 的四种触发 |
| 「大对象怎么处理」 | Humongous、晋升阈值、Region 浪费 | 答「直接进老年代」→ 在 G1 里这句话半对半错 |
| 「怎么排查 OOM」 | 区分泄漏和不够用 | 答「加大堆内存」→ 泄漏的话加了只是晚点死 |
| 「调优调什么」 | 堆大小、停顿目标、收集器三选一 | 背一堆参数名,说不出先动哪个 |
| 「ZGC 能用在生产吗」 | 停顿 vs 吞吐 vs CPU 的三角 | 答「停顿 1ms 完美」→ 说不出它吃掉多少吞吐 |
GcDemo.java,java GcDemo promote 3000 是主负载(分配 3000 个 1MB 对象、
10% 长期持有),gcstat.py 负责把 -Xlog:gc* 的日志统计成表。统一参数:
-Xmx512m,四种收集器各跑一次,同一台机器、同一份负载。
04.2一次 Young GC 的完整账本
先把一次 GC 拆开看。-Xlog:gc+heap=debug 会在回收前后各打一份堆快照,中间夹一行结果:
GC(0) Heap before GC invocations=0 (full 0):
GC(0) def new generation total 88512K, used 78720K
GC(0) eden space 78720K, 100% used
GC(0) from space 9792K, 0% used
GC(0) to space 9792K, 0% used
GC(0) tenured generation total 161792K, used 0K
GC(0) DefNew: 78720K(88512K)->776K(88512K) Eden: 78720K(78720K)->0K(78720K)
GC(0) Tenured: 0K(161792K)->0K(161792K)
这份日志的参数是 -Xmx256m -Xmn96m -XX:SurvivorRatio=8,把它翻译成人话:
| 数字 | 含义 | 怎么算出来的 |
|---|---|---|
| Eden 78720K | 新生代里放新对象的区域 | 96MB × 8/10 ≈ 76.8MB,再按堆对齐 |
| From / To 各 9792K | 两个 Survivor,同一时刻只有一个在用 | 96MB × 1/10 ≈ 9.6MB(SurvivorRatio=8) |
| Tenured 161792K | 老年代 | 256MB − 96MB ≈ 160MB |
78720K -> 776K | 回收前用了 78720K,回收后只剩 776K | 99% 的对象死了,只有 776K 活下来 |
最关键的是最后两行:
Eden: 78720K -> 0K——Eden 被整个清空,不留一点垃圾(复制算法:活对象搬走,整块丢掉);From: 0K -> 776K——776K 的活对象被复制到 Survivor,年龄 +1;Tenured: 0K -> 0K——老年代一点没动,因为这批对象还没到晋升年龄。
再看两份更细的日志,一个是年龄表,一个是回收后的堆:
GC(0) Desired survivor size 3342336 bytes, new threshold 1 (max threshold 15)
GC(0) Age table with threshold 1 (max threshold 15)
GC(0) - age 1: 5923048 bytes, 5923048 total
三行读法:Survivor 目标是 3.34MB(9792K 的 1/3 左右,由 -XX:TargetSurvivorRatio
默认 50 算出),晋升阈值被压到 1(因为这一轮存活对象超过目标,JVM 临时把阈值降到 1,
让它们下一轮就赶紧走),age 1 有 5.9MB。阈值上限 15 是 -XX:MaxTenuringThreshold 的默认值。
04.3四种收集器,同一份负载
同一份负载(3000 个 1MB 对象 + 300 个长期持有)跑四遍,统计结果:
配置 Y次数 Y总停顿 Y最大 F次数 F总停顿 其它STW GC总计
serial 37 173.4ms 9.54ms 1 1.9ms 0.0ms 175.3ms
parallel 23 55.2ms 4.69ms 2 8.3ms 0.0ms 63.6ms
g1 156 101.3ms 1.56ms 2 4.0ms 32.1ms 137.3ms
zgc 0 0.0ms 0.00ms 0 0.0ms 0.5ms 0.5ms
程序侧的总耗时:
serial 用时 450 ms
parallel 用时 334 ms
g1 OutOfMemoryError: Java heap space <-- 注意这里
zgc 用时 546 ms
四行数据,四个结论:
| 收集器 | 赢在哪 | 输在哪 | 关键数字 |
|---|---|---|---|
| Serial | 实现最简单,单核环境最省 | 停顿最长 | 最大停顿 9.54ms,是 Parallel 的 2 倍 |
| Parallel | 总停顿最短、总耗时最短 | 单次停顿仍有 4.69ms | GC 总时间 63.6ms,比 Serial 少 64% |
| G1 | 单次停顿最短(1.56ms) | GC 次数暴涨、总停顿变长 | Young GC 156 次,是 Parallel 的 6.8 倍 |
| ZGC | STW 几乎为零 | 吞吐最差 | STW 合计 0.5ms,但总耗时 546ms |
这组对比把「没有最好的收集器,只有最合适的」这句话变成了数字:
- Parallel 的总停顿 63.6ms 最少,因为它一次收得多、次数少。适合批处理、离线任务—— 反正跑完就行,停顿长短无所谓,看总吞吐。
- G1 的单次停顿 1.56ms 最短,代价是收 156 次、总停顿反而 137.3ms。 适合在线服务——用户感知的是单次卡顿,不是总卡顿。
- ZGC 把 STW 压到 0.5ms(几乎全是并发),但总耗时 546ms 比 Parallel 多 63%。 因为它要和业务线程抢 CPU做并发标记。适合大堆 + 低延迟(几十 GB 以上), 小堆用它纯属浪费。
04.4G1 的 Region,和 512KB 那条分界线
G1 把堆切成等大的 Region,大小在启动时算好:
[gc,init] Heap Region Size: 1M
[gc,init] Heap Min Capacity: 8M
[gc,init] Compressed Oops: Enabled (32-bit)
512MB 堆、16 核,Region 就是 1MB。规则是:对象大小 ≥ Region 的一半, 就叫 Humongous,必须占用整数个 Region。1MB 的 Region,一半就是 512KB。
把四个尺寸各分配 100 个,看堆上实际占了多少:
300 KB × 100 个:理论 29 MB,堆上实际 29 MB
512 KB × 100 个:理论 50 MB,堆上实际 100 MB
520 KB × 100 个:理论 50 MB,堆上实际 100 MB
1024 KB × 100 个:理论 100 MB,堆上实际 200 MB
300KB 正常,从 512KB 开始全部翻倍。因为:
- 300KB < 512KB,是普通对象,塞进 Eden 的 Region 里,按实际大小算;
- 512KB 恰好等于 Region 的一半,判为 Humongous,独占 1 个 Region(1MB),浪费一半;
- 520KB 超了一半,必须占 2 个连续 Region(2MB),浪费 1.48MB;
- 1MB 正好占 2 个 Region,200 个 Region 装 100MB 数据。
现在回头看 04.3 的 OOM:3000 个 1MB 对象里活下来 300 个,按 2 倍算就是 600MB, 而堆只有 512MB —— 撑爆是必然的。同一份负载在 Serial 下只要 300MB。
| 对象大小 | G1 分类 | 实际占用 | 后果 |
|---|---|---|---|
| < 512KB | 普通对象 | 按实际大小 | 正常 |
| = 512KB | Humongous | 2 倍(1 Region) | 内存利用率腰斩 |
| 520KB ~ 1MB | Humongous | 2 倍(2 Region) | 还需要连续 Region,易碎片化 |
| > 1MB | Humongous | 向上取整到 Region 整数倍 | 大数组是重灾区 |
① Parallel / Serial 里超过
-XX:PretenureSizeThreshold 的对象直接进老年代
(这个参数只对它们有效,G1 不认);② G1 里 ≥ Region/2 的对象是 Humongous,直接进老年代 Region, 而且按 Region 整数倍占用,实测 512KB 的对象吃 1MB;
③ 所以 G1 下调优大对象,要么调大 Region(
-XX:G1HeapRegionSize=4m),
要么拆小对象,要么干脆别用 G1。
04.5对象什么时候进老年代
四条路,缺一不可:
| # | 路径 | 判定条件 | 怎么观察 |
|---|---|---|---|
| 1 | 熬够年龄 | 每熬过一次 Young GC 年龄 +1,到 MaxTenuringThreshold(默认 15)就晋升 | -Xlog:gc+age=trace 看 age 表 |
| 2 | 动态年龄判定 | Survivor 里同龄对象总和 > Survivor 一半时,从该年龄往上全部晋升 | 日志里 new threshold 1 就是它干的 |
| 3 | 大对象 | Parallel/Serial 看 PretenureSizeThreshold;G1 看 Region/2 | 见 04.4 的翻倍实验 |
| 4 | Survivor 装不下 | 晋升时 Survivor 满了,担保机制直接进老年代 | 日志里 to space overflow |
第 2 条最容易被忽略,但它在日志里天天出现。04.2 那份日志的
new threshold 1 (max threshold 15) 就是证据:阈值不是固定 15,是每轮动态算的。
04.6什么时候会 Full GC
Full GC 是「整个堆 + 元空间」都扫一遍,停顿通常是 Young GC 的 10 倍以上。 它的触发条件有五种,前两种最常见:
| # | 触发 | 日志特征 | 怎么避免 |
|---|---|---|---|
| 1 | 老年代空间不足 | Pause Full (Allocation Failure) | 调大堆 / 减少长生命周期对象 |
| 2 | 晋升时担保失败 | Pause Full (Promotion Failed) | 调大 Survivor 或老年代 |
| 3 | 元空间不足 | Pause Full (Metadata GC Threshold) | 调大 MaxMetaspaceSize / 查类加载泄漏 |
| 4 | 显式调用 System.gc() | Pause Full (System.gc()) | 加 -XX:+DisableExplicitGC |
| 5 | G1 的并发标记跟不上 | Pause Full (G1 Compaction Pause) | 调小 InitiatingHeapOccupancyPercent |
第 3 条可以现场复现。用一个手写的 class 生成器不停 defineClass,把
-XX:MaxMetaspaceSize=32m 撑爆:
已加载 5000 个类,Metaspace 已用 4 MB
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
5000 个空类吃掉 4MB,平均每个类约 0.8KB。32MB 的上限在四万个类左右见底。 这类 OOM 的典型现场是 CGLIB 动态代理、热部署、脚本引擎—— 每次生成的类都要占元空间,而它们只有对应的 ClassLoader 被回收才会释放。
Java heap space,
元空间 OOM 报 Metaspace,两种错误加大 -Xmx 都没用。
排查第一步永远是看异常里写的是哪个词。
04.7内存泄漏:GC 之后为什么不降
「内存泄漏」和「内存不够」在监控图上长得一样(都是堆曲线一路上涨),区分方法只有一个: 强制 Full GC,看它降不降。降了是不够用,不降是泄漏。
已持有 60 MB,堆已用 123 MB / 最大 256 MB
已持有 80 MB,堆已用 163 MB / 最大 256 MB
GC 前堆占用 201 MB
连做 3 次 Full GC 之后堆占用 201 MB(HOLD 仍持有 100 MB)
100 个 1MB 数组,GC 前 201MB、GC 后还是 201MB,一字节没降。 为什么是 201MB 而不是 100MB?因为 1MB 对象在 G1 里是 Humongous,每个占 2 个 Region ——正好是 04.4 那条 512KB 分界线的又一次实证。
这段代码的「泄漏点」只有一行:
static final List<byte[]> HOLD = new ArrayList<>();
...
HOLD.add(new byte[1024 * 1024]); // 只加不减,且 HOLD 是 GC Root 可达的
static 集合是最经典的泄漏形态:它被类对象引用,类对象被 ClassLoader 引用,
ClassLoader 被 GC Root 引用——整条链都活着,里面的元素自然回收不掉。
| 现象 | 本质 | 判据 | 处理 |
|---|---|---|---|
| 堆一路涨,Full GC 后不降 | 泄漏 | dump 下来找「不该活着」的对象 | 修代码,加内存只会晚点死 |
| 堆一路涨,Full GC 后降回 | 不够用 | 看 GC 后水位是否回到基线 | 加堆 / 优化对象生命周期 |
| 堆平稳但频繁 Full GC | 晋升太快 | 看每次 GC 后老年代是否缓慢爬升 | 调 Survivor / 减少大对象 |
| 堆不高但 OOM | 元空间 / 直接内存 | 看异常类型是不是 Metaspace | 查动态类生成、Netty 的 ByteBuf |
jstat -gcutil <pid> 1000 看老年代占比是否只涨不跌;
② jmap -histo:live <pid> | head -20 看谁占得最多(会触发一次 Full GC,谨慎);
③ jmap -dump:live,format=b,file=heap.hprof <pid> 导出,用 MAT 看支配树;
④ 在 MAT 里找「谁引用着它」,而不是「它有多大」——引用链才是根因。
04.8调优的三个旋钮,和一个禁忌
调优不是背参数,是按顺序拧三个旋钮。顺序错了会白忙:
| 顺序 | 旋钮 | 怎么定 | 拧错了会怎样 |
|---|---|---|---|
| 1 | 堆大小 -Xmx/-Xms | 先看 Full GC 后存活对象有多大,堆设为它的 3–4 倍 | 堆太小 → 频繁 GC;堆太大 → 单次 Full GC 更久 |
| 2 | 收集器 | 堆 < 4GB 且要低延迟 → G1;吞吐优先 → Parallel;堆 > 32GB 且要求毫秒级 → ZGC | 小堆用 ZGC,白吃 CPU 还更慢(实测 546ms vs 334ms) |
| 3 | 停顿目标 | G1 的 -XX:MaxGCPauseMillis,默认 200 | 设太小(如 10ms)→ G1 只能每次收一点点,GC 次数暴涨 |
第 3 个旋钮的现象在 04.3 已经能看到:G1 的 Young GC 次数是 Parallel 的 6.8 倍(156 vs 23)。 默认目标 200ms,它每次只收一小块 Region 就停手,所以次数多、单次短—— 「低延迟」和「少次数」在 G1 里是同一个旋钮的两端。
-Xmn 和 -XX:MaxGCPauseMillis。
-Xmn 把新生代写死了,G1 就没法再靠调整新生代大小来满足停顿目标——
两个参数打架,后者直接失效。用 G1 就只给总堆和停顿目标,让 G1 自己算新生代。
-Xms8g -Xmx8g(两个设成一样,避免动态扩缩)-XX:+UseG1GC -XX:MaxGCPauseMillis=100-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m(防元空间反复扩缩触发 Full GC)-Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=50m(日志必开,
出事时没有 GC 日志等于闭眼开车)-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump(OOM 现场自动留证)
04.9速记卡与自查
| # | 卡 | 内容(带数字) |
|---|---|---|
| 1 | 一次 Young GC | Eden 100% → 0(整块丢弃),活对象复制到 Survivor 年龄 +1,老年代不动。 复制算法的前提是存活率极低:实测 78720K 只活下来 776K(1%)。 |
| 2 | 四种收集器 | 同一负载:Parallel 总停顿 63.6ms 最少;G1 单次 1.56ms 最短但收了 156 次; ZGC STW 0.5ms 但总耗时 546ms 最慢。 |
| 3 | G1 的 512KB | 512MB 堆 → Region 1MB,≥ Region/2 即 Humongous,按整数 Region 占。 实测 512KB 对象吃 1MB、1MB 吃 2MB,300KB 不浪费。 |
| 4 | 泄漏 vs 不够 | 连做 3 次 Full GC,堆占用从 201MB 到 201MB 一字节没降 → 泄漏。
static 集合是最常见形态。 |
动手清单与自查表
| # | 动手项 | 可观察的判据 |
|---|---|---|
| 1 | -Xlog:gc+heap=debug 跑一次 alloc 300 |
看到 Eden: 78720K -> 0K 和 Tenured: 0K -> 0K 两行并排 |
| 2 | 四种收集器各跑一遍 promote 3000 |
Parallel 最快、G1 单次最短但 OOM、ZGC 总耗时最长 |
| 3 | 跑 GcDemo hum,对比 300KB 和 512KB |
300KB 占 29MB,512KB 占 100MB(理论 50MB) |
| 4 | 把 -Xmx512m 换成 -Xmx1g 再跑 G1 的 promote 3000 |
不再 OOM——说明 OOM 的原因是 Region 翻倍,不是代码写错 |
| 5 | 跑 leak 100,看 GC 前后堆占用 |
201MB → 201MB,一字节不降 |
| 6 | 加 -XX:MaxMetaspaceSize=32m 跑 meta |
OutOfMemoryError: Metaspace,和 heap space 是两种错 |
-XX:G1HeapRegionSize=4m 再跑一遍。Region 变成 4MB,1MB 对象只占 1 个 Region,
200MB 立刻降到 100MB。同一份代码、同一个堆,只改一个参数——
这就是「G1 的大对象问题不是内存泄漏,是 Region 粒度」的铁证。
| 卡住了怎么办 | 去哪查 |
|---|---|
| 不知道当前用的哪个收集器 | java -XX:+PrintFlagsFinal -version | grep Use.*GC |
| 不知道 Region 多大 | -Xlog:gc+init=info 里搜 Heap Region Size |
| 不知道谁在频繁 GC | jstat -gcutil <pid> 1000,看 YGC/FGC 计数涨速 |
| 不知道对象多大 | jmap -histo:live <pid> | head(会 Full GC,生产慎用) |
| 不知道谁引用着泄漏对象 | MAT 的 Path to GC Roots,排除弱引用/软引用 |