Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 04垃圾回收与调优

GC 是八股里唯一一个「答错会被当场追问到崩」的题。因为它的每个结论都能被一行 -Xlog:gc 证伪:你说「G1 停顿短」,面试官问短多少;你说「大对象直接进老年代」, 他问那为什么 G1 还会 OOM。

这一章把 JDK 17 的四种收集器放在同一份负载下跑了一遍: 3GB 分配 + 300MB 存活。结果是 —— G1 唯一一个 OOM 的, 而它恰恰是「默认收集器」。原因在 04.4,一个 512KB 的分界线。

学习目标
能读一份 GC 日志,并从日志反推该调哪个参数
前置要求
读过第 03 章(运行时数据区、对象头)
本章产出
6 组实测 + 4 份真实日志 + 4 张速记卡
建议学时
3 小时(建议跟着把 4 个收集器各跑一遍)

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 完美」→ 说不出它吃掉多少吞吐
实验环境:JDK 17.0.12(16 核 / 16GB 物理机),源码单文件 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,回收后只剩 776K99% 的对象死了,只有 776K 活下来

最关键的是最后两行:

为什么新生代用「复制」而不是「标记清除」:因为新生代里 98% 以上是垃圾。 复制算法只搬运活着的少数对象,剩下的整块内存直接丢弃——实测 78720K 里有 77944K 是垃圾, 搬 776K 比扫 78720K 便宜得多。老年代反过来(存活率高),所以用标记-整理。 「哪种算法好」取决于存活率,不取决于算法本身。

再看两份更细的日志,一个是年龄表,一个是回收后的堆:

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.69msGC 总时间 63.6ms,比 Serial 少 64%
G1单次停顿最短(1.56ms)GC 次数暴涨、总停顿变长Young GC 156 次,是 Parallel 的 6.8 倍
ZGCSTW 几乎为零吞吐最差STW 合计 0.5ms,但总耗时 546ms

这组对比把「没有最好的收集器,只有最合适的」这句话变成了数字:

G1 那一行是 OOM —— 这不是偶然。同一份负载,Serial / Parallel / ZGC 都跑完了, 只有 G1 撑不住。原因不是 G1 弱,是这 3000 个对象每个都是 1MB, 而 G1 的 Region 只有 1MB —— 它们全部变成 Humongous 对象。下一节专门讲这个坑。

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 开始全部翻倍。因为:

现在回头看 04.3 的 OOM:3000 个 1MB 对象里活下来 300 个,按 2 倍算就是 600MB, 而堆只有 512MB —— 撑爆是必然的。同一份负载在 Serial 下只要 300MB。

对象大小G1 分类实际占用后果
< 512KB普通对象按实际大小正常
= 512KBHumongous2 倍(1 Region)内存利用率腰斩
520KB ~ 1MBHumongous2 倍(2 Region)还需要连续 Region,易碎片化
> 1MBHumongous向上取整到 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 的翻倍实验
4Survivor 装不下晋升时 Survivor 满了,担保机制直接进老年代日志里 to space overflow

第 2 条最容易被忽略,但它在日志里天天出现。04.2 那份日志的 new threshold 1 (max threshold 15) 就是证据:阈值不是固定 15,是每轮动态算的。

「对象一定在新生代分配吗」——不一定。两种情况直接在老年代分配: ① 大对象(上面第 3 条);② TLAB 装不下、Eden 也放不下时直接走老年代。 所以「新生代分配、老年代回收」只是常态,不是铁律。

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
5G1 的并发标记跟不上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 被回收才会释放。

元空间和堆是两个独立战场。堆 OOM 报 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 最慢。
3G1 的 512KB 512MB 堆 → Region 1MB,≥ Region/2 即 Humongous,按整数 Region 占。 实测 512KB 对象吃 1MB、1MB 吃 2MB,300KB 不浪费。
4泄漏 vs 不够 连做 3 次 Full GC,堆占用从 201MB 到 201MB 一字节没降 → 泄漏。 static 集合是最常见形态。
如果只记一句话:GC 的所有取舍都在算同一个账—— 「用多少 CPU 换多少停顿」。Parallel 用多线程换短停顿,G1 用更多次数换更短的单次, ZGC 用并发线程换几乎为零的 STW(代价是抢业务 CPU)。所以面试问到「选哪个」, 正确的回答永远是先反问:你这个服务,怕总卡还是怕一次卡很久?

动手清单与自查表

#动手项可观察的判据
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 是两种错
反例实验(做了会记一辈子):把 04.4 的「100 个 1MB 数组」改成 -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
不知道谁在频繁 GCjstat -gcutil <pid> 1000,看 YGC/FGC 计数涨速
不知道对象多大jmap -histo:live <pid> | head(会 Full GC,生产慎用)
不知道谁引用着泄漏对象MAT 的 Path to GC Roots,排除弱引用/软引用