Skip to content
Go back

Java四种引用类型——强软弱虚与Cleaner的堆外内存释放链

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。


Share this post on:

Previous Post
Java异常表的零开销机制
Next Post
IntegerCache——为什么Integer 127 == Integer 127是true而128不是?