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+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
算法认知:你知道哪些判断对象存活的方法?能不能说清楚引用计数法和可达性分析各自的原理和优缺点?
-
GC Roots 的理解:哪些东西能作为 GC Roots?这个背不下来很正常,但至少要能说出最常见的几种。
-
引用类型的区分:强、软、弱、虚四种引用,各自什么时候被回收?跟实际开发有什么关系?很多候选人知道
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 的有四类:
-
虚拟机栈中引用的对象:各个线程方法里的局部变量。方法正在执行时,里面的局部变量指向的对象肯定不能回收。
-
方法区中类静态变量引用的对象:
static变量引用的对象。类的生命周期没结束,它引用的对象就得留着。 -
方法区中常量引用的对象:比如
static final修饰的常量引用的对象。 -
本地方法栈中 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 就回收 | WeakHashMap、ThreadLocal |
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() 的 "自救" 机会(虽然基本没人用)。四种引用类型决定了不同场景下回收的积极性,其中弱引用在 ThreadLocal、WeakHashMap 中的应用是面试常考点。
