JVM 分代与 G1 一次讲透:多数盘子活不过一顿饭——Java 工兵连第 4 篇
「Java 工兵连」第 4 篇,讲 GC。GC 资料浩如烟海,但多数人看完仍答不上三个根本问题:为什么要分代?对象什么时候进老年代?G1 到底改进了什么? 这篇就只回答这三个问题。
一、先建直觉:餐厅的盘子
GC 的一切设计都建立在一条经验定律上——弱分代假说:绝大多数对象朝生夕死。想想餐厅:
- 多数盘子用完就洗——临时变量、请求对象、拼接的字符串,活不过「一顿饭」(一次请求);
- 少数银器要用很多年——缓存、连接池、Spring 容器里的 Bean,是「传家宝」。
把这两类混在一个池子里打扫,每次都要全屋盘点,蠢。于是 JVM 把堆分成两个区,让短命的死在一起、长命的住在一起,各用各的打扫方式——这就是分代。
二、传统分代:换桌布,不是逐个擦
新生代切成 Eden : S0 : S1 = 8 : 1 : 1(SurvivorRatio=8)。打扫方式是复制算法——宴会散场不逐个擦桌子,而是把还坐着的客人请到隔壁干净大厅(From Survivor → To Survivor),然后整个大厅一抹了之:
- 新对象在 Eden 出生;Eden 满了触发 Minor GC;
- 存活者复制进 To Survivor,年龄 +1;Eden 和 From 整体清空,两块 Survivor 角色互换;
- 熟客越少,复制越便宜——短命对象多,正是复制算法的主场,且天然无碎片。
对象什么时候升入老年代?记三条(熬够十五、块头太大、挤不下):
- 熬够十五:年龄达到
MaxTenuringThreshold(默认 15——年龄用 4 bit 存,这就是上限 15 的由来); - 块头太大:大对象(
PretenureSizeThreshold)直接进老年代,免得在 Survivor 之间反复搬运; - 挤不下:动态年龄判定——Survivor 里同龄对象总大小超过其一半,这个年龄及以上集体晋升(免得 Survivor 溢出)。
老年代的住户存活率高,复制不划算,改用标记-清除 / 标记-整理:从 GC Roots(栈上的引用、静态变量、JNI 引用等)出发标记活对象,再清理或整体平移。慢而稳,满了就是 Major/Full GC——停顿时间最长的「大盘点」。
三、G1:从「整堆大扫除」到「按预算收格子」
传统收集器(CMS 之前/并行收集器)的痛点:老年代是一整块连续区域,回收要么全收、要么不收,停顿时间随堆大小线性增长。G1(Garbage First,JDK 9 起默认)换了个思路,像垃圾分类回收车:全城不搞大扫除,每次出车按预算收「垃圾最多、最好收」的几个小区。
四个关键设计:
1. Region 网格。 堆切成约 2048 个等大的 Region(1–32MB 的 2 的幂),每个 Region 动态扮演 Eden、Survivor 或 Old——逻辑上仍分代,物理上不再连续(见 hero 图右半)。超过半个 Region 的大对象是 Humongous,直接找连续几个 Region 安放。
2. 收益排序,按预算收。 G1 维护每个 Region 的回收价值(垃圾占比 ÷ 预估回收成本),MaxGCPauseMillis(默认 200ms)是停顿预算:在预算内尽量多收高收益 Region,收不完的下轮再收——「Garbage First」= 先收垃圾最多的。注意它是软目标:G1 努力达成,不承诺保证。
3. 记账本:卡表与 Remembered Set。 老年代可能引用新生代,Minor GC 时总不能扫全堆。办法是「外来人口登记簿」:卡表(Card Table)把老年代切成 512B 的卡,写引用时由写屏障在卡上记账;G1 再为每个 Region 维护 RSet,记录「谁指向我」——回收时只查登记簿,不全城排查。
4. 并发标记:SATB。 Mixed GC 之前先并发初始标记(搭 Minor GC 的车)→ 并发标记(SATB,开始那一刻的快照为准,标记期间新出现的对象当作存活)→ 最终标记 → 筛选回收。堆占用超过 IHOP(默认 45%)触发。之后 G1 把年轻代 + 一批最划算的老年代 Region 一起搬空——这就是 Mixed GC。Full GC 仍是兜底(并发跟不上时退化,JDK 10 起改为并行执行,不再是单线程灾难)。
四、选型:什么时候用什么
| 场景 | 选择 | 理由 |
|---|---|---|
| 小堆(<4G)、批处理、追求吞吐 | Parallel GC | 吞吐冠军,停顿长但频率低 |
| 大堆(6G+)、在线服务、停顿敏感 | G1(JDK 9+ 默认) | 停顿可控,吞吐换延迟 |
| 超大堆(十几 G+)、要求极低停顿 | ZGC / Shenandoah | 亚毫秒级停顿(21 起 ZGC 分代) |
口诀:小堆吞吐 Parallel,大堆低停 G1,超大堆极低停顿 ZGC。
五、生产实践:三条动作
# JDK 9+ 统一日志,排障第一步永远是看真实 GC 行为
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=50m ...
# 快速体检:各代占用与 GC 次数/耗时
jstat -gcutil <pid> 1000
1. G1 下别手动设年轻代大小。 -Xmn、-XX:NewRatio 会捆住 G1 的自适应手脚——Region 时代年轻代大小是动态算出来的,手设等于告诉司机「别看路况,就按这个速度开」。
2. 停顿目标是「目标」不是「保证」。 把 MaxGCPauseMillis 设成 10ms 不会让 G1 变快,只会让它每轮收更少的 Region、回收频率更高、吞吐下降。合理路径:先用默认 200ms 观察真实停顿分布,再小步调。
3. IHOP 是 Mixed GC 的扳机。 老年代占用到 45%(默认)才开始并发标记周期——大促类流量前,可主动调低 IHOP 或调大堆,给标记周期留出提前量,避免「标记还没完成,堆先满了」的 Full GC。
老兵点评
- GC 调优的绝大部分不是调参数,是少造垃圾。分配速率降下来,Minor GC 频率自然降;短命大对象(大报文、无界集合、日志拼接)管住,老年代压力自然小。参数是最后 10% 的功夫。
- 读懂比调优重要。拿到一个服务先看三样:GC 频率、单次停顿分布、晋升速度(老年代增长曲线)。晋升快过回收,Full GC 只是时间问题——这条曲线比任何参数都早报警。
- 面试讲 G1 的段位差:初级背「Region + 并发标记」;中级讲 RSet/卡表和 SATB;高级讲停顿预测模型和「软目标」的取舍——能讲清 G1 用吞吐换可控停顿,就到顶了。
带走三句话
- 分代的根基是弱分代假说:多数盘子活不过一顿饭——新生代换桌布(复制算法),老年代大扫除(标记整理)。
- 晋升三条路:熬够十五、块头太大、挤不下;年龄 4 bit,所以上限 15。
- G1 = 2048 个格子 + 回收收益排序 + 停顿预算:逻辑分代物理不分代,先收最划算的;选型口诀「小堆吞吐 Parallel,大堆低停 G1,超大堆极低停顿 ZGC」。
本篇是「Java 工兵连」第 4 篇。→ 上一篇:第 3 篇 · volatile 不是锁 | 继续阅读:第 5 篇 · Spring 事务失效的八个瞬间。
❦ 终 ❦