虚引用是唯一能准确表达“对象回收已完成”的机制,它不持有对象、不阻止回收,仅在对象被彻底回收后将自身加入ReferenceQueue,通知用户手动清理堆外资源。

虚引用本身不持有对象,也不能阻止回收,它的作用纯粹是“收尸通知”——当堆外内存对应的 Java 对象(如 DirectByteBuffer)被彻底回收后,JVM 会把绑定的 PhantomReference 实例加入 ReferenceQueue,你的代码从中取到它,就代表可以安全清理底层资源了。
为什么堆外内存必须靠虚引用+队列来清理
堆外内存(比如 NIO 的 DirectByteBuffer 或 Netty 的 PooledByteBuf)不占堆空间,GC 不管它。但它的 Java 对象(如 ByteBuffer 实例)在堆上,一旦被回收,背后那块堆外内存就成“孤儿”,容易泄漏。虚引用配合队列,就是让 JVM 在对象销毁完成那一刻,给你一个明确信号,让你手动调用释放逻辑(如 Unsafe.freeMemory 或 Cleaner.clean)。
- Finalize 已废弃:不可靠、不及时、影响 GC 性能,不能用于关键资源清理
- 软/弱引用不合适:它们关注的是“能否取回对象”,而堆外清理需要的是“对象已确定消失”这个事实
- 虚引用是唯一能准确表达“回收已完成”的机制:入队即代表 referent 内存已释放、finalize 已执行完毕(如有)、对象状态不可再访问
典型配合步骤(以 DirectByteBuffer 为例)
Java NIO 底层正是这样做的:Cleaner 继承自 PhantomReference,并自带静态队列和守护线程。你也可以自己模拟这套逻辑:
- 创建 ReferenceQueue> queue = new ReferenceQueue();
- 构造 PhantomReference
ref = new PhantomReference(buffer, queue); - 启动一个非 daemon 线程(或复用业务线程),持续调用 queue.poll() 或 queue.remove() 获取已入队的虚引用
- 拿到虚引用后,立即执行清理动作:调用 buffer.cleaner().clean(),或直接调用 Unsafe.getUnsafe().freeMemory(address)
- 注意:不要在处理逻辑中再尝试 get() —— 它永远返回 null;也不要试图保存该虚引用或关联原对象,否则可能干扰 GC
Netty 中的实际体现
Netty 的 PooledByteBuf 使用内存池管理堆外内存,其释放不依赖单个对象生命周期,而是通过池子统一回收。但在 Unpooled 模式或某些自定义 Buffer 场景下,它仍会内部使用 Cleaner(也就是 PhantomReference + Queue)来兜底:当用户忘记 release,且 ByteBuf 对象被 GC 后,Cleaner 就会触发 unsafe.freeMemory。
- Cleaner 是 PhantomReference 的子类,构造时强制传入 ReferenceQueue,并注册到 Cleaner 的静态链表中
- JVM 启动一个低优先级的 FinalizerThread(或专门的 CleanerThread),轮询队列并调用每个 Cleaner 的 clean() 方法
- clean() 方法里封装了真正的释放逻辑,比如释放 native memory、关闭 fd、注销回调等
- 这种设计把资源释放从“对象语义”解耦为“事件语义”,更可控、可测试、可监控
使用时的关键细节
虚引用不是“自动清理工具”,它只提供通知能力,真正清理要你写代码。几个容易踩的坑:
- 虚引用变量本身要避免长期持有:如果在静态 Map 里存着 PhantomReference,它自己不会被回收,队列也一直无法清空,造成间接内存泄漏
- 队列消费要及时:不能只 create 不 poll,否则队列堆积,通知延迟甚至丢失(remove() 是阻塞的,poll() 是非阻塞的,按需选)
- 不要在 clean 逻辑里抛异常:异常会中断队列处理线程,后续虚引用可能再也得不到处理
- 多线程安全:ReferenceQueue 本身是线程安全的,但你自己的清理逻辑(比如修改共享计数器)需要自行同步










