Java 四种引用类型:从强引用到虚引用的完整链路
一句话结论(30s)
四种引用按回收时机递减——强引用永不回收、软引用 OOM 前回收、弱引用每次 GC 回收、虚引用仅跟踪 GC 事件,因为这是 JVM 内建的一套”内存敏感性”分级机制。关键设计是虚引用 + Cleaner 链表实现堆外内存的精确延迟释放;代价是 ThreadLocal 的 value 仍为强引用导致泄漏、Cleaner 需用静态链表保活防止被提前回收。
核心原理(2min)
DirectByteBuffer 构造时 Cleaner.create(this, Deallocator),Cleaner 继承 PhantomReference;GC 判定对象变为 phantom-reachable 后 Cleaner 入 ReferenceQueue,Reference-Handler 线程调 clean()→Deallocator.run()→Unsafe.freeMemory(address) 释放堆外内存。软引用用于缓存驱逐、弱引用用于 ThreadLocal key / WeakHashMap、虚引用 get() 恒返回 null 只做 GC 通知。
底层深入(5-10min)
强度递减
强引用 (Strong) → GC 永不回收 → 普通 new 出来的对象
软引用 (Soft) → OOM 前回收 → 内存敏感的缓存
弱引用 (Weak) → 每次 GC 回收 → ThreadLocal key、WeakHashMap
虚引用 (Phantom) → 跟踪 GC 事件 → Cleaner 释放堆外内存
软引用:JVM 内置的”缓存驱逐策略”
SoftReference<byte[]> cache = new SoftReference<>(new byte[100 * 1024 * 1024]); // 100MB
byte[] data = cache.get(); // OOM 前 JVM 自动清空 → 返回 null
JVM 在抛出 OOM 前会遍历所有软引用,清空引用的对象。Guava CacheBuilder.softValues() 就是基于软引用的内存敏感缓存——内存充足时正常服务缓存,内存不足时 JVM 自动清理软引用腾空间,应用不需手动管理驱逐。
💭 思考:软引用和「自己实现 LRU 缓存」有什么区别?软引用把「何时驱逐」交给 JVM——内存紧张时 JVM 自动清软引用,你只管 get() 判空;LRU 则是你自己定淘汰策略。软引用省心但时机不可控,LRU 可控但代码多。
弱引用:ThreadLocal 的 key
// ThreadLocalMap.Entry 继承 WeakReference<ThreadLocal<?>>
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value; // ← 强引用!泄漏的根源
}
ThreadLocal 对象被外部强引用断掉后,GC 自动回收 ThreadLocal 对象(Entry.key → null)。但 value 仍是强引用——Entry 本身仍是可达的(通过 Thread → ThreadLocalMap → Entry 链)。这就是 ThreadLocal 内存泄漏的根因。
WeakHashMap 同理:key 被回收 → 自动清除 entry。适合”key 消亡则附加数据也消亡”的映射场景。
💭 思考:ThreadLocal 为什么把 Entry 设计成 WeakReference 的 key?因为如果 key 是强引用,ThreadLocal 对象即使业务代码不再使用,也会被 ThreadLocalMap 一直抓住、无法回收。弱引用让 key 随 GC 消失,避免了 ThreadLocal 本身泄漏——但 value 仍是强引用,这是另一层坑。
虚引用 + Cleaner:堆外内存的精确释放
// DirectByteBuffer 构造时
Cleaner cleaner = Cleaner.create(this, new Deallocator(address, size, capacity));
Cleaner 继承 PhantomReference。GC 判定 DirectByteBuffer 可达性变为 phantom-reachable → Cleaner 入 ReferenceQueue → Reference-Handler 线程调 Cleaner.clean() → Deallocator.run() → Unsafe.freeMemory(address)。
GC 管理的是堆上的 DirectByteBuffer 对象(几字节),堆外内存的释放是通过虚引用链”顺带”完成的。
💭 思考:堆外内存既然不归 GC 管,DirectByteBuffer 怎么保证它被释放?关键在 Cleaner——GC 回收 DirectByteBuffer 这个堆对象时,虚引用被入队,Cleaner 的 clean() 顺带把堆外的 native 内存 free 掉。堆外内存是「搭」在堆对象生命周期上释放的。
Cleaner 内部维护一个双向链表(first 静态字段是强引用 GC Root),保证所有 Cleaner 不被 GC 提前回收——如果 Cleaner 也被 GC,堆外内存永远无法释放。
💭 思考:为什么 Cleaner 自己也要被强引用保活?想想看:如果 Cleaner 也被 GC 回收了,那 GC 回收 DirectByteBuffer 时就没有人通知「该 free 堆外内存了」,堆外内存就成了永久泄漏。所以 Cleaner 内部用静态 first 链表把每个 Cleaner 都钉成 GC Root。
引用队列说明
| 引用类型 | GC 时机 | 入 ReferenceQueue? |
|---|---|---|
| 软引用 | OOM 前 | ✅(引用被清除后入队) |
| 弱引用 | 每次 GC | ✅ |
| 虚引用 | GC 时 | ✅(必须注册队列,get() 恒返回 null) |
虚引用的 get() 永远返回 null——你不应该通过虚引用来访问对象,虚引用只是”GC 通知”的机制,不是”访问对象”的通道。
章末提问
追问 1:四种引用类型分别在什么场景用?
结论先行:软引用用于内存敏感缓存(OOM 前回收)、弱引用用于 ThreadLocal key / WeakHashMap(每次 GC 回收)、虚引用用于 Cleaner 的堆外内存释放(只做 GC 通知)。
因为:它们的回收时机递减——强引用永不回收,软引用在 OOM 前回收适合缓存驱逐,弱引用每次 GC 回收适合「key 消亡则 value 也消亡」的映射,虚引用 get() 恒返回 null、只能用来跟踪 GC 事件,正好配合 Cleaner 在对象被回收后释放堆外内存。
追问 2:ThreadLocal 内存泄漏的根因是什么?弱引用 key 为什么没解决 value 泄漏?
结论先行:根因是 ThreadLocalMap.Entry 的 key 是弱引用、value 却是强引用,key 被回收后 value 仍通过 Thread → ThreadLocalMap → Entry 链可达,无法回收。
因为:ThreadLocal 对象本身被外部断掉强引用后,弱引用 key 会被 GC 清成 null,但 value 存在 Entry 里的强引用字段中,Entry 又挂在 ThreadLocalMap 里、被线程持有,只要线程不死 value 就不释放。所以要么用完后主动 remove(),要么借助 get/set 时的惰性清理。
追问 3:DirectByteBuffer 的堆外内存是怎么被释放的?为什么 Cleaner 不能被 GC?
结论先行:DirectByteBuffer 构造时用 Cleaner.create 注册一个继承 PhantomReference 的 Cleaner,对象被 GC 后 Cleaner 入 ReferenceQueue,Reference-Handler 线程调 clean() → Unsafe.freeMemory 释放堆外内存;Cleaner 用静态 first 双向链表保活,防止自身先被回收。
因为:GC 只能管理堆上几字节的 DirectByteBuffer 对象,堆外 native 内存需要「顺带」释放;若 Cleaner 先被 GC,就没有人执行 freeMemory,堆外内存永久泄漏,所以必须用静态强引用链表把 Cleaner 钉成 GC Root。