垃圾回收算法与分代收集
调优参数之前,先要明白 GC 在做什么决策:哪些对象是垃圾、什么时候回收、用哪种方式回收。这三个问题对应可达性分析、分代假说和具体回收算法。本文按「判活 → 算法 → 分代 → 并发标记」的顺序展开,帮助读者建立可推导的知识链,而不是死记名词。
如何判断对象已死
引用计数法的缺陷
引用计数为每个对象维护一个计数器,被引用加一,引用失效减一,归零即可回收。它实现简单、回收及时,但无法处理循环引用:
public class CycleDemo {
public static void main(String[] args) {
Object a = new Object();
Object b = new Object();
// 人为制造循环引用
a = b;
b = a;
a = null;
b = null;
System.gc();
}
}
a 和 b 互相引用,计数永远不为零。Python 用额外的循环检测器补上这个洞,而 HotSpot 直接放弃引用计数,采用可达性分析。
可达性分析
从一组称为 GC Roots 的对象出发,沿引用链遍历,能到达的对象存活,其余可回收。GC Roots 包括:
- 虚拟机栈中局部变量表引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI 引用的对象。
- 被
synchronized持有的对象。 - JVM 内部引用,如基本类型对应的 Class 对象、常驻异常对象、系统类加载器。
可达性分析必须在一致性快照上进行,也就是遍历期间引用关系不能变。为此需要 Stop The World,或者像 G1、ZGC 那样用并发标记加写屏障来近似一致性。
引用的四种强度
| 引用类型 | 回收时机 | 典型用途 |
| --- | --- | --- |
| 强引用 | 永不主动回收 | 普通赋值 |
| 软引用 | 内存不足时回收 | 内存敏感缓存 |
| 弱引用 | 下次 GC 必回收 | ThreadLocalMap、WeakHashMap |
| 虚引用 | 随时回收,仅用于通知 | 堆外内存释放、Cleaner |
软引用在 JDK 中由 SoftReference 表示,回收策略与 -XX:SoftRefLRUPolicyMSPerMB 相关;弱引用由 WeakReference 表示,ThreadLocal 的 key 就是弱引用,配合 value 的强引用形成经典的内存泄漏场景。
三种基础回收算法
标记-清除
标记出所有存活对象,然后清除未标记对象。实现简单,但有两个问题:一是效率随对象数量线性下降;二是产生大量内存碎片,大对象可能因找不到连续空间而提前触发 GC。
标记-复制
把内存分为两块,每次只用一块。回收时把存活对象复制到另一块,然后清空当前块。优点是没有碎片、分配只需移动指针;缺点是可用内存减半。实际实现不会按 1:1 划分,而是用 Eden 加两块 Survivor 的 8:1:1 结构,只浪费 10%。
复制算法的代价与存活对象数量成正比,因此适合存活率低的新生代。
标记-整理
标记后把存活对象向一端移动,再清理边界外的内存。没有碎片,也不需要减半内存,代价是移动对象需要更新所有引用,且移动过程必须 STW。适合存活率高的老年代。
三种算法的选择本质上是对「碎片」「内存利用率」「暂停时间」的权衡。
分代收集理论
两个分代假说
分代收集建立在两个经验假说上:
- 弱分代假说:绝大多数对象朝生夕灭。
- 强分代假说:熬过越多次 GC 的对象越难死亡。
由此推出跨代引用假说:跨代引用相对于同代引用只占极少数。于是不需要为老年代记录所有指向新生代的引用,只需在新生代维护一份「记忆集」,记录老年代指向新生代的指针位置。
记忆集与卡表
记忆集(Remembered Set)是抽象概念,卡表(Card Table)是 HotSpot 的实现方式。堆被划分为一个个 512 字节的卡页,只要卡页内存在指向新生代的引用,就把对应卡标记为 dirty。Young GC 时只需扫描 dirty 卡,而不必扫描整个老年代。
写屏障负责维护卡表:每次引用字段赋值时,把被引用对象所在的卡标记为 dirty。这也是为什么大量写操作会带来额外开销,ZGC 和 Shenandoah 的读屏障/写屏障开销更高,原因就在于此。
对象晋升
新生代对象经历一次 Minor GC 后进入 Survivor,年龄加一。年龄达到阈值(默认 15,-XX:MaxTenuringThreshold)后晋升老年代。还有两个提前晋升条件:
- 动态年龄判断:Survivor 中相同年龄对象的总大小超过 Survivor 的一半,该年龄及以上的对象直接晋升。
- 大对象直接进入老年代:超过
-XX:PretenureSizeThreshold的对象跳过新生代。
动态年龄判断是很多「对象过早晋升」问题的根源,它不需要等到 15 岁。
一次 Young GC 的完整流程
- 暂停所有应用线程。
- 从 GC Roots 出发标记 Eden 和 From Survivor 中的存活对象。
- 把存活对象复制到 To Survivor,年龄加一。
- 清空 Eden 和 From Survivor。
- 交换 From 和 To 的角色。
- 处理晋升到老年代的对象,更新卡表。
- 恢复应用线程。
如果 To Survivor 空间不足,存活对象通过分配担保直接进入老年代。
并发标记的三色抽象
CMS、G1、ZGC 都需要并发标记,而并发意味着应用线程可能在标记过程中修改引用。三色标记法把对象分为三种状态:
- 白色:尚未访问,标记结束后仍是白色即判定为垃圾。
- 灰色:自身已访问,但至少有一个引用还未扫描。
- 黑色:自身和所有引用都已扫描完成。
并发标记的正确性要求是「黑色对象不能指向白色对象」,否则白色对象会被漏标。破坏这个不变式只有两种操作:
- 插入:黑色对象新增指向白色对象的引用。
- 删除:灰色对象删除指向白色对象的引用。
对应的解决方案是增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB):
| 方案 | 拦截操作 | 记录内容 | 重新扫描对象 | 使用者 |
| --- | --- | --- | --- | --- |
| 增量更新 | 插入 | 新引用的白色对象 | 黑色对象 | CMS |
| 原始快照 | 删除 | 被删除引用的旧值 | 灰色对象 | G1、Shenandoah |
SATB 的语义是「按标记开始时的快照判定存活」,因此会把标记期间死亡的对象也当作存活,产生浮动垃圾。这是 G1 选择 SATB 的代价,换来的是更低的重新标记成本。
常见坑 / 最佳实践
- 以为
System.gc()能精确控制回收。它只是建议,且默认触发 Full GC。生产环境应加-XX:+DisableExplicitGC,同时排查代码中是否有人调用(包括DirectByteBuffer的依赖库)。 - 把
MaxTenuringThreshold调到最大来减少晋升。Survivor 空间不足时动态年龄判断会提前生效,参数根本不生效。应先调大 Survivor 比例,再观察晋升速率。 - 忽视
finalize的性能与可靠性。finalize让对象多活一轮,且执行线程优先级低,可能永远不执行。JDK 9 起标记废弃,JDK 18 起finalization=disabled可关闭。 - 在循环里创建大数组或大字符串。大对象直接进老年代,会快速推高老年代占用并触发 Full GC,与新生代参数无关。
- 只看 GC 次数不看耗时。Minor GC 频繁但单次 5 ms 尚可接受;单次 500 ms 的 Mixed GC 才是延迟杀手。要同时看频率、单次耗时和回收后剩余占用。
小结
- HotSpot 用可达性分析判活,GC Roots 主要来自栈、静态变量、常量和 JNI 引用。
- 标记-清除、标记-复制、标记-整理分别适合不同存活率的场景,分代收集把它们组合使用。
- 卡表通过写屏障维护跨代引用,避免 Young GC 扫描整个老年代。
- 三色标记中,增量更新解决插入漏标,SATB 解决删除漏标,G1 使用 SATB。
- 动态年龄判断和大对象直接晋升是两个容易让参数失效的隐性规则。
- 点赞
- 收藏
- 关注作者
评论(0)