垃圾回收算法与分代收集

举报
yd_94907100 发表于 2026/10/29 19:47:33 2026/10/29
【摘要】 从判活到回收,梳理 GC 算法与分代收集的完整逻辑

调优参数之前,先要明白 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 的完整流程

  1. 暂停所有应用线程。
  2. 从 GC Roots 出发标记 Eden 和 From Survivor 中的存活对象。
  3. 把存活对象复制到 To Survivor,年龄加一。
  4. 清空 Eden 和 From Survivor。
  5. 交换 From 和 To 的角色。
  6. 处理晋升到老年代的对象,更新卡表。
  7. 恢复应用线程。

如果 To Survivor 空间不足,存活对象通过分配担保直接进入老年代。

并发标记的三色抽象

CMS、G1、ZGC 都需要并发标记,而并发意味着应用线程可能在标记过程中修改引用。三色标记法把对象分为三种状态:

  • 白色:尚未访问,标记结束后仍是白色即判定为垃圾。
  • 灰色:自身已访问,但至少有一个引用还未扫描。
  • 黑色:自身和所有引用都已扫描完成。

并发标记的正确性要求是「黑色对象不能指向白色对象」,否则白色对象会被漏标。破坏这个不变式只有两种操作:

  • 插入:黑色对象新增指向白色对象的引用。
  • 删除:灰色对象删除指向白色对象的引用。

对应的解决方案是增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB):

| 方案 | 拦截操作 | 记录内容 | 重新扫描对象 | 使用者 |

| --- | --- | --- | --- | --- |

| 增量更新 | 插入 | 新引用的白色对象 | 黑色对象 | CMS |

| 原始快照 | 删除 | 被删除引用的旧值 | 灰色对象 | G1、Shenandoah |

SATB 的语义是「按标记开始时的快照判定存活」,因此会把标记期间死亡的对象也当作存活,产生浮动垃圾。这是 G1 选择 SATB 的代价,换来的是更低的重新标记成本。

常见坑 / 最佳实践

  1. 以为 System.gc() 能精确控制回收。它只是建议,且默认触发 Full GC。生产环境应加 -XX:+DisableExplicitGC,同时排查代码中是否有人调用(包括 DirectByteBuffer 的依赖库)。
  2. 把 MaxTenuringThreshold 调到最大来减少晋升。Survivor 空间不足时动态年龄判断会提前生效,参数根本不生效。应先调大 Survivor 比例,再观察晋升速率。
  3. 忽视 finalize 的性能与可靠性。finalize 让对象多活一轮,且执行线程优先级低,可能永远不执行。JDK 9 起标记废弃,JDK 18 起 finalization=disabled 可关闭。
  4. 在循环里创建大数组或大字符串。大对象直接进老年代,会快速推高老年代占用并触发 Full GC,与新生代参数无关。
  5. 只看 GC 次数不看耗时。Minor GC 频繁但单次 5 ms 尚可接受;单次 500 ms 的 Mixed GC 才是延迟杀手。要同时看频率、单次耗时和回收后剩余占用。

小结

  • HotSpot 用可达性分析判活,GC Roots 主要来自栈、静态变量、常量和 JNI 引用。
  • 标记-清除、标记-复制、标记-整理分别适合不同存活率的场景,分代收集把它们组合使用。
  • 卡表通过写屏障维护跨代引用,避免 Young GC 扫描整个老年代。
  • 三色标记中,增量更新解决插入漏标,SATB 解决删除漏标,G1 使用 SATB。
  • 动态年龄判断和大对象直接晋升是两个容易让参数失效的隐性规则。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。