GC 核心机制与收集器演进¶
GC 核心机制 一句话口诀
-
可达性分析 + GC Roots 是 GC 的根基——从 Roots 出发扫不到的就是垃圾;循环引用在可达性分析下天然不成问题(引用计数才怕循环)。
-
三色标记(白/灰/黑) 是所有并发 GC 的公共语言;并发标记的漏标靠写屏障补扫——业务线程留痕、GC 线程补扫。
-
三种 GC 算法:标记-清除(留碎片)、标记-整理(慢但无碎片)、复制(快但浪费一半空间);分代收集的本质是新生代用复制、老年代用整理/清除。
-
收集器演进主线:Serial→Parallel(吞吐)→CMS(首次并发)→G1(Region + 可预测停顿)→ZGC(染色指针 + 读屏障 + 亚毫秒)。每一代都在回答同一个问题:还能把哪些必须 STW 的事挪到业务线程并发时做?
-
ZGC 染色指针把 GC 状态编在指针高位 4 位(Finalizable / Remapped / Marked1 / Marked0),配合读屏障实现对象并发转移——这是 ZGC 亚毫秒停顿的根本。
📖 边界声明:本文聚焦"GC 算法机制与收集器的实现原理",以下主题请见对应专题:
- 内存分区、对象头、压缩指针 → JVM 内存分区与对象布局
- GC 调优参数、OOM 排查、生产 checklist → GC 调优实战与常见误区
- 容器化 JVM、虚拟线程、JFR、分代 ZGC 落地 → JVM 现代实践与前沿技术
1. 可达性分析与 GC Roots¶
JVM 不使用引用计数(无法解决循环引用),而是可达性分析:从 GC Roots 出发,能被引用链到达的对象就是存活的,否则可以回收。
GC Roots 的完整范围:
flowchart TD
subgraph GCRoots["GC Roots(根节点集合)"]
R1["虚拟机栈中的局部变量<br>(所有线程的栈帧)"]
R2["元空间中的静态变量<br>(static 字段引用的对象)"]
R3["元空间中的常量引用<br>(字符串常量池等)"]
R4["JNI 引用<br>(Native 方法持有的对象)"]
R5["同步锁持有的对象<br>(synchronized 锁对象)"]
R6["JVM 内部引用<br>(基本类型 Class 对象、异常对象等)"]
end
R1 --> A["对象 A(存活)"]
R2 --> B["对象 B(存活)"]
A --> C["对象 C(存活)"]
B --> C
D["对象 D(孤立,可回收)"]
E["对象 E(孤立,可回收)"]
D -.->|"循环引用,但不可达"| E 📖 术语家族:*Reference 引用强度家族
字面义:reference = 引用;strong / soft / weak / phantom = 强 / 软 / 弱 / 虚——描述"这条引用对 GC 可达性的约束力有多强"。
在本框架中的含义:Java 从 JDK 1.2 起把"引用"本身变成了一等公民对象(java.lang.ref 包),让应用层能显式告诉 GC:"这条引用在内存紧张时可以放弃"。GC Roots 的可达性分析遵循一条严格规则:只有经过强引用链路可达的对象才不回收;经过软/弱/虚引用可达的对象各有各的回收时机。
同家族成员:
| 成员 | GC 回收时机 | 典型用途 | 源码位置 |
|---|---|---|---|
强引用(普通变量 Object o = new Object()) | 永不回收(除非手动置 null) | 日常对象引用 | — |
SoftReference<T> | 内存不足时回收(Full GC 前最后的救命稻草) | 内存敏感的缓存(如图片缓存) | java.lang.ref.SoftReference |
WeakReference<T> | 下一次 GC 必定回收(不管内存是否充足) | 规范键值缓存、WeakHashMap、ThreadLocalMap.Entry | java.lang.ref.WeakReference |
PhantomReference<T> | 随时回收,且无法通过它 get() 到对象(get() 永远返回 null) | 对象被回收时的清理通知(替代 finalize()) | java.lang.ref.PhantomReference |
FinalReference<T>(JDK 内部) | 对象实现了 finalize() 方法时 JVM 自动生成 | finalize() 机制的底层实现 | java.lang.ref.FinalReference(package-private) |
Reference<T> | 上述四种的共同基类,定义 get() / clear() / enqueue() 契约 | — | java.lang.ref.Reference |
ReferenceQueue<T> | 配合 Soft/Weak/Phantom 使用,对象被 GC 回收时会把引用对象入队 | 实现"对象死亡通知"的通道 | java.lang.ref.ReferenceQueue |
命名规律:<Xxx>Reference = "可达性约束力为 Xxx 的引用"。强度递减排列:强 > 软 > 弱 > 虚,约束力越弱,GC 越早可以放弃。ReferenceQueue 是配套的"死亡通知队列",Reference#get() 返回 null 是"被 GC 回收了"的信号。JVM 在可达性分析遍历时,按强度分轮次处理:第一轮只沿强引用走标记,第二轮再决定软/弱/虚引用对象的命运。
2. 三色标记算法(理解并发 GC 的基础)¶
并发 GC(CMS、G1、ZGC)在标记阶段与业务线程并发执行,需要解决标记过程中对象引用关系变化的问题。三色标记是核心算法:
- 白色:未被访问,GC 结束后仍为白色 → 可回收
- 灰色:已被访问,但其引用的对象还未全部扫描完
- 黑色:已被访问,且其所有引用都已扫描完 → 存活,不会再被扫描
初始状态:所有对象白色
↓
从 GC Roots 出发,将直接引用的对象标记为灰色
↓
取出一个灰色对象,扫描其所有引用:
- 将未访问的引用对象标记为灰色
- 将当前对象标记为黑色
↓
重复直到没有灰色对象
↓
剩余白色对象 = 垃圾
并发标记的问题:漏标:
业务线程在 GC 标记过程中修改了引用关系,可能导致存活对象被错误回收(漏标):
初始:A(黑) → B(灰) → C(白)
并发执行时:
业务线程:A.ref = C(黑色 A 新增对 C 的引用)
业务线程:B.ref = null(灰色 B 删除对 C 的引用)
结果:C 变成白色(无灰色节点引用它),但 A 是黑色不会再扫描
→ C 被错误回收!
解决方案:
| 方案 | 原理 | 使用者 |
|---|---|---|
| 增量更新(Incremental Update) | 黑色对象新增引用时,将该黑色对象重新标记为灰色,重新扫描 | CMS |
| 原始快照(SATB) | 灰色对象删除引用时,将被删除的引用记录下来,GC 结束前重新扫描 | G1、ZGC |
2.1 谁来"重新标记为灰色"?——写屏障(Write Barrier)¶
GC 线程无法实时感知业务线程的每一次字段赋值,所以"把黑色对象改回灰色"这件事其实是业务线程自己在赋值的瞬间完成的:
JIT / 解释器会在每条"引用字段赋值"字节码前后自动插入一小段机器码,这段代码叫 Write Barrier(写屏障)。业务线程在赋值时顺带执行屏障,留下一个"这里改过"的记号;GC 线程到了 Remark 阶段再根据这些记号去补扫,从而保证并发期间发生的引用变动不会被漏标。
简单说就是一句话:业务线程留痕,GC 线程补扫。
展开:两种写屏障的伪代码 & 执行者分工
以 a.ref = c; 为例,CMS 和 G1/ZGC 采用了两种不同的屏障策略:
// CMS 增量更新屏障(Post-write,写之后)
void putfield_ref(Object a, Object c) {
a.ref = c; // ① 先赋值
if (in_concurrent_marking) {
card_table[addressOf(a) >> 9] = DIRTY; // ② 把 a 所在 card 标脏
}
}
// G1 / ZGC 的 SATB 屏障(Pre-write,写之前)
void putfield_ref(Object a, Object c) {
if (in_concurrent_marking) {
Object old = a.ref; // ① 先读出旧值
satb_queue.push(old); // ② 旧值入 SATB 队列(按"GC 开始时快照"视为存活)
}
a.ref = c; // ③ 再赋值
}
执行者分工:
| 步骤 | 执行者 | 时机 |
|---|---|---|
| ① 拦截赋值、留下记号(dirty card / SATB entry) | 业务线程(执行 JIT 生成的屏障代码) | 赋值那一瞬间 |
| ② 真正把黑色对象重新扫描 | GC 线程 | Remark 阶段(STW) |
写屏障把 STW 期间需要做的事情从"扫整个堆"压缩成"只扫并发期间变脏的那一小部分"——本质上是用"全量扫描"换"增量扫描"。于是同样是 STW,时长可以从秒级降到毫秒级。
形象点说:
- 无写屏障 = 装修要把全屋家具都搬走(停工几天)
- 有写屏障 = 只搬今天要施工那个房间的家具(停工几小时)
这也是 JVM GC 演进的主线:还能把哪些\"必须 STW 才能做的事\",挪到业务线程并发的时候做?
| 方案 | STW 时长 | 原因 |
|---|---|---|
| 无写屏障(Serial / Parallel) | 百 ms ~ 秒级 | 必须暂停业务线程,把整个堆扫一遍 |
| 有写屏障(CMS / G1) | 10 ~ 50 ms | 大部分标记在并发阶段完成,Remark 只需扫并发期间变脏的 card |
| 染色指针 + 读屏障(ZGC / Shenandoah) | < 1 ms | 连对象搬迁都能并发做 |
📖 写屏障在具体收集器上是如何落地、一次完整并发收集的四阶段时间线长什么样,见 §5.1 CMS 详解。
展开:写屏障的代价
每一条引用字段赋值都会多执行几条屏障指令,引用更新密集的业务(大图遍历、ORM、大量 setter)开销可达 5%~10%。这也是为什么 Parallel GC 的吞吐量最高——它没有写屏障。G1 的 RSet 维护同样依赖写屏障,这也是 G1 内存占用较高的原因之一(见 §5.2)。
展开:写屏障如何补上 Initial Mark 的'漏'——一张图串起全流程
Initial Mark 本身并不漏扫——它在 STW 下完整枚举了当时的 GC Roots。真正的"漏"发生在并发标记期间:业务线程会新增引用(黑→白)、修改 Root(新压栈局部变量、静态字段改写)。写屏障让业务线程在每次"危险操作"时留下痕迹(dirty card 或 SATB entry),Remark 阶段 STW 重扫线程栈 + 消费所有痕迹,把这些"并发期间漏掉的增量"补扫完。
sequenceDiagram
participant App as 业务线程
participant Barrier as 写屏障(JIT插入)
participant GC as GC线程
Note over GC: Initial Mark (STW)
GC->>GC: 扫 GC Roots 直接引用,染灰
Note over App,GC: Concurrent Mark (并发)
App->>App: a.ref = c (a黑, c白 → 漏标风险!)
App->>Barrier: 触发写屏障
Barrier->>Barrier: CMS: card_table[a]=DIRTY<br/>G1: satb_queue.push(old)
GC->>GC: 并行地从灰色对象出发扫描
Note over GC: Remark (STW)
GC->>GC: ① 重扫线程栈(新增Root)
GC->>GC: ② 扫 dirty card / 消费 SATB 队列
GC->>GC: ③ 从新增灰色对象继续扫完
Note over App,GC: Concurrent Sweep (并发)
GC->>GC: 清理白色对象 所以写屏障的真正定位是:不是让 Initial Mark 扫得更全,而是让"Initial Mark + 并发标记"这套流水线在业务不停顿的前提下仍然正确。它把并发 GC 的正确性问题,转化成了一个"业务线程留痕 + GC 线程增量补扫"的工程问题。
📖 术语家族:*Barrier 屏障家族
字面义:barrier = 屏障 / 拦截;在 GC 语境里是指"JIT 在业务线程的读/写字节码前后自动插入的一小段拦截代码"。
在本框架中的含义:屏障是并发 GC 的"眼线"——GC 线程没法在业务线程的每一次字段访问时都暂停它,于是把"记录引用变化"这件事外包给业务线程自己:业务线程执行赋值或取值时顺带做一次检查或登记,GC 线程日后按这些记录补扫。不同收集器根据"想补扫什么"选用不同的屏障。
同家族成员:
| 成员 | 触发时机 | 主要动作 | 典型使用者 |
|---|---|---|---|
| Write Barrier(写屏障) | 引用字段赋值时 | 拦截 a.ref = b,记录一条痕迹给 GC | 所有并发 GC 的通用基础 |
| ├─ Pre-write Barrier(前置写屏障) | 赋值之前 | 读出旧值 old,入 SATB 队列(保证"GC 开始时可达就算活") | G1、Shenandoah(SATB 实现) |
| ├─ Post-write Barrier(后置写屏障) | 赋值之后 | 把源对象所在 Card 标脏(card_table[a>>9]=DIRTY) | CMS 增量更新、G1 RSet 维护 |
| Load Barrier(读屏障 / 加载屏障) | 读取引用字段时(Object b = a.ref) | 检查染色指针的颜色位:若对象已迁移则自动修正到新地址 | ZGC、Shenandoah |
| Memory Barrier(内存屏障,广义) | 指令间 | CPU 级别的重排序 / 可见性约束,与 GC 无直接关系(见 JMM) | 所有并发原语 |
命名规律:<Xxx> Barrier = "在 Xxx 操作前后插入的拦截代码"。写屏障按插入位置细分 Pre- 与 Post-,按"要记录什么"选定 SATB(记旧值)或增量更新(记脏 Card)。读屏障是 ZGC 的标志性技术——它让"对象转移"也能并发完成,代价是每次读引用多几条指令。
📖 CPU 级别的 Memory Barrier 和 JMM 的 happens-before 关系详见 并发基础:JMM 与线程同步,本文的 Barrier 专指 GC 屏障。
3. 三种 GC 算法¶
Mark-Sweep Algorithm:
┌────────────────────────────────────────────────────────────────────────────┐
│ [Alive] [Garbage] [Alive] [Garbage] [Alive] [Garbage] │ After marking. │
│ [Alive] [ ] [Alive] [ ] [Alive] [ ] │ After sweeping. │
│ ← Generates memory fragmentation, large objects cannot allocate → │
└────────────────────────────────────────────────────────────────────────────┘
Mark-Compact Algorithm:
┌────────────────────────────────────────────────────────────────────────────┐
│ [Alive] [Garbage] [Alive] [Garbage] [Alive] [Garbage] │ After marking │
│ [Alive] [Alive] [Alive] [ Free Space ] │ After compacting │
│ ← No fragmentation, but moving objects requires updating all references → │
└────────────────────────────────────────────────────────────────────────────┘
Copying Algorithm:
┌────────────────────────────────────────────────────────────────────────────┐
│ From: [Alive][Garbage][Alive][Garbage] │ To: [Empty] │ Before GC │
│ From: [Cleared ] │ To: [Alive][Alive] │ After GC │
│ ← No fragmentation, fast, but 50% space utilization → │
└────────────────────────────────────────────────────────────────────────────┘
| 算法 | 优点 | 缺点 | 典型使用者 |
|---|---|---|---|
| Mark-Sweep | 实现简单、不移动对象 | 内存碎片、大对象难分配 | CMS |
| Mark-Compact | 无碎片、空间利用率高 | 移动对象、需要更新引用、慢 | Serial Old、Parallel Old、G1 Full GC |
| Copying | 无碎片、速度最快(无需扫描死对象) | 空间利用率 50% | 新生代(Serial、ParNew、G1 Young) |
分代收集的本质:新生代用 Copying(死多活少,浪费一半也划算)、老年代用 Mark-Compact 或 Mark-Sweep(死少活多,copy 成本太高)——这是弱分代假说的直接工程化。
4. 逃逸分析与栈上分配¶
前面讲的所有 GC 机制都在解决一个问题:如何高效地回收堆上的垃圾。但还有一条更激进的思路——如果一个对象根本不需要进堆,那就不需要 GC 了。这就是 逃逸分析(Escape Analysis) 的出发点:JDK 6 起由 C2 JIT 在方法编译时进行的一项静态分析,JDK 8 起默认开启(-XX:+DoEscapeAnalysis)。
4.1 什么叫"逃逸"?¶
"逃逸"指的是一个在方法内部 new 出来的对象,其引用被传播到方法作用域之外。HotSpot 把逃逸程度分为三级,级别越高,可做的优化越少:
| 逃逸级别 | 含义 | 典型场景 | 可做的优化 |
|---|---|---|---|
| NoEscape | 引用完全不离开当前方法 | 局部 new 后只读字段 | 标量替换、(理论上的)栈上分配、锁消除 |
| ArgEscape | 引用作为参数传给别的方法,但未被外部长期持有 | logger.debug(new Point(x,y)) | 锁消除(有条件) |
| GlobalEscape | 引用被存入静态字段 / 实例字段 / 返回值 / 抛出的异常 | return new X()、this.p = new P() | 无,必须堆分配 |
// ① NoEscape:引用完全不出方法
public int sum() {
Point p = new Point(1, 2);
return p.x + p.y; // p 用完即弃,JIT 可做标量替换
}
// ② ArgEscape:引用作为参数传出去,但未被长期持有
public void log() {
logger.debug(new Point(1, 2)); // 逃到 debug 方法,但没被存起来
}
// ③ GlobalEscape:引用被外部长期持有
public Point create() {
return new Point(1, 2); // 逃到调用者,必须堆分配
}
4.2 基于逃逸分析的三项优化¶
HotSpot 在确认一个对象为 NoEscape 后,会按以下优先级尝试优化:
① 标量替换(Scalar Replacement)—— 真正在生产中起作用的主力优化¶
把对象的字段直接拆散为独立的局部变量,塞进栈帧或寄存器,对象本身彻底不存在。由 -XX:+EliminateAllocations 控制(默认开启)。
// 源代码
Point p = new Point(1, 2);
return p.x + p.y;
// JIT 等价改写为(伪代码)
int p$x = 1; // 对象消失,字段变成局部变量
int p$y = 2; // 后续甚至可能被常量折叠为 return 3
return p$x + p$y;
② 锁消除(Lock Elision)¶
如果加锁对象是 NoEscape 的(不可能被其他线程看到),同步块就没有存在的必要。经典例子:StringBuffer.append 内部有 synchronized,但如果这个 StringBuffer 是方法内的局部变量,JIT 会直接把锁去掉,性能退化到接近 StringBuilder。
③ 栈上分配(Stack Allocation)—— 理论存在,HotSpot 实际并未落地¶
一个常见的误解
很多资料(包括《深入理解 Java 虚拟机》早期版本)都写过"逃逸分析会把对象分配到栈上"。但 HotSpot 至今没有真正实现过通用的栈上分配——源码里只保留了 StackAllocate 的占位开关,C2 最终落地的始终是标量替换。
换个角度看,标量替换其实比栈上分配"更彻底":栈上分配只是换了个分配位置,对象结构还在;而标量替换直接让对象消失、字段变成独立的局部变量。所以你在实践中观察到的"对象没进堆",本质都是标量替换的效果。本节标题仍然沿用"栈上分配"这个流传更广的说法,但请记住:真正在工作的是标量替换。
4.3 怎么验证它生效了?¶
相关的 JVM 参数(JDK 8+ 默认都为开启):
-XX:+DoEscapeAnalysis # 逃逸分析总开关
-XX:+EliminateAllocations # 标量替换
-XX:+EliminateLocks # 锁消除
-XX:+PrintEscapeAnalysis # 打印 EA 过程(需 debug 版 JVM)
-XX:+PrintEliminateAllocations # 打印被消除的分配(需 debug 版 JVM)
生产 JVM 上看不到 PrintXxx 的输出也没关系,更实用的验证方式是写一个紧循环反复创建短命对象,观察 Young GC 频率:关闭逃逸分析(-XX:-DoEscapeAnalysis)后 Young GC 会显著变密,前后对比即可看到优化效果。
4.4 局限性¶
- 只在 C2(Server 编译器)里有效——C1(Client)不做逃逸分析,解释执行时也不生效。所以冷代码、启动期、被
-Xcomp强制编译到 C1 的路径都吃不到这个优化。 - 分析成本不低:对象字段一多、调用链一深,逃逸分析会保守地把对象判为 GlobalEscape(宁可放弃优化也不能出错)。
- 数组不友好:元素个数在编译期不可知的数组很难被标量替换。
💡 和 GC 的关系:逃逸分析本身不是 GC 算法,但它是减小 GC 压力的第一道防线——大量短命对象(方法内的临时
Point、Iterator、自动装箱的Integer等)如果都能被标量替换掉,Eden 的分配速率会显著下降,Young GC 频率也随之降低。这也是为什么"方法短、对象作用域小"的代码风格天然对 JIT 友好。
5. Safepoint(安全点)—— STW 的基石¶
前面讲到的"STW(Stop-The-World)"并不是 JVM 下达一个指令,业务线程就能瞬间全部暂停。业务线程随时可能在 JIT 后的机器码中间执行,JVM 必须让它们主动停在一个"安全"的位置——这个位置就叫 Safepoint(安全点)。所有 GC 算法的 STW 阶段、栈扫描、对象移动,都必须以"所有业务线程都到达 Safepoint"为前提。
5.1 什么是 Safepoint?¶
Safepoint 是线程执行过程中的一个特殊位置,在这个位置上:
- 线程的栈帧、寄存器、PC 等全部运行时状态是可被 JVM 安全读取和修改的(例如 GC 可以精确扫描栈上的引用、可以移动对象后更新指针);
- 线程的执行语义保证没有处于一条字节码指令的"中间"(比如
iadd刚从操作数栈弹出一个操作数、还没压回结果的瞬间,就不是安全的)。
只有所有业务线程都停在 Safepoint 上,JVM 才能安心执行"需要全局一致视图"的任务(GC、偏向锁撤销、Code Cache 清理、Class Redefinition 等)。
5.2 JVM 在哪里插入 Safepoint?¶
HotSpot 在以下位置埋设 Safepoint 检查点:
| 位置 | 说明 |
|---|---|
| 方法返回之前 | 保证每次方法调用链结束都会有机会停下 |
| 非 counted loop 的回边 | 例如 while(cond)、for(:),循环每次迭代检查一次 |
| 调用另一个方法的位置 | invokevirtual 等 invoke 指令前后 |
| 抛异常的位置 | 异常分派前 |
counted loop 的 Safepoint 空洞(经典坑)
HotSpot 为了优化性能,默认不在 for (int i = 0; i < N; i++) 这种"可数循环"(counted loop)的回边插 Safepoint(因为循环变量是 int,被认为循环时间"可控")。
后果:如果循环体极长(例如数组 int[] arr = new int[Integer.MAX_VALUE] 的遍历),其他线程触发 GC 后会一直等待这个线程跳出循环,表现为"莫名其妙的长 STW"——日志里看到 Total time for which application threads were stopped: 5.2s,但 GC 本身只花了 20ms,剩下 5 秒全在等那个线程到达 Safepoint。
对策:JDK 10+ 可用 -XX:+UseCountedLoopSafepoints 在 counted loop 回边也插 Safepoint;或把长循环拆小。
5.3 JVM 如何通知线程进入 Safepoint?¶
HotSpot 采用非常精巧的主动轮询(polling) 机制,而不是挂起信号:
1. JVM 需要 STW 时,设置一个全局标志(修改某个特殊内存页的保护属性)
2. 每个 Safepoint 检查点编译进了一条 "test" 指令去读那个内存页
3. 正常情况下读取成功,线程继续跑(开销 ~ 一条指令)
4. STW 时那个页被设为不可读,读取触发 SIGSEGV 信号
5. JVM 的信号处理器接管,把该线程挂起在 Safepoint 上
6. 所有线程都挂起后,JVM 执行需要 STW 的工作(GC 扫描等)
7. 工作完成,恢复页保护,所有线程被唤醒继续执行
这个设计让 Safepoint 检查在正常运行时几乎零开销,只有进入 STW 时才付出代价。
5.4 Safepoint 与 Safe Region 的区别¶
问题:线程处于 sleep()、阻塞 IO 或 synchronized 等待中时,它根本"跑不起来",也就无法主动到达 Safepoint。怎么办?
答案:Safe Region(安全区域)。
- 线程进入 sleep / 阻塞前,标记自己处于 Safe Region —— "我这段时间状态是冻结的,GC 随便看";
- JVM 发起 STW 时,看到 Safe Region 中的线程直接视作"已到 Safepoint",不再等待;
- 线程醒来离开 Safe Region 时,检查 STW 是否正在进行——如果是就等 STW 结束再继续。
5.5 Safepoint 与 GC 日志诊断¶
看到奇怪的长停顿但 GC 本身很短,务必开启 Safepoint 相关日志:
# JDK 9+ 统一日志
-Xlog:safepoint=info
# JDK 8
-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1
输出中关注两个指标:
time to safepoint(TTSP):从 JVM 发起 STW 到所有线程到位的时间——偏高说明有线程迟迟不到达,可能命中 counted loop 空洞;at safepoint:真正执行 GC / 其他工作的时间。
一句话总结 Safepoint
GC 日志里写的"STW 停顿" = TTSP(等线程到齐) + at safepoint(干活时间)。GC 调优不仅要调 GC 算法本身,还要关注所有业务线程能不能快速到达 Safepoint——这才是所有并发 / 低延迟收集器的真正前提。
6. GC 收集器演进¶
6.1 收集器全景¶
flowchart LR
subgraph Young["新生代收集器"]
Serial["Serial<br>单线程,Client模式"]
ParNew["ParNew<br>多线程版Serial"]
PScan["Parallel Scavenge<br>吞吐量优先"]
end
subgraph Old["老年代收集器"]
SerialOld["Serial Old<br>单线程标记整理"]
CMS["CMS<br>并发标记清除<br>JDK9废弃"]
ParOld["Parallel Old<br>吞吐量优先"]
end
subgraph Whole["整堆收集器"]
G1["G1<br>JDK9+默认"]
ZGC["ZGC<br>JDK15+生产可用<br>亚毫秒停顿"]
Shen["Shenandoah<br>RedHat开发<br>低停顿"]
end
Serial --> SerialOld
Serial --> CMS
ParNew --> CMS
PScan --> ParOld 全景图中几个收集器的现状说明
- ParNew:JDK 9 被标记为废弃(与 CMS 绑定),JDK 14 正式移除,新项目无需关注。
- Shenandoah:由 Red Hat 主导、与 ZGC 定位相似的低停顿收集器,OpenJDK 12+ 提供,Oracle JDK 未包含,生产使用远少于 ZGC,本文不展开。
- 下面按照「先经典后现代」的顺序展开:先讲 Serial / Parallel 这两条基础线(§6.2),再讲 CMS → G1 → ZGC 的演进(§7 ~ §9)。
📖 术语家族:*Collector / GC 收集器家族
字面义:collector = 收集器 / 回收器;HotSpot 用"Collector"指代一整套完成垃圾回收工作的算法组合(分代划分 + 扫描策略 + 回收算法 + 并发度)。
在本框架中的含义:一个收集器 = "作用分代(新生代 / 老年代 / 整堆)+ 并发度(串行 / 并行 / 并发)+ 回收算法(复制 / 标记清除 / 标记整理)" 的一种组合。JVM 参数层面用一组 -XX:+Use<Xxx>GC 开关来启用不同收集器;源码层面每个收集器都对应一个 C++ 类(继承 CollectedHeap)。
同家族成员:
| 成员 | 作用分代 | 并发度 | 算法 | JVM 参数 | 现状 |
|---|---|---|---|---|---|
| Serial | 新生代 | 单线程(全程 STW) | 复制 | -XX:+UseSerialGC | 小堆 / 冷启动兜底 |
| Serial Old | 老年代 | 单线程(全程 STW) | 标记-整理 | 同上 | 所有收集器 Full GC 兜底 |
| ParNew | 新生代 | 多线程并行(全程 STW) | 复制 | 与 CMS 绑定 | JDK 14 移除 |
| Parallel Scavenge | 新生代 | 多线程并行(全程 STW) | 复制 | -XX:+UseParallelGC | JDK 8 默认,仍可用 |
| Parallel Old | 老年代 | 多线程并行(全程 STW) | 标记-整理 | 同上 | 同上 |
| CMS(Concurrent Mark Sweep) | 老年代 | 多线程并发(仅 Init/Remark STW) | 标记-清除 | -XX:+UseConcMarkSweepGC | JDK 9 废弃 / 14 移除 |
| G1(Garbage First) | 整堆(Region 化) | 多线程并发 | Region + 复制 / 标记-整理 | -XX:+UseG1GC | JDK 9+ 默认 |
| ZGC | 整堆 | 多线程几乎全并发 | 染色指针 + 读屏障 | -XX:+UseZGC | JDK 15+ 生产可用;JDK 21+ 可分代 |
| Shenandoah | 整堆 | 多线程几乎全并发 | Brooks Pointer + 读写屏障 | -XX:+UseShenandoahGC | OpenJDK 12+,Oracle JDK 无 |
| Epsilon | 整堆 | — | 只分配不回收(No-Op) | -XX:+UseEpsilonGC | JDK 11+,专用于性能压测 |
命名规律: 1. "Serial" / "Parallel" / "Concurrent" 描述并发度——单线程 / 多线程并行(都 STW)/ 多线程并发(与业务线程同时跑) 2. "Scavenge" / "Old" 后缀描述作用分代——Scavenge 专属新生代(复制算法),Old 专属老年代(整理算法) 3. 无后缀的现代收集器(G1 / ZGC / Shenandoah)都是整堆收集器,不再强分代,而是用 Region / 染色指针等新抽象统一处理 4. -XX:+Use<Xxx>GC 是启用参数统一格式;一个 Use 开关通常同时激活新生代 + 老年代收集器的组合(如 -XX:+UseSerialGC 同时激活 Serial + Serial Old)
演进主线:Serial → Parallel(吞吐优先)→ CMS(首次把"标记-清除"做成并发)→ G1(Region 化 + 可预测停顿)→ ZGC(染色指针 + 读屏障 + 亚毫秒)——每一代都在回答同一个问题:还能把哪些必须 STW 的事挪到业务线程并发时做?
6.2 Serial / Parallel 系列详解¶
这两个系列是 JVM 最经典、也最容易被教程跳过的收集器。但它们绝不是只有历史意义:
- Parallel Scavenge + Parallel Old 是 JDK 8 的默认组合,大量仍在线的 JDK 8 服务此刻正在使用它;
- Serial / Serial Old 是所有其他收集器的"最终兜底":CMS 并发失败会退化到 Serial Old,G1 的 Full GC 退路同样偏向单线程整堆回收,Serverless 冷启动 / 小堆容器 / CLI 工具的默认选择也是 Serial。
6.2.1 Serial / Serial Old¶
- 线程模型:单线程 GC,GC 期间全程 STW。
- 算法:新生代复制算法(Serial);老年代标记-整理(Serial Old)。
- 启用参数:
-XX:+UseSerialGC(新生代 + 老年代一起启用)。 - 定位:
- Client 模式 / 小堆(< 100MB) 的默认选择——单线程反而省去了线程间协调开销;
- 其他收集器 Full GC 的兜底——CMS 的 Concurrent Mode Failure、G1 Full GC(早期版本)都会回退到 Serial Old 风格的单线程整堆回收,一次 Full GC 常常就是几秒;
- Serverless 冷启动、CI / CLI 短任务、单核容器等"堆小、生命周期短"的场景。
flowchart LR
S1["业务线程运行"] --> S2["STW:单线程 GC"] --> S3["业务线程恢复"] 6.2.2 ParNew¶
- 本质:Serial 的多线程版本,仅负责新生代。
- 历史地位:在很长一段时间里,ParNew 是唯一能与 CMS 配合的新生代收集器,因此成为 CMS 体系的"标配"。
- 现状:
- JDK 9 把
ParNew + CMS 以外的所有 ParNew 组合标记为废弃; - JDK 14 随 CMS 一并被移除。
- JDK 9 把
- 新项目无需再关注 ParNew,但维护老 JDK 8 服务时仍可能看到。
6.2.3 Parallel Scavenge / Parallel Old(JDK 8 默认)¶
- 线程模型:多线程 GC,GC 期间全程 STW(和 Serial 一样会 STW,只是"并行"干活,不是"并发"与业务线程同时跑)。
- 算法:新生代复制算法(PS);老年代标记-整理(PO)。
- 启用参数:
-XX:+UseParallelGC(JDK 8 为默认,JDK 9+ 需显式指定)。 - 设计目标:吞吐量优先,即
业务运行时间 / (业务运行时间 + GC 时间)最大化,不追求单次停顿短。 - 独有的自适应调节:PS 提供一组"说目标、不说参数"的开关,让 JVM 自己调新生代大小、Survivor 比例、晋升阈值:
| 参数 | 含义 |
|---|---|
-XX:MaxGCPauseMillis=<N> | 期望的最大停顿时间目标(软目标,JVM 尽量满足) |
-XX:GCTimeRatio=<N> | 吞吐量目标,GC 时间占比 = 1/(1+N),默认 99(即 1%) |
-XX:+UseAdaptiveSizePolicy | 打开自适应调节,JVM 自动调整分代大小(默认开启) |
Parallel 的适用场景
追求吞吐量、不关心单次停顿长短的离线 / 后台任务:
- 批处理作业(Spark / Flink 的部分场景)、ETL、数据导出
- 定时计算、离线报表
- CPU 核数多、堆不大(几 GB)、可接受秒级 STW 的场景
反之,在线接口服务通常要的是"停顿可控",此时应选 G1 / ZGC,而不是 Parallel。
6.2.4 经典组合关系¶
§6.1 全景图中的几条连线,对应的就是历史上真正被广泛使用的组合:
| 新生代 → 老年代 | 组合特点 | 启用参数 |
|---|---|---|
| Serial → Serial Old | 单线程全 STW,小堆兜底 | -XX:+UseSerialGC |
| ParNew → CMS | 低停顿经典组合(已废弃) | -XX:+UseConcMarkSweepGC |
| Parallel Scavenge → Parallel Old | 吞吐量优先,JDK 8 默认 | -XX:+UseParallelGC |
| Serial → CMS | 历史可用组合,实际极少见 | - |
7. CMS 详解¶
CMS(Concurrent Mark Sweep)是第一个真正意义上的并发收集器,目标是最短 STW 停顿时间。它首次将「标记」与「清除」两个最耗时的阶段挪到业务线程并发执行,是从 Parallel 到 G1 / ZGC 这条演进主线上的关键跳板。
7.1 四阶段流程¶
flowchart LR
C1["① 初始标记<br>STW,仅标记 GC Roots<br>直接关联的对象,极快"]
--> C2["② 并发标记<br>与业务线程并发<br>从 GC Roots 遍历整个对象图<br>耗时最长但不停顿"]
--> C3["③ 重新标记<br>STW,修正并发标记期间<br>因业务线程修改引用<br>导致的标记变动(增量更新)"]
--> C4["④ 并发清除<br>与业务线程并发<br>清除不可达对象<br>不移动对象(标记-清除)"] 以典型的 4GB 老年代为例,一次完整的并发收集时间分布大致如下(具体数值受堆大小、对象图复杂度、业务写入速率影响):
┌─────────────┬──────────────────────┬──────────────┬────────────────┐
│ Initial Mark│ Concurrent Mark │ Remark │ Concurrent │
│ (STW) │ (并发,不停业务) │ (STW) │ Sweep (并发) │
├─────────────┼──────────────────────┼──────────────┼────────────────┤
│ ~5 ms │ ~2 s │ ~20 ms │ ~1 s │
│ 扫 GC Roots │ 顺着 Roots 扫全堆 │ 只扫 dirty │ 清理白色对象 │
│ │ 业务线程并发运行, │ card(增量)│ │
│ │ write barrier 记录 │ │ │
│ │ 引用变更 │ │ │
└─────────────┴──────────────────────┴──────────────┴────────────────┘
关键设计取舍:
- 只在 ① 和 ③ STW——这两步耗时极短(合计约 25 ms),而最耗时的 ② 和 ④ 都是并发完成。这正是 CMS 把整体停顿从秒级压到几十毫秒的根本原因。
- Remark 阶段依赖写屏障 + Card Table——并发标记期间业务线程的引用修改已被写屏障记为 dirty card,Remark 只需扫这些增量,而不是扫整堆(三色标记 / 写屏障的原理见 §2)。
- 采用 Mark-Sweep 而非 Mark-Compact——整理(Compact)需要移动对象、更新所有引用,必须 STW 完成。CMS 为了换取并发清除的能力,主动放弃了内存整理——这也是下文「内存碎片」问题的根源。
7.2 三大核心问题¶
CMS 的三个致命缺陷
1. 浮动垃圾(Floating Garbage)
并发清除阶段业务线程产生的新垃圾,标记阶段已结束、不会被识别,只能等下一次 GC 回收。为此 CMS 必须预留一部分老年代空间给浮动垃圾使用,不能等到老年代 100% 满才触发 GC。
2. 内存碎片(Fragmentation)
Mark-Sweep 不移动对象,长期运行后老年代会碎片化。即使总剩余空间充足,也可能因为找不到连续空间而无法分配大对象,此时被迫触发 Full GC。
3. 并发模式失败(Concurrent Mode Failure)
如果在 CMS 并发执行期间老年代空间不够容纳新晋升对象(包括浮动垃圾),JVM 会中断 CMS、退化为 Serial Old 单线程整堆 Mark-Compact——一次停顿可能长达几秒到十几秒,是 CMS 最可怕的抖动来源。
触发链路:
7.3 关键参数¶
| 参数 | 作用 | 调优建议 |
|---|---|---|
-XX:+UseConcMarkSweepGC | 启用 CMS(JDK 9 后已废弃) | - |
-XX:CMSInitiatingOccupancyFraction=N | 老年代占用达到 N% 时触发 CMS | 默认 ~92%,建议调低到 70~80%,留出浮动垃圾空间 |
-XX:+UseCMSInitiatingOccupancyOnly | 只按上面阈值触发,不让 JVM 自适应调整 | 建议开启,让 GC 触发时机可预测 |
-XX:+CMSScavengeBeforeRemark | Remark 前先做一次 Young GC | 建议开启,能显著缩短 Remark 的 STW |
-XX:+CMSClassUnloadingEnabled | CMS 并发收集期间卸载类 | 有动态加载类的场景需开启 |
-XX:CMSFullGCsBeforeCompaction=N | 每 N 次 Full GC 后强制压缩一次老年代 | 碎片严重时设为 0~5 |
7.4 历史意义与退役¶
CMS 在 2004 年(JDK 1.4.2)引入,统治了整个 Java 服务端低延迟场景十年之久。但它也留下了两个无法根治的硬伤:内存碎片和并发模式失败——这两者叠加意味着 CMS 的尾延迟永远不可控。
- JDK 9:
-XX:+UseConcMarkSweepGC被标记为废弃,启动时会打印 deprecation 警告。 - JDK 14:CMS 被正式移除,相关代码从 HotSpot 彻底删除。
- 精神遗产:CMS 开创的"并发标记 + 写屏障 + 增量更新"范式被 G1 完整继承并改良(SATB 替代增量更新、Region + Compact 解决碎片问题),dirty card 机制更是一直沿用至今。读懂 CMS 是读懂 G1 的前提。
⚠️ 升级建议:新项目直接用 G1(JDK 9+ 默认)或 ZGC(JDK 15+ 生产可用);存量 CMS 项目在升级 JDK 时必须同步切换收集器,最简单的迁移路径是 -XX:+UseG1GC。
8. G1 详解¶
G1(Garbage First)是 JDK 9+ 的默认收集器,核心设计思想是将堆划分为等大的 Region,优先回收垃圾最多的 Region。
8.1 G1 的堆结构¶
G1 堆(示例:2GB 堆,Region 大小 = 2MB,共 1024 个 Region)
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ E │ S │ O │ O │ H │ H │ E │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O │ E │ O │ E │ S │ O │ E │ O │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ E │ O │ O │ E │ O │ E │ O │ E │
└────┴────┴────┴────┴────┴────┴────┴────┘
E = Eden Region S = Survivor Region
O = Old Region H = Humongous Region(大对象,占连续多个 Region)
8.2 G1 的 GC 模式¶
| 模式 | 触发条件 | 回收范围 | STW |
|---|---|---|---|
| Young GC | Eden Region 用完 | 所有 Young Region | 是(短暂) |
| Mixed GC | 老年代占堆比例超过阈值(默认 45%) | 所有 Young + 部分 Old Region | 是(短暂) |
| Full GC | Mixed GC 来不及回收 / 大对象分配失败 | 整堆 | 是(长,应避免) |
8.3 G1 的 Remembered Set(RSet)¶
G1 的每个 Region 都维护一个 RSet,记录哪些其他 Region 的对象引用了本 Region 中的对象。这样 GC 时只需扫描 RSet,不必扫描整个堆,实现 Region 级别的独立回收。
Region A (Old) Region B (Young)
┌──────────────┐ ┌──────────────┐
│ obj1 ──────────────→ │ obj2 │
│ │ │ │
│ RSet: {} │ │ RSet: {A} │ ← 记录 A 中有引用指向 B
└──────────────┘ └──────────────┘
G1 RSet 的性能代价
RSet 的代价:每次引用关系变化都要更新 RSet(通过 Write Barrier 实现),这是 G1 内存占用较高的原因之一。
💡 优化建议:在引用关系变化频繁的场景中,G1 的内存开销会相对较高,需要适当增大堆空间。
9. ZGC 详解(JDK 15+ 生产可用)¶
ZGC 的目标是无论堆多大,STW 停顿时间都不超过 10ms(实际通常 < 1ms)。
9.1 染色指针:ZGC 如何在指针里做文章¶
ZGC 在 64 位指针的高位(原版 ZGC 在 JDK 11~20 里默认占 bit 42~45、共 4 位)直接编码 GC 状态,体现成"染色"。这 4 位分别表示:
| 标志位 | 含义 |
|---|---|
Finalizable | 该引用可由 Finalizer 达(即使对象"已死") |
Remapped | 最新发生的转移周期已经完成,指针是指向新地址的"最新值" |
Marked1 | 在最新一轮标记中已被标记(与 Marked0 交替使用) |
Marked0 | 在上一轮标记中已被标记 |
示意图(并非精确位置,仅表示"指针高位有几个状态标志 + 低 42~44 位存真实地址"):
64-bit Pointer Layout(示意):
┌────────────────────────┬──────────────────────────────┐
│ Unused(18b) │ Color(4b) │ Virtual address(低 42~44 位) │
│ │ F,R,M1,M0 │ 指向对象实际地址(堆最大理论 16TB) │
└────────────────────────┴──────────────────────────────┘
💡 早期资料常写"ZGC 最大支持 4TB"——那是 JDK 11~12 的早期限制。JDK 13 起 ZGC 支持最大 16TB 堆,JDK 15 起正式生产可用。
分代 ZGC(JDK 21+)的位布局已调整
上图的"bit 42~45 四位染色"是原版 ZGC(JDK 11~20)的设计。分代 ZGC(JDK 21+,JEP 439)为了划分年轻代/老年代、以及 remember set 交互,在底层分配了更多颜色位——读者遇到"ZGC 最终形态"这种表述时,需略好分代/非分代的区别;本节代码默认描述原版 ZGC 以保持原理层面的清晰度。
9.2 并发阶段流程¶
ZGC 的并发阶段(几乎全程与业务线程并发):
flowchart LR
Z1["① 初始标记<br>STW < 1ms<br>标记 GC Roots"]
--> Z2["② 并发标记<br>与业务线程并发<br>遍历对象图"]
--> Z3["③ 再标记<br>STW < 1ms<br>处理剩余标记"]
--> Z4["④ 并发转移准备<br>选择回收集合"]
--> Z5["⑤ 初始转移<br>STW < 1ms<br>转移 GC Roots 直接引用的对象"]
--> Z6["⑥ 并发转移<br>与业务线程并发<br>移动对象,更新引用(读屏障)"] ZGC 读屏障机制
读屏障(Load Barrier):业务线程每次读取对象引用时,ZGC 插入一段检查代码,如果对象已被移动,自动修正指针。这是 ZGC 实现并发移动对象的关键。
💡 技术细节:读屏障是 ZGC 实现亚毫秒停顿的核心技术,虽然带来少量性能开销,但实现了真正的并发对象移动。
9.3 分代 ZGC(JDK 21+)¶
📖 分代 ZGC(JEP 439,JDK 21 引入;JEP 474,JDK 23 成为默认)的启用方式与收益对比见 JVM 现代实践与前沿技术 §7.1,本文不再重复。
10. 五大收集器横向对比¶
| 对比项 | Serial / Serial Old | Parallel Scavenge / Parallel Old | CMS | G1 | ZGC |
|---|---|---|---|---|---|
| GC 线程 | 单线程 | 多线程(并行) | 多线程(并发) | 多线程(并发) | 多线程(几乎全并发) |
| 是否全程 STW | 是 | 是 | 仅初始 / 重新标记 STW | 仅短暂 STW | 仅 < 1ms STW |
| 设计目标 | 简单、小堆 | 高吞吐 | 低停顿 | 可控停顿 + 吞吐平衡 | 极低停顿 |
| 最大停顿时间 | 长(全程 STW) | 长(全程 STW,但并行更短) | 数百 ms(Full GC 可达秒级) | 可控(默认 200ms 目标) | < 10ms(通常 < 1ms) |
| 吞吐量 | 低 | 最高 | 高 | 中高 | 略低(读屏障开销) |
| 内存占用 | 极低 | 低 | 低 | 中(RSet 开销) | 中(染色指针) |
| 适用堆大小 | < 100MB | 100MB ~ 数 GB | < 6GB | 6GB ~ 数十 GB | 数 GB ~ 16TB |
| JDK 版本 | 全版本可用 | JDK 8 默认,仍可用 | JDK 9 废弃 | JDK 9+ 默认 | JDK 15+ 生产可用 |
| 启用参数 | -XX:+UseSerialGC | -XX:+UseParallelGC | -XX:+UseConcMarkSweepGC(已废) | -XX:+UseG1GC | -XX:+UseZGC |
| 典型场景 | 小堆 / 冷启动 / 兜底 | 批处理 / 后台计算 / 吞吐敏感 | 历史遗留系统 | 通用场景 | 超大堆 / 低延迟场景 |
一句话选型指南
- 堆 < 100MB / Serverless / CLI 工具 → Serial
- JDK 8 + 离线批处理、ETL、不在乎停顿 → Parallel(JDK 8 默认,通常不用改)
- JDK 8 + 在线服务、追求停顿可控 → G1(JDK 8u40 起稳定,JDK 9+ 默认)
- JDK 11+ 且堆 > 16GB 或对 P99 停顿极敏感 → ZGC
- 还在用 CMS → 计划升级到 G1 或 ZGC
11. 常见问题 Q&A¶
Q1:为什么年轻代和老年代要分开?
弱分代假说:大多数对象朝生夕死。实测数据表明,超过 90% 的对象在第一次 Minor GC 时就被回收。分代的收益:Minor GC 只扫描新生代(约占堆的 1/3),速度快(通常 < 10ms),频率高但代价小。如果不分代,每次 GC 都要扫描全堆,代价极高。分代收集还能按"对象年龄"选择最优算法——新生代用复制(死多活少,浪费 50% 空间也划算),老年代用标记-整理(活多死少,复制成本太高)。
Q2:G1 是怎么做到可预测停顿的?
传统分代(CMS)的老年代是一块连续内存,回收时必须处理整个老年代,停顿随堆增大而不可控。G1 将堆切成小块(Region),每次根据
-XX:MaxGCPauseMillis的目标选垃圾最多的部分 Region 回收(Garbage First 名字由来),在有限时间内回收最多垃圾——停顿时间因此可预测。RSet 让 Region 可以独立回收(只扫 RSet 而非全堆),是可预测停顿的基础设施。
Q3:ZGC 为什么停顿时间这么短?
ZGC 通过染色指针将 GC 状态编码在指针高位,通过读屏障在业务线程读取引用时自动修正被移动对象的指针,使得对象转移(移动)可以与业务线程并发进行,不需要 STW。STW 阶段只剩标记 GC Roots 等极少量工作,因此停顿时间通常 < 1ms,与堆大小无关。
Q4:三色标记的漏标问题,CMS 和 G1 各用什么方案解决?
CMS 用增量更新(Incremental Update):黑色对象新增引用时,写屏障把黑色对象改回灰色,Remark 时重扫。G1 用 SATB(Snapshot At The Beginning):灰色对象删除引用时,写屏障把被删除的引用入队,Remark 时按"GC 开始时快照"视为存活。SATB 的优点是"不把黑色改回灰色"——减少 Remark 工作量。
Q5:Soft / Weak / Phantom Reference 到底什么时候会被回收?
按"约束力递减"排序:强引用永不回收;
SoftReference只在 Full GC 前"最后挣扎"时回收(内存够就保留),是典型的"内存敏感缓存"场景;WeakReference下一次 GC 不管内存够不够都回收,典型应用是WeakHashMap的 key 和ThreadLocalMap.Entry的 key;PhantomReference最弱——get()永远返回 null,唯一用途是配合ReferenceQueue接收"对象已被回收"的通知,替代不靠谱的finalize()。一句话:软引用防 OOM、弱引用防泄漏、虚引用做清理通知。📖 写屏障代价的量化数据(引用密集型业务 5%~10% 开销、Parallel 为什么吞吐最高)已在 §2.1 的"写屏障的代价"折叠块给出,生产调优题(Full GC 频繁怎么排查?、G1 vs ZGC 生产怎么选?)已在 GC 调优实战与常见误区 给出工程视角答案,本文不再重复,专注"机制原理"题。
12. 一句话口诀¶
可达性分析找活人、三色标记搞并发、写屏障补漏标、分代假说分代回收——从 Serial 到 ZGC,一部演进史就是一部"把 STW 挪到业务线程并发时做"的历史。