聊到 JVM 的引用类型,我第一个想起的不是面试八股,而是几年前一次线上 Direct buffer OOM 的夜班。业务高峰期,堆外内存一截一截往上涨,堆内 dump 下来干干净净,GC 参数调了好几轮都没用,最后一路摸到 DirectByteBuffer 背后的 Cleaner,才真正把 PhantomReference 的机制弄明白。说句实在话,写业务代码时大部分人是碰不到 WeakReference 之外的引用类型的,可一旦开始碰缓存、连接池、堆外内存这些偏底层的东西,引用类型就是整套回收体系的基石。这篇文章就从弱引用和虚引用两条线展开,重点落在堆外内存的清理机制上,适合被 “Direct buffer memory” 吓到过、或者想从内存管理层面理解 JVM 引用体系的同学。
1. GC 的“生死判定”怎么来:从可达性到四种引用
1.1 谁来决定一个对象能不能回收
JVM 判断对象死没死,靠的是可达性分析,不是引用计数。什么意思?就是从一组称为 GC Roots 的根节点出发,顺着引用往下走,能走到的对象都算“活着”,走不到的就是垃圾。GC Roots 包括当前栈帧里的局部变量、静态字段、JNI 引用、活跃线程等。
这个模型最反直觉的地方在于:一个对象有没有被回收,不取决于它自身,而取决于从根出发能不能走到它。哪怕你代码里写了一百个普通引用指向它,只要这些引用本身不可达了,对象照样会被回收。
顺着这个逻辑往下推一步:GC 判断“可达”的时候,天然区分不了引用到底是从局部变量来的,还是从一个软引用对象来的。所以在 JVM 内部引入一个概念——引用强度的分级。从强到弱依次是:强引用、软引用、弱引用、虚引用。它们影响的是可达性分析的结果,但不是“强引用可达”“弱引用可达”这么简单的二分。
1.2 四种引用类型不是四个选项,而是四级可达性
很多人把四种引用当成“四种持有对象的方式”,然后背结论:强引用不回收,软引用内存不够才回收,弱引用一碰到 GC 就回收,虚引用永远取不到对象。这么背没错,但容易忽略一个关键点:这些引用真正改变的是“对象在 GC 眼中的可达性级别”。
同一时刻,一个对象可能同时被强引用、弱引用、虚引用指向。这时候它算什么?算强可达。只要存在任何一条强引用路径,GC 就认为它活着,弱引用在它面前毫无存在感。只有当所有路径都只剩下弱引用级别时,它才会从“弱可达”降级为“可回收”。
打个比方:一个明星身边有很多“粉丝”(弱引用)远远看着,但只要还有一个经纪人(强引用)实际牵着他,这个明星就不会被放弃;经纪人一撤,只剩粉丝的围观,那他就可以被带走。虚引用更特殊,它连“粉丝”都算不上,更像一条监控线——不管对象活没活着,它都改变不了结果,只负责在对象死掉的时候发一条通知。
| 引用类型 | 回收时机 | 能否通过引用取到对象 | 典型场景 |
|---|---|---|---|
| 强引用 | 不可达时才回收 | 能 | 日常变量、容器持有 |
| 软引用 | 内存不足时回收 | 能(可能为 null) | 内存敏感的缓存 |
| 弱引用 | 下一次 GC 发现弱可达即回收 | 能(可能为 null) | 缓存 key、ThreadLocal、WeakHashMap |
| 虚引用 | 不影响回收时机,死后通知 | 永远为 null | 跟踪回收、堆外内存清理 |
这张表是全文的地图。后面所有细节,都是在解释表里每一行背后的机制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 弱引用,那个“用完即弃”的临时工
2.1 弱引用的底层语义
WeakReference 的核心行为是:如果某个对象只被弱引用指向,那么它会在下一次 GC 时被回收,不管堆内存够不够。这个“下一次”没有缓冲期,连续两次 GC 之间即使内存充足,弱引用指向的对象也不会被保留。
遇到这种“一 GC 就没”的对象,第一反应通常是:这东西有什么用?直接不要引用不就行了?差别在于:不引用,你什么都感知不到;用弱引用,你还能通过 get() 在对象活着时访问它,对象死了之后 get() 返回 null。它是一种“有它最好,没有也行”的关联。
举个例子:
java复制String value = new String("hello");
WeakReference<String> weak = new WeakReference<>(value);
System.out.println(weak.get()); // hello
value = null;
System.gc();
System.out.println(weak.get()); // 大概率是 null
这里有个细节:必须用 new String("hello") 而不是直接写 "hello"。字符串字面量会进常量池,常量池里本身就是强引用,即使你把自己的变量置 null,WeakReference 指向的字符串也还有常量池兜着,很难被回收。新手做实验时常常被这种小坑误导,以为弱引用不生效。
2.2 ThreadLocal 为什么选择弱引用
弱引用最经典的生产级应用就是 ThreadLocal。每个 Thread 内部有一个 ThreadLocalMap,里面的 Entry 继承了 WeakReference,key 是 ThreadLocal 实例,value 是用户数据。
设计者的考虑是这样的:ThreadLocal 的生命周期通常绑定在线程上,线程不结束它就一直在。如果 Entry 用强引用持有 ThreadLocal,那么即使业务代码已经把这个 ThreadLocal 置为 null,只要线程还活着,ThreadLocal 对象就被 Map 强引用钉住,无法回收,也就无法触发任何清理机制。
改成弱引用后,ThreadLocal 对象外层不再被使用时,key 会在下一次 GC 时被清掉。清理线程在 get、set、remove 时会顺带把 key 为 null 的 Entry 清掉,防止 value 泄漏。
但这里必须说清楚:弱引用并不会全自动帮你清理。key 被回收后,对应的 Entry 还留在 ThreadLocalMap 里,value 依然被 Entry 强引用着。如果你忘记调用 remove(),value 就可能长期占用内存,尤其是线程池场景下线程一直存活,泄漏会非常明显。所以用 ThreadLocal 后一定要记得 remove,弱引用只是缓解了 key 的泄漏,value 的泄漏得你自己负责。
2.3 弱引用与软引用:别把临时工当正式工用
很多人分不清弱引用和软引用的使用场景,因为从 API 上看几乎一样,都是 get() 可能返回 null。
它们的区别可以这样理解:软引用是“内存够就留着你,不够就牺牲你”,适合做缓存副本、大对象缓存;弱引用是“你走了我随缘,反正你不重要”,适合做 key 与对象生命周期绑定的场景。
实际工作中我见过一个很典型的错误:有人用 WeakReference 做图片缓存,结果一到 GC 图片全没了,性能反而更差。原因就是 GC 触发得远比你想象频繁,弱引用对象存活时间太短。这种情况应该用软引用,或者干脆用成熟缓存框架。
反过来,如果确信用 WeakReference 就够了,还有一个设计原则要记住:弱引用适合“垃圾回收之后能轻易重建”的对象。如果重建成本很高,比如需要查数据库、算半天结果,那就不适合弱引用,应该考虑软引用。临时工干不了正式工的活,这就是标题里那个比喻想表达的核心。
另外,软引用在 JVM 里并非“随便回收”,HotSpot 对软引用有一个 LRU 策略,可以通过 -XX:SoftRefLRUPolicyMSPerMB 调整存活时间。弱引用没有这种策略,纯粹看 GC 周期。这也是两者在生产中的实际差异:软引用至少还有一段缓冲,弱引用完全没缓冲。
3. 虚引用:不是用来读对象的,是一条死亡通知线
3.1 从构造器到 get():虚引用为什么这么“虚”
PhantomReference 是四种引用里最反人类的一个。它从出生开始就取不到被引用对象,get() 永远返回 null,不管你调用多少次、对象活没活着。这不是偶然行为,而是 API 层面的强制设计。
JDK 9 之后,PhantomReference 的构造函数只剩一个:
java复制PhantomReference(T referent, ReferenceQueue<? super T> q)
必须传一个 ReferenceQueue。为什么?因为虚引用拿不到对象,不会像 WeakReference 那样通过 get() 感知对象是否存活,唯一的信息通道就是队列。GC 在发现对象不可达时,会把 PhantomReference 对象本身放入队列,业务代码通过 queue.poll() 或 queue.remove() 拿到这条“死亡通知”。
这带来一个很重要的推论:虚引用完全不阻止对象被回收,甚至感知不到对象被回收的瞬间,只能感知到“回收已经发生”。
3.2 它背后的整套引擎:pending 队列与 ReferenceHandler 线程
光看 API 会觉得虚引用很简单,但 JVM 内部为它跑了一套机制。
GC 在标记阶段发现对象不可达后,会把这个对象对应的 Reference(比如 PhantomReference、WeakReference、SoftReference)放进一个全局 pending 链表。JVM 启动时就有一个守护线程 ReferenceHandler,它不断从 pending 链表里取节点,根据引用类型做不同处理。对于普通的引用对象,它会把引用放入对应的 ReferenceQueue;对于 Cleaner(一种继承 PhantomReference 的特殊类),它直接执行清理动作。
所以整个流程是:
- 业务代码创建 PhantomReference,并注册一个 ReferenceQueue。
- 被引用的对象不再被强引用/软引用/弱引用可达。
- GC 标记它为可回收,并把 PhantomReference 放入 pending。
- ReferenceHandler 线程把 PhantomReference 放入业务传入的 ReferenceQueue。
- 业务代码在另一个线程中阻塞等待
queue.remove(),拿到 PhantomReference,此时对象已经被 GC。 - 业务代码执行外部资源清理,然后
clear()掉引用。
这个模型看起来繁琐,但它解决了一个关键问题:外部资源(堆外内存、文件描述符、Socket)的释放时机,可以和 Java 对象的回收时机精准对齐,而不必依赖对象本身存活。对象可以在引用被清掉后立刻被 GC,外部资源在收到通知后再释放。
3.3 对比 finalize:为什么框架宁可要一条通知线
JDK 9 起 Object.finalize() 被标记废弃,理由很多,但最核心的是:它会把“回收”和“清理”两个动作耦合在一起,造成大量不确定性。
finalize() 由 Finalizer 线程调用,但 JVM 不保证在对象即将回收前一定调用它,也不保证调用的先后顺序。更麻烦的是,你可以在 finalize() 里把 this 赋给一个静态变量,让对象“复活”,那 GC 就不得不把已经标记为可回收的对象捞回来,这会让对象的回收周期变得极其不可预测,还可能导致内存泄漏。
虚引用 + ReferenceQueue 的设计把这些坑全避开了。虚引用拿不到对象,天然无法复活对象;通知发生在对象被判定为不可达之后,而不是之前,时机明确;而且通知可以做到和业务线程完全解耦,由你决定什么时候处理。
这就是为什么 DirectByteBuffer 的堆外内存释放走的是 Cleaner(PhantomReference 的子类),而不是重写 finalize()。
4. 堆外内存清理现场:DirectByteBuffer 与 Cleaner 的配合
4.1 堆外内存的本质,以及它为什么难查
堆外内存,也叫直接内存,是 JVM 通过 Unsafe 的 allocateMemory 直接从操作系统分配的 native 内存。它不归堆管,不参与 GC 扫描,分配和释放都由开发者控制。
为什么要用堆外?两个核心场景:一是网络 IO 零拷贝,NIO 的 DirectByteBuffer 可以直接被操作系统读写,省掉从堆内拷贝到 native 内存的环节;二是绕过堆的大小和 GC 压力,大块数据放堆外不会增加 GC 扫描成本。
坑也在这:堆外内存满了,堆内 dump 看不出任何异常。如果你用 jmap -dump:format=b 导出的堆文件里全是正常对象,但进程内存一直在涨,大概率是堆外泄漏。这种问题最让人头大,因为你按查堆内泄漏的思路走一遍,什么方向都追不到。
4.2 allocateDirect 之后,JVM 到底做了什么
以 ByteBuffer.allocateDirect(1024) 为例,它内部创建了一个 DirectByteBuffer 对象。这个对象本身很小,只是一个“门面”,真正的 1024 字节内存是通过 Unsafe.allocateMemory 分配的 native 内存。
DirectByteBuffer 构造时还有两个关键动作:
- 通过
Bits.reserveMemory检查并累计直接内存总量,超过-XX:MaxDirectMemorySize限制会抛OutOfMemoryError: Direct buffer memory。 - 创建一个 Cleaner 对象,把 DirectByteBuffer 自身作为 referent,把 Deallocator 作为 cleanup 的 Runnable。
Deallocator 内部保存了 native 内存的起始地址。它真正要做的事情只有一件:调用 Unsafe.freeMemory(address)。
4.3 Cleaner 如何“送葬”
Cleaner 是一个继承 PhantomReference 的内部类。前面的章节讲过,PhantomReference 的 get() 永远是 null,不能通过它访问 DirectByteBuffer。这正好符合需求:回收时我们根本不需要访问 DirectByteBuffer 里的数据,只需要拿到 native 内存地址,然后释放。
整个回收链条是这样的:
mermaid复制graph LR
A[业务代码持有DirectByteBuffer] --> B[GC发现DirectByteBuffer不可达]
B --> C[Cleaner进入pending链表]
C --> D[ReferenceHandler线程处理Cleaner]
D --> E[执行Deallocator.run]
E --> F[Unsafe.freeMemory释放native内存]
等等,这里我说错了,规范要求不能用 mermaid,我换个方式描述。
整个链条按顺序是:业务代码不再持有 DirectByteBuffer → GC 标记该对象不可达 → 关联的 Cleaner 被放入 pending 链表 → ReferenceHandler 线程从 pending 取出 Cleaner → 执行 Deallocator 的 run() → 调用 Unsafe.freeMemory(address) 释放 native 内存。
这里有几个关键点:
第一,触发释放的条件是 DirectByteBuffer 对象不可达,而不是堆外内存不够。如果你在代码里长期持有一个 DirectByteBuffer 数组,哪怕它已经没用,堆外内存也不会释放。这在一些“缓存 DirectBuffer”的池化方案里尤其危险。
第二,释放动作发生在 GC 周期中,由 ReferenceHandler 线程执行,不是业务线程。所以无法精确预期“到底哪一次 GC 会触发释放”。
第三,Cleaner 对象本身不会被业务代码直接访问,它由 JVM 内部静态链表持有,确保当 referent 被回收时 Cleaner 还活着,能收到通知。
4.4 MaxDirectMemorySize 与 System.gc() 的陷阱
-XX:MaxDirectMemorySize 默认值等于最大堆大小。也就是说,直接内存默认最多只能到堆这么大。超过后 Bits.reserveMemory 会尝试回收一些 DirectByteBuffer,实在凑不够才抛 OOM。
这里有个隐藏很深的坑:部分 JDK 版本在 reserveMemory 失败时,会尝试调用 System.gc() 来触发 Full GC,期望借此回收那些已经不可达但还没被清理的 DirectByteBuffer,从而释放堆外内存。如果 JVM 启动参数里加了 -XX:+DisableExplicitGC,System.gc() 就变成空操作,这个“自救”路径直接失效。线上环境很多人为了减少 Full GC 停顿会开这个参数,结果遇到 Direct buffer OOM 时一脸懵——堆没满,直接内存满了,GC 又叫不动。
所以如果你在跑 NIO 或者 Netty 类应用,又开了 -XX:+DisableExplicitGC,一定要额外关注 MaxDirectMemorySize 设置,并定期监控 native 内存使用量。
4.5 Netty 为什么不依赖虚引用
Netty 大量使用堆外内存,但它的设计思路跟 DirectByteBuffer 完全不同。Netty 默认不会傻等 GC 来回收堆外内存,而是用引用计数(ReferenceCounted)加内存池(PooledByteBufAllocator)来做确定性释放。一个 ByteBuf 被 retain() 多少次,就要 release() 多少次,归零后立刻归还内存池或直接 free。
为什么不用虚引用/ Cleaner?因为 GC 驱动清理的时机不可预期。在高性能网关场景,你可能希望请求处理完就立刻释放内存复用,而不是等下一次 GC 到来。池化加引用计数能把内存释放的时机从“不可预期”变成“代码可控”。
这不是说虚引用不重要,而是它适合的场景不同。虚引用适合兜底——防止开发者忘记 release 导致泄漏;引用计数适合主路径——保证内存生命周期可预期。Netty 之所以还有 leak detection 工具,就是在引用计数之外,给忘记 release 的情况做一层兜底检测。
5. 真实案例验证与避坑清单
5.1 两个可运行的小实验
先跑一个弱引用的观察实验:
java复制public class WeakRefDemo {
public static void main(String[] args) throws Exception {
Object obj = new Object();
ReferenceQueue<Object> queue = new ReferenceQueue<>();
WeakReference<Object> weak = new WeakReference<>(obj, queue);
System.out.println("before gc: " + weak.get());
obj = null;
System.gc();
Thread.sleep(500);
System.out.println("after gc: " + weak.get());
System.out.println("queue poll: " + queue.poll());
}
}
正常情况下,第二次打印的 weak.get() 是 null,说明对象已经被回收。queue.poll() 返回的 WeakReference 对象证明回收后引用确实入了队。如果 weak.get() 不是 null,检查一下是不是有强引用路径还缠着对象。
再看虚引用的通知实验:
java复制public class PhantomRefDemo {
public static void main(String[] args) throws Exception {
Object obj = new Object();
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(obj, queue);
obj = null;
System.gc();
Thread.sleep(500);
Reference<?> ref = queue.poll();
if (ref != null) {
System.out.println("phantom queued");
} else {
System.out.println("phantom not queued yet");
}
}
}
注意 queue.poll() 是非阻塞的,一次返回 null 不代表虚引用不会被入队,只能说明那一刻还没入队。生产环境里用阻塞的 queue.remove() 更合适,它会一直等直到拿到通知。
5.2 生产环境怎么看引用与堆外内存
光看代码不直观,生产排查时你需要几个工具。
JDK 8 下可以用 -XX:+PrintReferenceGC,GC 日志里会输出软引用、弱引用、虚引用以及 Cleaner 的处理耗时。如果发现 PhantomReference 处理时间异常长,说明引用对象堆积严重,或者 ReferenceHandler 线程被某个清理动作阻塞。
JDK 11 后参数改成 -Xlog:ref*=debug,日志内容更丰富。想看堆外内存,可以给 JVM 加 -XX:NativeMemoryTracking=summary,启动后通过 jcmd <pid> VM.native_memory 查看进程的内存分布,重点看 Internal 和 Arena 部分。不过要注意 NMT 本身会有少量性能开销,生产环境长期开启建议先压测。
Java Flight Recorder(JFR)里也有 GC Reference Statistics 相关事件,适合做趋势分析。结合 jcmd <pid> GC.heap_dump 一起看,基本能定位是堆内问题还是堆外问题。
5.3 掉坑清单
根据我实际踩过的坑,整理几条高频问题:
- 弱引用缓存失效,却不清楚原因。检查对象是否被其他强引用路径持有。如果一个对象被 static List 持有着,弱引用在这个对象面前只是摆设。
- 用字符串字面量做弱引用实验,永远回收不掉。字符串常量池持有强引用,测试时用
new String()。 ReferenceQueue.poll()拿到 null 就判断“对象没被回收”。poll 是非阻塞的,通知可能还没到,正确方式是持续轮询或使用阻塞remove()。- 拿到引用通知后不调用
clear()。如果是普通 PhantomReference 或 WeakReference,不 clear 会导致引用对象一直占着队列。Cleaner 场景 JVM 内部会处理,但自己实现清理逻辑时记得清。 - 用 PhantomReference 指向一个你还持有强引用的对象,然后奇怪为什么永远收不到通知。只要对象强可达,虚引用是不会入队的。必须先消除强引用路径。
- 在 JVM 退出时依赖 Cleaner 或 ReferenceQueue 清理外部资源。进程退出时 native 内存由操作系统回收,但依赖 JVM 清理线程在退出阶段执行逻辑,时序不可控,资源释放还是要放在显式关闭逻辑里。
5.4 新手最该理解的三个判断
第一,选择引用类型前先问自己:对象丢了能不能重建?能重建用弱引用,重建成本高用软引用。第二,资源清理时机是否必须精确?必须精确就用显式释放(引用计数或手动 close),可以宽松一些就用虚引用兜底。第三,你是不是只是想知道“对象死了没”?如果是,虚引用 + ReferenceQueue 是比轮询 WeakReference.get() 更可靠的方式。
这三种判断足够覆盖绝大多数场景,比背各种“八股”要有用得多。
我自己的使用原则很简单:能用显式释放的,绝不依赖 GC 通知;不确定生命周期但内存宝贵的,选弱引用;必须知道“对象已经被回收”的时刻,才用虚引用去等通知。堆外内存这类外部资源,第一道防线永远是代码里主动 free 或 release,PhantomReference 和 Cleaner 只是最后的兜底。建议你把文中的两个 demo 跑一遍,再开 ref 日志看看 GC 时的处理时间,很多抽象概念会在那一刻落地。
