上一篇文章我们把JVM运行时数据区(堆、栈、方法区、程序计数器、本地方法栈)过了一遍,评论区有不少人在问:内存区域划分清楚了,那对象到底什么时候被回收?CMS和G1天天听人讲,真正落实到线上该怎么选?还有人说面试被问到三色标记,当场就懵了。
这篇我就把JVM垃圾回收这条主线完整串起来:从根可达性算法这个判定对象生死的“第一原理”开始,到标记-清除、标记-复制、标记-整理三个经典算法的取舍逻辑,再到支撑CMS和G1并发标记的三色标记算法,最后落到这两个收集器的实现差异和调优实战上。如果你正在准备JVM面试,或者想让自己的GC日志不再“看了等于没看”,这篇应该能帮你把零散的知识点焊成一个整体。
1. 从线上Full GC案例到垃圾对象的判定
1.1 一次让我重新理解GC的线上故障
先讲个真实经历。前段时间排查一个线上服务变慢的问题,接口响应时间从几十毫秒涨到几秒,日志里不断出现Full GC。我第一反应是堆内存设置太小,结果一看监控,老年代使用率像爬楼梯一样一节一节往上顶,每次Full GC能回收一点点,但很快又涨回去。
后来用jmap导出一份堆dump,在MAT(Memory Analyzer Tool)里分析时发现,大量重复对象被一个静态Map里的临时对象链“持有”。这个Map是历史代码遗留下来的缓存,早就不再写入新数据了,但一直没人清理。于是整个对象链就像一块挂在墙上的旧海报,明明已经没人看,却因为胶带还在,就一直贴在墙上。
这个案例让我彻底意识到一个问题:判断一个对象能不能被回收,从来不取决于“它有没有被引用”,而是取决于“它能不能从GC Roots被访问到”。这就是根可达性算法的核心思想,也是理解JVM GC一切行为的前提。
1.2 为什么“没人引用的对象”这个说法是错的
很多人在面试时会说:没有任何引用指向的对象就可以被回收。这个说法放在JVM里其实是不准确的。
准确的说法是:从一组称为GC Roots的根节点出发,无法通过引用链到达的对象,才会被判定为可回收。一个对象可以被其他对象引用,但只要它的整个引用链到不了任何GC Roots,在GC眼里它就是垃圾。
打个比方,一个仓库里有很多货箱,判定哪些箱子是废品,不是看箱子里有没有标签,而是看能不能从仓库门口(根)沿着传送带走到这个箱子。哪怕箱子外面缠满了线,但这些线的另一端没有接在门口,这个箱子一样是要被清走的。
这个思想直接决定了后面所有垃圾回收器的实现方向,也决定了为什么CMS和G1能做并发标记,而早期的Serial、Parallel回收器只能STW(Stop The World)。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 根可达性算法:判定对象生死的第一原理
2.1 引用计数法的遗憾
在根可达性之前,历史上有一种更直观的方案,叫引用计数法:每个对象维护一个计数器,每被一个地方引用计数器加1,引用失效减1,计数为0就回收。
这套方案听起来简单,Python早期版本就靠它来管理内存,但它有两个致命短板。第一,每次引用赋值都要做加减法,本身就有额外开销;第二,循环引用问题完全无解。举个例子,A引用B、B引用A,外部对它们的引用断开后,两个对象的计数器都还是1,永远降不到0,内存泄漏就成了必然。
JVM最终选择了根可达性算法,从源头绕开了这个问题。因为只要回答“从根出发能不能到这个对象”,循环引用自然就断了——那两个互相抱团的对象,如果没有任何一条链指向它们,照样会被回收。
2.2 GC Roots到底有哪些
根可达性的关键在于根节点的定义。JVM里的GC Roots不是一个笼统的概念,具体包括这几类,面试时最好结合项目经验按这个分类说:
- 虚拟机栈中局部变量表引用的对象:正在执行的方法里的参数、局部变量、临时变量。
- 静态属性引用的对象:方法区中类的static字段指向的对象。
- 常量池引用的对象:字符串常量池里的引用、类常量等。
- 本地方法栈中JNI引用的对象:Java调用native方法时传过去的强引用。
- 被synchronized锁持有的对象:正在被加锁的Monitor对应的对象。
- 存活的Java线程对象本身,以及各类JVM内部的系统对象。
这里有个容易忽略的点:GC Roots里包含了“当前存活线程对象”,所以你在dump堆的时候经常会看到Thread对象,它们通常是存活状态的,除非线程已经终止。这也是为什么排查线程泄漏时要重点看线程堆栈,线程本身有时候就是根。
2.3 一次自救机会:finalize()的复活机制
根可达性算法还给对象留了一个“缓刑”阶段。当对象被判定为不可达后,如果它的finalize()方法还没有被JVM调用过,它会先被放入一个F-Queue队列,由一个低优先级的Finalizer线程去执行finalize()。
如果在finalize()里把this引用重新赋给某个静态变量或者某个GC Roots可达的对象,这个对象就能在下一次GC时重新“活过来”。但要注意,这种自救只能成功一次,第二次判定不可
