高频八股 · JVM(8):一个对象是怎么被判"死刑"的?
一、引用计数 vs 可达性分析
1.1 引用计数
思路:给每个对象配一个计数器,有多少引用指向我,计数就是几,归零即垃圾。
优点:实现简单,实时性好,归零立刻能收。
致命缺点:循环引用。
Node a = new Node();
Node b = new Node();
a.next = b; // b 的计数 = 1
b.next = a; // a 的计数 = 1
a = null;
b = null; // 两个对象计数都还是 1,永远收不掉
外面已经访问不到它们了,但计数不归零,永远泄漏。所以 HotSpot 没用它——用引用计数的是 Python、ObjC(ARC 是编译器插桩,不是运行时维护)。
1.2 可达性分析
思路:从一组叫 GC Roots 的根对象出发,顺着引用链遍历。能走到的就是活的,走不到的就是垃圾。
GC Roots ──→ A ──→ B ──→ C 活着
└─→ D
E ──→ F 谁都走不到 → 垃圾
天然免疫循环引用——判断依据是"从根能不能走到",不是"有几个人指着我"。
| 依据 | 有多少引用指向我 | 从根能否走到我 |
| 循环引用 | 无法回收 | 不受影响 |
| 时机 | 实时 | 需要 GC 周期 |
| 谁在用 | Python、ObjC | HotSpot |
1.3 为什么必须 STW
遍历引用图时,如果用户线程还在改引用,就可能把刚挂到根上的对象误判为死,直接误杀。所以遍历期间必须冻结所有用户线程,这个暂停就是 STW。后面所有回收器的演进,本质上都在压短这段时间。
二、GC Roots 有哪几类
根的本质不是"重要",而是"它的存活不由 GC 决定"——它在栈上或类级别,GC 管不着,只能当起点。
| 虚拟机栈 | 正在执行的方法的局部变量、参数 | 方法是否返回 |
| 静态变量 | static 字段指向的对象 | 类是否卸载 |
| 常量 | static final、字符串常量池的引用 | 类是否卸载 |
| JNI 引用 | native 方法持有的 Java 对象 | native 调用是否结束 |
补充清单(《深入理解 Java 虚拟机》里的,答题时能加分):系统类加载器加载的 Class 对象、常驻异常对象(如 NPE、OOM 预创建)、被同步锁持有的对象、JMXBean / JVMTI 回调。
实用判断法:问一句"是谁在引用它"——引用者是栈上的变量,它就是根;是堆里的对象,就继续往上追。
三、不可达 ≠ 立刻死:两次标记
对象被判不可达后,还要过一次审判:
不可达 → 第一次标记
↓
有 finalize() 且从未执行过?
├─ 否 → 直接回收
└─ 是 → 进 F-Queue,执行 finalize()
├─ 重新连上引用链 → 逃脱
└─ 没连上 → 第二次标记 → 回收
三个要点:只有重写了 finalize() 且没执行过的对象才有资格进 F-Queue;它只会被执行一次;执行线程优先级很低,不保证及时。
public class SaveMe {
static SaveMe SAVE_ME;
@Override
protected void finalize() {
SAVE_ME = this; // 重新挂回引用链
}
public static void main(String[] args) throws Exception {
SAVE_ME = new SaveMe();
SAVE_ME = null;
System.gc(); Thread.sleep(500);
System.out.println(SAVE_ME == null ? "死了" : "活了"); // 活了
SAVE_ME = null;
System.gc(); Thread.sleep(500);
System.out.println(SAVE_ME == null ? "死了" : "活了"); // 死了
}
}
第一次活,第二次死——因为 finalize() 只执行一次,第二次直接跳过 F-Queue。
3.1 finalize 为什么被废弃
三个理由:执行时机不确定、单线程执行拖慢 GC、异常被吞掉。
- JDK 9 标记 @Deprecated;JDK 18 标记为 deprecated for removal(JEP 421)。
- 替代方案:关资源用 try-with-resources,回收时清理本地资源用 java.lang.ref.Cleaner——它本质就是"虚引用 + 引用队列"的封装。
四、四种引用:GC 认不认这条边
引用就是内存里存放对象地址的那几个字节,出现在栈帧槽位或对象字段里。软、弱、虚引用则是堆里的包装对象(Reference 子类),内部 referent 字段指向目标,JVM 对这个字段特殊处理。
| 强引用 | 对象 | 认可 | 不可达后的下一次 GC |
| SoftReference | 对象或 null | 内存够时认可,不够时不认可 | 内存不足时的 GC |
| WeakReference | 对象或 null | 从不认可 | 下一次 GC |
| PhantomReference | 永远 null | 从不认可,回收后入队 | 下一次 GC |
四种引用的真正区别,在于 GC 认不认这条引用边。
4.1 强引用:宁可 OOM 也不收
Object o = new Object();
只要这条链还在,内存不够了也宁可抛 OOM。所谓"内存泄漏",本质就是临时对象被一条强引用链悄悄拴住了——静态集合、缓存 Map、没 remove() 的 ThreadLocal,都是这个套路。
4.2 软引用:内存不够才收
SoftReference<byte[]> ref = new SoftReference<>(new byte[10 * 1024 * 1024]);
byte[] data = ref.get(); // 内存够 → 拿到;不够 → null
设计意图是做缓存。但实际项目别这么写:
业务缓存老实用 Caffeine / Guava(强引用 + 容量淘汰)。
4.3 弱引用:下次 GC 必收
不管内存够不够,只要 GC 就回收。
最经典的应用是 ThreadLocal 的 Entry:
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 弱引用
value = v; // value 强引用
}
}
为什么用弱引用:ThreadLocal 对象通常被线程的 ThreadLocalMap 持有,如果 key 是强引用,线程不死它就永远不回收。
但弱引用带来新问题:key 被回收后变成 null,value 还是强引用,这条 Entry 变成 key=null 的脏 Entry,访问不到却占着内存。
完整因果链:key 弱引用被回收 → 留下 null key 的 Entry → value 强引用无法释放 → 泄漏。
所以 ThreadLocal 用完必须 remove();源码里 set/get/remove 会顺手清一部分,但不保证清干净。
其他用法:WeakHashMap、Guava 的 weakKeys()、各种监听器注册表。
4.4 虚引用:只为"收尸通知"
三个特点:get() 永远返回 null;必须配合 ReferenceQueue;唯一作用是对象被回收时收到通知。
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> ref = new PhantomReference<>(new Object(), queue);
System.gc();
queue.remove(1000); // 收到通知
ref.get(); // 永远 null
典型场景:DirectByteBuffer 回收堆外内存。
allocateDirect() 的内存不在堆里,GC 管不着。JDK 的做法是挂一个 Cleaner(PhantomReference 的子类),等 DirectByteBuffer 对象被回收时虚引用入队,Cleaner 线程收到通知,调用 Unsafe.freeMemory() 把堆外内存还回去。
这也解释了"堆内存很健康,但进程 RSS 一直涨"——堆外内存的释放依赖 GC 触发,对象不收,堆外就不还。
五、两个容易答错的细节
1. "不可达"和"被回收"不是同一时刻。 判断不可达只在 GC 跑可达性分析时才做,所以从"没人引用"到"真正回收"中间至少隔着一次 GC;软/弱/虚引用全都是"下次 GC 才回收"。
2. 引用队列是"被动通知",get() 是"主动查询"。 对象被回收时,JVM 把 Reference 对象本身塞进队列。虚引用 get() 永远 null,只能靠队列;WeakHashMap 也是靠队列知道"哪条 Entry 该删了"。
所以"弱引用会自动清理"这话不准确——弱引用只负责让 key 变 null,清理动作是 WeakHashMap 自己做的。这是个很好的加分点。
六、高频面试题速答
Q1:怎么判断对象是垃圾?
引用计数(归零即垃圾)和可达性分析(从 GC Roots 走不到即垃圾)。HotSpot 用后者,因为前者解决不了循环引用。
Q2:GC Roots 有哪几类?
虚拟机栈中的局部变量和参数、静态变量、常量、JNI 引用。扩展还有系统类加载器的 Class 对象、常驻异常对象、同步锁持有对象等。
Q3:可达性分析为什么要 STW?
遍历期间用户线程改引用,可能把活对象误判为死对象而误杀,所以必须冻结引用图。
Q4:不可达一定被回收吗?
不一定。重写了 finalize() 且没执行过的会进 F-Queue,能重新连上引用链就活下来;否则直接回收。
Q5:finalize 能救几次?
一次。它只会被执行一次,第二次不可达就直接回收。
Q6:四种引用区别?
强引用永不回收(宁可 OOM);软引用内存不足时回收;弱引用下次 GC 必回收;虚引用只用于回收通知,get() 永远 null。
Q7:ThreadLocal 为什么内存泄漏?
Entry 的 key 是弱引用、value 是强引用。key 被回收后变 null,value 仍被强引用,形成访问不到也释放不了的脏 Entry。用完要 remove()。
Q8:虚引用能做什么?
只做"回收通知"。典型是 DirectByteBuffer 通过 Cleaner 释放堆外内存。
七、一句话总结
HotSpot 用可达性分析判死,从 GC Roots 走不到就是垃圾;但"不可达"只是"可以死了",还要过 finalize 这一关,而且只有一次机会。四种引用的区别,本质是 GC 认不认这条引用边——强引用永不放手,软引用看内存脸色,弱引用下次 GC 就走,虚引用只负责发讣告。
下一期讲回收的"手法":三种回收算法怎么取舍、8:1:1 到底是怎么来的、跨代引用怎么用卡表 + 写屏障解决,顺带把三色标记的两个经典问题讲清楚。
(本系列持续更新中,共 10 期,敬请关注~)
网硕互联帮助中心






评论前必须登录!
注册