高频八股 · JVM(8):一个对象是怎么被判"死刑"的?
一、引用计数 vs 可达性分析
1.1 引用计数
思路 :给每个对象配一个计数器,有多少引用指向我,计数就是几,归零即垃圾。
优点 :实现简单,实时性好,归零立刻能收。
致命缺点 :循环引用。
java
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 ;它只会被执行一次;执行线程优先级很低,不保证及时。
java
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 对这个字段特殊处理。
| 类型 | get() |
GC 是否认可这条边 | 回收时机 |
|---|---|---|---|
| 强引用 | 对象 | 认可 | 不可达后的下一次 GC |
| SoftReference | 对象或 null | 内存够时认可,不够时不认可 | 内存不足时的 GC |
| WeakReference | 对象或 null | 从不认可 | 下一次 GC |
| PhantomReference | 永远 null | 从不认可,回收后入队 | 下一次 GC |
四种引用的真正区别,在于 GC 认不认这条引用边。
4.1 强引用:宁可 OOM 也不收
java
Object o = new Object();
只要这条链还在,内存不够了也宁可抛 OOM 。所谓"内存泄漏",本质就是临时对象被一条强引用链悄悄拴住了 ------静态集合、缓存 Map、没 remove() 的 ThreadLocal,都是这个套路。
4.2 软引用:内存不够才收
java
SoftReference<byte[]> ref = new SoftReference<>(new byte[10 * 1024 * 1024]);
byte[] data = ref.get(); // 内存够 → 拿到;不够 → null
设计意图是做缓存。但实际项目别这么写:
- 回收策略不受你控制 。HotSpot 按"空闲堆 ×
-XX:SoftRefLRUPolicyMSPerMB"(默认 1000,即每 MB 空闲堆换 1 秒)算存活时间,一次 Full GC 后空闲堆变小,缓存会成片消失; - 你想按 LRU 淘汰热点,它按内存水位一把清空。
业务缓存老实用 Caffeine / Guava(强引用 + 容量淘汰)。
4.3 弱引用:下次 GC 必收
不管内存够不够,只要 GC 就回收。
最经典的应用是 ThreadLocal 的 Entry:
java
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 ;唯一作用是对象被回收时收到通知。
java
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 期,敬请关注~)