垃圾回收算法#
一句话答案#
三种基础算法:标记-清除(碎片)、复制(浪费空间)、标记-整理(移动开销),分代收集结合使用。
核心要点
三种基础算法:
1. 标记-清除(Mark-Sweep)
阶段1 标记:从 GC Root 出发,标记所有可达对象
阶段2 清除:回收未被标记的对象(不可达对象)
内存布局示意(O=存活, X=待回收, _=空闲):
回收前:[O][X][O][O][X][X][O]
回收后:[O][_][O][O][_][_][O] ← 碎片化!plaintext- 优点: 实现简单,不移动对象
- 缺点: ① 产生大量内存碎片,大对象分配困难 ② 需要维护空闲链表,分配效率低
2. 标记-复制(Mark-Copy)
将内存分为两半(from 区 / to 区)
标记:标记 from 区存活对象
复制:将存活对象紧密地复制到 to 区
清空 from 区,交换 from 和 to 角色
from:[O][X][O][O][X][X][O]
to(复制后):[O][O][O][O]______ ← 无碎片!plaintext- 优点: 无内存碎片,分配简单(指针碰撞)
- 缺点: 内存利用率只有 50%;存活对象多时复制开销大
JVM 年轻代用的是改进版复制算法(Eden:S0:S1 = 8:1:1):
- 每次只有 10% 空间(一个 Survivor)闲置,而不是 50%
- Minor GC 后存活率低,复制开销小,非常适合
3. 标记-整理(Mark-Compact)
标记:标记所有存活对象
整理:将存活对象向一端移动,然后直接清除边界以外的内存
整理后:[O][O][O][O]___________ ← 无碎片,利用率100%plaintext- 优点: 无内存碎片,内存利用率高
- 缺点: 移动对象需要更新所有引用(Stop-The-World 时间长),开销大
老年代通常用标记-整理或标记-清除(CMS 用标记-清除,G1 整体用标记-整理)。
三色标记与并发漏标(闭环主线)#
上面三种算法都默认”标记时世界静止”。但 CMS/G1 的并发标记要让用户线程和标记线程同时跑,对象图会被边标记边修改——这就引出了三色标记法和漏标问题。这是 JVM 并发 GC 最核心的一条线。
1. 三色定义(按”扫描进度”给对象染色)
| 颜色 | 含义 | 状态 |
|---|---|---|
| 白色 | 尚未被 GC 访问到 | 标记结束仍为白 = 垃圾,被回收 |
| 灰色 | 自己已被访问,但它的引用字段还没扫完 | 处于”波面”上,正在处理 |
| 黑色 | 自己和所有引用字段都已扫完 | 确定存活,不会再扫 |
并发标记如何推进:
- 初始:GC Roots 直接引用的对象置灰,其余全白。
- 推进:从灰色集合取一个对象,把它引用的白色对象染灰,自己扫完后转黑(灰→黑)。
- 结束:灰色集合为空,所有可达对象变黑,剩下的白色对象即垃圾。
- 本质是一次广度/深度优先遍历,灰色是黑白之间的”扫描波面”。
2. 漏标的两个充要条件(必须同时满足,缺一不漏标)
并发期间用户线程改引用,可能导致一个本应存活的白对象被错杀(漏标)。漏标当且仅当同时满足:
条件①:黑对象新增了一条指向某白对象的引用(黑已扫完不会再看它的引用,新引用发现不了)。 条件②:该白对象与所有灰对象之间的引用关系全部被破坏(没有任何灰对象能再触达它)。
- 直觉:白对象唯一的”活路”是被某个还没扫完的灰对象引用到。只要还有一个灰对象引用它(条件②不满足),迟早会被扫到、染灰、存活。
- 只要还没扫到它的黑对象其实没新增引用(条件①不满足),它就和这次快照无关。
- 两条同时成立,白对象既够不到灰、又只挂在已扫完的黑下面 → 永远不会被染色 → 被错误回收。
3. 两种解法,各破坏一个条件(不能搞反!)
| 方案 | 破坏哪个条件 | 写屏障记录什么 | remark 阶段做什么 | 代价 |
|---|---|---|---|---|
| 增量更新 Incremental Update(CMS) | 破坏条件① | 记录黑对象新增的”黑→白”引用(关注”插入”) | 重新扫描这些被记录的黑对象(把它当灰重新处理) | remark 要重扫黑对象,停顿较长 |
| SATB Snapshot-At-The-Beginning(G1) | 破坏条件② | 记录被删除/覆盖的旧引用(关注”删除”) | 按标记开始时的快照,把旧引用指向的白对象重新标活 | 多标活 → 产生浮动垃圾,但 remark 短 |
- 增量更新(CMS):盯条件①。一旦黑对象插入对白对象的新引用,写屏障把这个黑对象记下来,remark 时重新扫描它,相当于让它”退回灰色”。
- SATB(G1):盯条件②。一旦灰/白对象的某条引用被删除(覆盖),写屏障把被删除的那个旧引用推进 SATB 缓冲区,相当于”保留标记开始那一刻的快照”,凡是快照里活着的就当它活着标记。这样即使条件②被破坏(引用被删),旧引用记录仍能让白对象被标活。
- 代价对症:SATB 按快照标记,会把”标记期间本已死掉”的对象也保活 → 这批就是浮动垃圾,留待下次 GC;但它 remark 只需处理缓冲区,停顿短。增量更新不产生这类浮动垃圾,但 remark 要重新扫描黑对象,停顿相对长。
4. 写屏障(Write Barrier)如何拦截
- 写屏障是 JVM 在对象引用字段赋值(如
obj.field = other)时,由 JIT 在赋值指令前后插入的一小段代码(不是硬件内存屏障,是 GC 用的”切面/钩子”)。 - 增量更新用写后屏障:赋值后,若是黑对象新指向白对象,记录该黑对象。
- SATB 用写前屏障:赋值前先把字段的**旧值(即将被覆盖的旧引用)**记入 SATB 缓冲区,再执行赋值。
- 代价:每次引用写入都多几条指令,是并发 GC 换取低停顿付出的吞吐成本。
面试回答(2分钟版)
垃圾回收有三种基础算法。标记-清除算法分两阶段,先从 GC Roots 出发标记所有可达对象,然后清除未标记的对象,优点是简单不需要移动对象,缺点是会产生大量内存碎片导致大对象分配困难。标记-复制算法把内存分为两半,每次只用一半,GC 时把存活对象紧凑地复制到另一半然后清空原区域,优点是没有碎片且分配只需指针碰撞,缺点是浪费一半空间。JVM 新生代用的是改进版复制算法,Eden 和两个 Survivor 按 8:1:1 划分,每次只浪费 10% 空间,因为新生代对象存活率低所以复制开销很小。标记-整理算法先标记存活对象然后将它们向一端移动,清除边界外的内存,优点是没有碎片且利用率 100%,缺点是移动对象要更新所有引用,STW 时间较长。实际使用中新生代用复制算法,老年代用标记-整理或标记-清除,CMS 用标记-清除所以会有碎片问题,G1 整体采用标记-整理的思路按 Region 回收。
追问与易错
追问方向:
- “什么是 GC Roots?”→ 虚拟机栈引用/静态变量/常量/JNI 引用
- “为什么新生代用复制算法?”→ 新生代对象朝生夕灭,存活率低,复制开销小
- “三色标记法是什么?”→ 白(未访问)/灰(自己访问过、引用未扫完)/黑(自己和引用都扫完)并发遍历对象图,详见上文「三色标记与并发漏标」
- “并发标记为什么会漏标?怎么解决?”→ 漏标需同时满足两条件:①黑对象新增对白对象的引用 ②白对象与所有灰对象的引用都被破坏。CMS 增量更新破坏条件①(记黑→白新引用,remark 重扫黑对象);G1 SATB 破坏条件②(记被删旧引用,按起始快照标记,产生浮动垃圾但 remark 短)
易错点:
- ❌ “引用计数法是 JVM 使用的”——JVM 使用可达性分析,引用计数有循环引用问题
- ❌ 混淆 Minor GC / Major GC / Full GC 的范围