GC 干活的顺序永远是先找出垃圾、再收拾垃圾。找垃圾这一步,HotSpot 用的是可达性分析(Reachability Analysis):先挑一组"绝对不能被回收"的对象当起点,叫 GC Roots,然后顺着引用关系一路找过去------能走到的对象算活着,走不到的就当垃圾处理。
这套思路现在是主流 JVM 的标配,但大多数人只记得"可达性分析"这五个字。真被追问 GC Roots 具体指哪些、和引用计数法到底差在哪,就说不利索了。而恰恰是这些细节,在排查线上内存问题和面试里才真正用得上。
一、可达性分析到底在做什么
GC Roots 是哪些对象
GC Roots 是"必须活着"的那批引用,也是整张引用图的入口。规范里列了好几类,落到代码上,大致是这么些东西:
- 虚拟机栈里正在用的局部变量和参数 。方法里写了
Object obj = new Object(),只要这个方法还没返回,obj指向的对象就不会被回收; - 本地方法栈里 JNI 引用的对象。就是那些 C/C++ 写的 native 方法拿着的 Java 对象;
- 方法区里的静态属性 。
static Object cache = new Object(),只要这个类没被卸载,它指向的对象就一直算活着; - 方法区里的常量引用。比如字符串常量池里的字面量;
- 被 synchronized 锁住的对象。锁没释放,作为锁对象的那个对象就不能回收;
- JVM 内部自己用的引用 。像系统类加载器、基本类型对应的 Class 对象(
int.class这种)、常驻的异常对象(比如 NPE 那个单例)。
这些 roots 有个共同点:要么压根不在堆里,要么虽然指向堆,但 JVM 会强制它们保持存活。从这几个入口出发遍历,堆里所有还活着的对象都能被覆盖到------这就是它判断得准的原因。
一次完整的分析过程
分析本身是一次图遍历,但工程上有几个细节值得说:
- 枚举 GC Roots。这一步得暂停所有用户线程(STW,Stop The World)。不暂停不行:你这边刚把 roots 数完,那边用户线程就改了引用,图都变了,分析结果自然不作数。
- 从 roots 出发遍历引用链。顺着引用关系递归地走,走过的对象打上"可达"标记。遍历完还没打上标记的,就是不可达、可回收的对象。
- 二次标记。被判不可达的对象不会立刻回收,中间还夹着一道 finalize() 的流程。这块是历史包袱,下面单独拎出来说。
finalize():该翻篇的历史
一个对象被判不可达后,如果它重写了 finalize() 并且从没执行过,JVM 会把它丢进 F-Queue,交给一条低优先级的 Finalizer 线程去跑 finalize()。跑的时候对象还有最后一次"自救"机会------比如在 finalize() 里把 this 赋给某个静态变量,就重新接回了引用链,这次就不回收了。只有自救失败,才会真正被标记回收。
这套机制现在基本可以当它不存在:执行时机不确定、可能根本不执行,还拖慢回收。从 Java 9 开始 finalize() 就被标成 deprecated,官方三令五申让别用,替代方案是 try-with-resources 或者 Cleaner。
所以如果面试里还有题在问"finalize() 怎么让对象自救",答完最好补一句"新版本已经淘汰了这个机制",能听出你是跟着版本在走的。
二、为什么 Java 没选引用计数法
讲可达性分析,绕不开要和引用计数法做个对比------毕竟后者才是最符合直觉的方案。
思路很简单:给每个对象挂一个计数器,谁引用它就给计数加一,引用失效就减一,减到零说明没人用了,直接回收。即时、简单、不用 STW,Python 至今还在用它。
但它有个治不好的病:处理不了循环引用。A 引用 B、B 引用 A,外部再没有别的引用了,这俩本该一起被回收;可它们互相把对方的计数顶在 1,谁也归不了零,就这么永远赖在内存里。要想补上这个窟窿,就得额外做环检测或者配合别的算法,复杂度一上去,"实现简单"这个最大的优点也就没了。
可达性分析从根上绕开了这个问题:它不关心"谁引用了我",只关心"从根能不能走到我"。两个对象就算互相引用得再紧,只要从 GC Roots 出发到不了它们,一律算垃圾。
三、JDK 21 之后,GC 这两年挪到哪了
"标记"这一步(也就是可达性分析),说实话十几年没什么大变化。但 GC 的另一半------"怎么把垃圾收得又快又省"------这两年动作特别大。既然都聊到 GC 了,顺手把最近的动态捋一遍,都是选型和面试容易碰到的。
分代 ZGC 登场(JDK 21,JEP 439) 。ZGC 从 JDK 15 起就是正式功能,卖点是亚毫秒级停顿、停顿时间几乎不随堆大小增长。但它早期不分代,每次都扫全堆,CPU 花在大量"注定还活着"的老对象上,有点浪费。JDK 21 给它补上了分代:堆分成年轻代和老年代,年轻代回收得更勤(绝大多数对象都是朝生夕死),老年代扫得少。不过当时还得手动加 -XX:+ZGenerational 才开。
分代 ZGC 成为默认(JDK 23,JEP 474;JDK 24 移除非分代模式,JEP 490) 。接下来的两个版本把这个方向走到底了:非分代 ZGC 被删除,-XX:+UseZGC 开出来的就是分代。所以"用 ZGC 要记得加参数"这条老经验,现在可以扔了。
紧凑对象头(JDK 25,JEP 519)。这是个不起眼但很实在的改动。堆里每个对象都带一个对象头,存类指针、identity hash 之类的元数据,64 位 JVM 默认(开启指针压缩)是 12 字节,关掉压缩则是 16 字节。JEP 519 把它压到 8 字节,业务代码一行不用改。对象越密集省得越多,官方口径大约是堆占用降两成、GC 频率跟着降一成多。小对象特别多的应用(大量短命的 DTO、事件对象)收益最明显。到 JDK 27,这个特性已经默认开启。
分代 Shenandoah 转正(JDK 25,JEP 521)。Shenandoah 这个低停顿收集器,分代版本在 JDK 24 还是实验特性,JDK 25 正式转正。它和 ZGC 定位相近,但内存开销通常更省一点,是低延迟场景下 ZGC 之外的另一条路。
G1 减少同步(JDK 26,JEP 522)。G1 至今仍是大多数服务端的默认收集器。JDK 26 动了它的写屏障,减少 GC 线程和应用线程之间的同步竞争,同样配置下吞吐能好一些。同一版本里,AOT 缓存(JEP 516)也不再是 G1 的专利,ZGC 也能用上,启动更快。
G1 变成所有环境的默认 GC(JDK 27,JEP 523)。JDK 27 在 2026 年 9 月发布,最有意思的一条是:G1 取代了 Serial,成为所有环境(包括单核、内存受限的配置)的默认收集器。这在几年前几乎是不可想象的------那会儿小内存环境默认还是 Serial。能让 G1 在受限环境下持平 Serial 的吞吐,靠的正是 JEP 522 那类减少同步的优化。
再往远看
- 对象本身的形态可能要变。Project Valhalla 的值类型如果落地,值对象不再有独立的对象头和引用身份,能被压平存储。堆里对象更少、头也更省,GC 的压力会跟着降------这比紧凑对象头更彻底。
- 启动时间继续被压缩。Project Leyden 和 AOT 系列在把"预热"这件事往前挪,以后 JVM 启动即接近峰值状态,GC 也得跟着适配。
- 低延迟正在变成默认预期。停顿从秒级走到毫秒级、再到亚毫秒级,现在已经很少有人还为了几百毫秒的 GC 停顿去改架构了。关注点慢慢从"停顿"挪到了"内存占用"和"云上成本"。
- 云原生和大堆场景。容器里对内存的感知、CPU 配额和 GC 线程数的关系,会越来越重要。GC 是不是"感知容器 limit",直接决定它会不会被 OOMKilled。
一句话:判断"谁是垃圾"的办法早就不变了,怎么把垃圾收得又快又省,才是这两年在卷的地方。
四、总结
回到开头那个问题------Java 怎么标记无用对象?答案是可达性分析:从一组 GC Roots 出发遍历引用链,能到达的算存活,到不了的判定为可回收。它靠的是可达性而不是引用次数,所以天然免疫循环引用,这也是 Java 弃用引用计数法的根本原因。
有几条值得带走:
- 判断对象可回收,中间还有一次 finalize() 的"自救"机会,但这套机制从 Java 9 起就被淘汰了,别再用;
- 枚举 GC Roots 需要 STW,这是所有基于可达性分析的收集器都无法完全绕开的停顿来源之一;
- "标记"这一步很稳定,"回收"这一步进化很快------从 G1 到分代 ZGC,再到紧凑对象头,都是在让回收更快、更省、停顿更短。
说到底,可达性分析只回答了"谁该死",真正影响线上表现的,是收集器怎么高效地执行这个判决。拎清这条分界线,很多 GC 问题和参数就不再是玄学了。