JVM 如何判断对象是否存活的?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 算法认知:你知道哪些判断对象存活的方法?能不能说清楚引用计数法和可达性分析各自的原理和优缺点?

  2. GC Roots 的理解:哪些东西能作为 GC Roots?这个背不下来很正常,但至少要能说出最常见的几种。

  3. 引用类型的区分:强、软、弱、虚四种引用,各自什么时候被回收?跟实际开发有什么关系?很多候选人知道 WeakReference 但说不清什么时候用。

核心答案

JVM 用的是可达性分析算法(Reachability Analysis)。从一组叫 "GC Roots" 的根对象出发,顺着引用链往下找。能找到的对象就是活的,找不到的就是死的,可以被回收。

不过在讲可达性分析之前,得先提一个已经被淘汰的方案——引用计数法。面试官经常拿它当铺垫来问。

深度解析

一、引用计数法(已被淘汰)

思路很简单:给每个对象维护一个计数器,被引用一次 +1,引用断开 -1。计数器为 0 就说明没人用了,可以回收。

引用计数法的致命缺陷就是循环引用。两个对象互相指着对方,计数器永远不为 0,但实际上外面已经没人用它们了。这块内存就泄露了。

Python 用的是引用计数法,但它额外配了一套分代 GC 来解决循环引用的问题。而 Java 从一开始就没选这条路,直接用了可达性分析。

二、可达性分析算法(JVM 实际使用的方案)

从 "GC Roots" 出发,沿着引用链往下搜。搜索走过的路径叫 "引用链"(Reference Chain)。如果一个对象到 GC Roots 之间没有任何引用链可达,说明这个对象不可达,可以被回收。

上面这幅图很直观。GC Roots 是起点,能连上的就是活的,连不上的就是死的。

但你可能会问:到底哪些对象能当 GC Roots?

三、哪些对象可以作为 GC Roots

这个问题面试官几乎必问。能当 GC Roots 的有四类:

  1. 虚拟机栈中引用的对象:各个线程方法里的局部变量。方法正在执行时,里面的局部变量指向的对象肯定不能回收。

  2. 方法区中类静态变量引用的对象static 变量引用的对象。类的生命周期没结束,它引用的对象就得留着。

  3. 方法区中常量引用的对象:比如 static final 修饰的常量引用的对象。

  4. 本地方法栈中 JNI 引用的对象native 方法里引用的对象。

还有几个在特定场景下也会被视为 GC Roots:

  • 同步锁(synchronized)持有的对象
  • JVM 内部的引用:基本数据类型的 Class 对象、常驻的异常对象(NullPointerException 等)、类加载器
  • JMXBean、JVMTI 中注册的回调、本地代码缓存

面试时把前四个说清楚就够了。后面的属于加分项,说出来面试官会觉得你研究过源码。

四、四次生死判定——真正被回收要过几关

一个对象被判定为不可达,不等于立刻就被回收。还要过两道坎:

第一关:finalize() 方法

如果对象不可达,且重写了 finalize() 方法且还没被调用过,JVM 会把它放到 F-Queue 里,稍后由一个低优先级的 Finalizer 线程去执行它的 finalize() 方法。在这个方法里,对象可以把自己重新跟引用链上的某个对象关联起来——也就是 "自救"。

public class FinalizeEscape {
    private static FinalizeEscape SAVE_ME;

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        // 自救:把自己重新挂到 GC Root 上
        SAVE_ME = this;
    }
}

但是!finalize() 方法最多只会被系统调用一次。如果对象自救过一次,下次再被判定不可达就真的死了。

说实话,finalize() 在实际开发中基本不用,而且已经被标记为 @Deprecated(forRemoval=true)。它执行时间不确定、不保证执行完毕、还可能导致对象复活。JDK 9 引入的 Cleaner 是更好的替代方案。这块知道就行,别在项目里用 finalize()

第二关:真正回收

如果对象没重写 finalize(),或者 finalize() 执行完也没自救成功,那它就真的被判定死亡了,等待 GC 回收。

五、四种引用类型

判断对象存活跟引用类型关系密切。JDK 1.2 之后把引用分成了四级:

引用类型 回收时机 用途 示例
强引用(Strong) 永远不回收,除非不可达 普通赋值 Object obj = new Object()
软引用(SoftReference) 内存不够时才回收 缓存 SoftReference<byte[]> cache
弱引用(WeakReference) 下次 GC 就回收 WeakHashMapThreadLocal WeakReference<Object> ref
虚引用(PhantomReference) 随时回收,拿不到对象 跟踪 GC、管理堆外内存 配合 ReferenceQueue 使用
// 软引用:适合做缓存
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024 * 10]);
byte[] data = cache.get(); // 内存够时能拿到,不够时返回 null

// 弱引用:ThreadLocal 里用的就是弱引用
WeakReference<Object> weakRef = new WeakReference<>(new Object());
System.gc();
weakRef.get(); // GC 之后大概率返回 null

ThreadLocal 内存泄漏这个经典问题就跟弱引用有关。ThreadLocalMap 的 key 是弱引用指向 ThreadLocal 对象,但 value 是强引用。如果 ThreadLocal 被 GC 回收了,key 变成 null,value 却还在,就泄漏了。所以用完 ThreadLocal 一定要调 .remove()

面试高频追问

可达性分析在什么阶段进行?

在 GC 的标记阶段。不同垃圾收集器的标记方式不一样:CMS 用的是增量更新(Incremental Update),G1 用的是 SATB(Snapshot At The Beginning),ZGC 用的是染色指针。不过原理都是从 GC Roots 出发做可达性分析。

方法区会被回收吗?

会,但条件苛刻。《Java 虚拟机规范》说可以不要求回收方法区。回收的内容主要是废弃的常量和不再使用的类。判断一个类是否 "不再使用" 需要同时满足三个条件:该类所有实例都被回收、加载该类的类加载器已被回收、该类对应的 java.lang.Class 对象没有在任何地方被引用。条件这么严,所以方法区的回收效率通常很低。

常见面试变体

  • "Java 用的是什么垃圾判定算法?为什么不用引用计数法?"
  • "GC Roots 包含哪些对象?"
  • "finalize() 方法有什么作用?为什么不推荐使用?"
  • "强引用、软引用、弱引用、虚引用的区别?"

总结

JVM 判断对象存活用的是可达性分析,不是引用计数。从 GC Roots 出发沿引用链搜索,不可达的对象视为可回收,但还要经过 finalize() 的 "自救" 机会(虽然基本没人用)。四种引用类型决定了不同场景下回收的积极性,其中弱引用在 ThreadLocalWeakHashMap 中的应用是面试常考点。