GC Roots 与三色标记法¶
GC Roots 是可达性分析的起点,三色标记法是现代低延迟 GC 的核心标记算法,通过写屏障解决并发时的漏标问题。
GC Roots¶
GC Root 是垃圾回收的**起点**。从这些对象出发,能走到的就是活的,走不到的就是垃圾。
七种 GC Root¶
| 类型 | 说明 | 示例 |
|---|---|---|
| 虚拟机栈中的引用 | 每个线程栈帧中的局部变量 | 方法里的 User user = new User() |
| 静态变量 | 类的 static 引用 | private static AppConfig instance |
| 常量池引用 | static final 引用的对象 | private static final String NAME |
| JNI 引用 | 本地方法中持有的 Java 对象 | native void method(Buffer buf) |
| 活跃线程 | 每个正在运行的线程 | Thread 对象 |
| 同步锁 | 被 synchronized 持有的对象 |
synchronized (sharedLock) |
| Class 对象 | 已加载类对应的 Class 对象 | User.class |
可达性分析图¶
graph TD
R1[虚拟机栈局部变量] --> A[对象 A]
A --> B[对象 B]
B --> C[对象 C]
B --> D[对象 D]
R2[静态变量] --> E[对象 E]
E --> F[对象 F]
R3[JNI 引用] --> G[对象 G]
R4[活跃线程] --> H[对象 H]
R5[同步锁] --> I[对象 I]
R6[Class 对象] --> J[对象 J]
K[对象 K] -.->|无引用链| R1
L[对象 L] -.->|仅被 K 引用| K
style K fill:#ff6b6b,color:#fff
style L fill:#ff6b6b,color:#fff
style R1 fill:#51cf66,color:#fff
style R2 fill:#51cf66,color:#fff
style R3 fill:#51cf66,color:#fff
style R4 fill:#51cf66,color:#fff
style R5 fill:#51cf66,color:#fff
style R6 fill:#51cf66,color:#fff
K 和 L 没有任何 GC Root 能到达 → 垃圾,可回收。
三色标记法¶
垃圾回收中用来标记对象存活状态的算法,是 G1、ZGC、Go 等现代 GC 的核心。
三种颜色¶
| 颜色 | 含义 | 最终状态 |
|---|---|---|
| ⚪ 白色 | 还没扫描到的 | 垃圾,可回收 |
| 🔘 灰色 | 扫描过了,但它引用的对象还没扫 | 等待继续扫描 |
| ⚫ 黑色 | 扫描过了,它引用的对象也都扫了 | 存活 |
完整标记过程¶
用一个例子演示:
初始状态:GC Root 直接引用的对象标灰,其余全白。
扫描 A:A 有引用 → B,A 变黑,B 变灰。
扫描 B:B 有引用 → C、D,B 变黑,C 和 D 变灰。
扫描 C:C 有引用 → G,C 变黑,G 变灰。
扫描 D、F、G:无更多引用,全部变黑。
所有对象变黑,标记完成,无白色对象可回收。
graph LR
subgraph "① 初始状态"
A1[⚫A] --> B1[⚪B]
E1[⚫E] --> F1[⚪F]
end
subgraph "② 扫描 A"
A2[⚫A] --> B2[🔘B]
E2[⚫E] --> F2[⚪F]
end
subgraph "③ 扫描 B"
A3[⚫A] --> B3[⚫B]
B3 --> C3[🔘C]
B3 --> D3[🔘D]
E3[⚫E] --> F3[⚪F]
end
流程总结¶
graph TD
A[GC Root 标灰] --> B[取一个灰色对象]
B --> C[扫描它的引用]
C --> D[灰变黑,引用的白变灰]
D --> E{还有灰色?}
E -->|是| B
E -->|否| F[剩余白色 = 垃圾]
并发标记的两大问题¶
三色标记通常和业务线程**并发执行**,会产生问题:
问题一:漏标 — 活对象被误判为垃圾 💀¶
**同时**满足两个条件才会漏标:
- 存在黑色对象到白色对象的**新引用**
- 所有灰色对象到该白色对象的引用都被**删除**
sequenceDiagram
participant GC as GC 线程
participant App as 业务线程
Note over GC: ⚫A 🔘B ⚪C<br>A→C, B→C
App->>App: A 删除 A→C
App->>App: B 新增 B→C
Note over GC: A 是黑色,不会再被扫描<br>C 是白色 → 误判为垃圾!💥
问题二:多标 — 死对象被当成活的(浮动垃圾)😅¶
并发期间某个对象变成垃圾,但标记阶段已经过了它 → 这次回收不了。下次 GC 就能回收,不致命,只是多占一轮内存。
解决漏标¶
| 方案 | 策略 | 使用者 | 优缺点 |
|---|---|---|---|
| 增量更新 | 黑色新增引用时,把它变回灰色,重新扫描 | CMS | 缺点:Remark 阶段可能较长 |
| SATB(原始快照) | 删除引用时,记录被删除的对象,标记它 | G1 | 缺点:可能多标浮动垃圾,但不会漏标 |
| 染色指针 + 读屏障 | 硬件级别解决 | ZGC | 暂停 <1ms |
对比总结¶
| 传统标记 | 三色标记 | |
|---|---|---|
| 状态 | 二元(已访问/未访问) | 三态(白/灰/黑) |
| 并发能力 | 无法区分"还没扫到"和"确认是垃圾" | 精确追踪进度,写屏障维护一致性 |
| 使用场景 | 单线程 GC | 并发 GC(G1、ZGC、Go) |
练习题¶
题目一:为什么多标(浮动垃圾)不致命,但漏标会导致程序崩溃?
答案
多标只是把本该回收的对象多留了一轮,下次 GC 就能回收,影响只是多占一点内存。漏标则相反——活对象被当成垃圾回收了,程序还在用这个对象但内存已经被释放,导致空指针、数据损坏甚至 JVM 崩溃。
题目二:G1 的 SATB 和 CMS 的增量更新有什么区别?
答案
增量更新在"新增引用"时拦截:黑色对象新增了对白色对象的引用,就把黑色变灰重新扫描。SATB 在"删除引用"时拦截:灰色对象删除了对白色对象的引用,就把被删除的白色对象记录下来,在标记结束时强制标灰。G1 选 SATB 是因为删除引用更常见(对象字段赋 null),拦截成本更低。