Java的GC(垃圾回收)中,标记无用对象的核心方法是可达性分析算法

发布时间:2026/10/3 8:25:38
Java的GC(垃圾回收)中,标记无用对象的核心方法是可达性分析算法 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。这一步得暂停所有用户线程STWStop The World。不暂停不行你这边刚把 roots 数完那边用户线程就改了引用图都变了分析结果自然不作数。从 roots 出发遍历引用链。顺着引用关系递归地走走过的对象打上“可达”标记。遍历完还没打上标记的就是不可达、可回收的对象。二次标记。被判不可达的对象不会立刻回收中间还夹着一道 finalize() 的流程。这块是历史包袱下面单独拎出来说。finalize()该翻篇的历史一个对象被判不可达后如果它重写了 finalize() 并且从没执行过JVM 会把它丢进 F-Queue交给一条低优先级的 Finalizer 线程去跑 finalize()。跑的时候对象还有最后一次“自救”机会——比如在 finalize() 里把 this 赋给某个静态变量就重新接回了引用链这次就不回收了。只有自救失败才会真正被标记回收。这套机制现在基本可以当它不存在执行时机不确定、可能根本不执行还拖慢回收。从 Java 9 开始 finalize() 就被标成 deprecated官方三令五申让别用替代方案是 try-with-resources 或者 Cleaner。所以如果面试里还有题在问“finalize() 怎么让对象自救”答完最好补一句“新版本已经淘汰了这个机制”能听出你是跟着版本在走的。二、为什么 Java 没选引用计数法讲可达性分析绕不开要和引用计数法做个对比——毕竟后者才是最符合直觉的方案。思路很简单给每个对象挂一个计数器谁引用它就给计数加一引用失效就减一减到零说明没人用了直接回收。即时、简单、不用 STWPython 至今还在用它。但它有个治不好的病处理不了循环引用。A 引用 B、B 引用 A外部再没有别的引用了这俩本该一起被回收可它们互相把对方的计数顶在 1谁也归不了零就这么永远赖在内存里。要想补上这个窟窿就得额外做环检测或者配合别的算法复杂度一上去“实现简单”这个最大的优点也就没了。可达性分析从根上绕开了这个问题它不关心“谁引用了我”只关心“从根能不能走到我”。两个对象就算互相引用得再紧只要从 GC Roots 出发到不了它们一律算垃圾。三、JDK 21 之后GC 这两年挪到哪了“标记”这一步也就是可达性分析说实话十几年没什么大变化。但 GC 的另一半——“怎么把垃圾收得又快又省”——这两年动作特别大。既然都聊到 GC 了顺手把最近的动态捋一遍都是选型和面试容易碰到的。分代 ZGC 登场JDK 21JEP 439。ZGC 从 JDK 15 起就是正式功能卖点是亚毫秒级停顿、停顿时间几乎不随堆大小增长。但它早期不分代每次都扫全堆CPU 花在大量“注定还活着”的老对象上有点浪费。JDK 21 给它补上了分代堆分成年轻代和老年代年轻代回收得更勤绝大多数对象都是朝生夕死老年代扫得少。不过当时还得手动加-XX:ZGenerational才开。分代 ZGC 成为默认JDK 23JEP 474JDK 24 移除非分代模式JEP 490。接下来的两个版本把这个方向走到底了非分代 ZGC 被删除-XX:UseZGC开出来的就是分代。所以“用 ZGC 要记得加参数”这条老经验现在可以扔了。紧凑对象头JDK 25JEP 519。这是个不起眼但很实在的改动。堆里每个对象都带一个对象头存类指针、identity hash 之类的元数据64 位 JVM 默认开启指针压缩是 12 字节关掉压缩则是 16 字节。JEP 519 把它压到 8 字节业务代码一行不用改。对象越密集省得越多官方口径大约是堆占用降两成、GC 频率跟着降一成多。小对象特别多的应用大量短命的 DTO、事件对象收益最明显。到 JDK 27这个特性已经默认开启。分代 Shenandoah 转正JDK 25JEP 521。Shenandoah 这个低停顿收集器分代版本在 JDK 24 还是实验特性JDK 25 正式转正。它和 ZGC 定位相近但内存开销通常更省一点是低延迟场景下 ZGC 之外的另一条路。G1 减少同步JDK 26JEP 522。G1 至今仍是大多数服务端的默认收集器。JDK 26 动了它的写屏障减少 GC 线程和应用线程之间的同步竞争同样配置下吞吐能好一些。同一版本里AOT 缓存JEP 516也不再是 G1 的专利ZGC 也能用上启动更快。G1 变成所有环境的默认 GCJDK 27JEP 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 问题和参数就不再是玄学了。