1. 从一次诡异的OOM说起:为什么必须搞懂四种引用类型
大概半年前,我们团队的一个后台服务在压测时频繁抛OutOfMemoryError,堆Dump拉下来一看,密密麻麻全是某个自定义对象的实例。当时第一反应是哪里创建了海量对象没释放,但沿着调用链查了半天,所有业务代码都在合理范围内,对象数量就是降不下来。
后来用了MAT在Dump文件里找GC Roots,才看到一个藏在缓存组件里的静态Map——它把一个对象的引用放进了value,而这个Map本身被静态变量持有,于是所有放入的对象都成了永久存活状态。清理逻辑明明写着"缓存过期后移除",但因为粗心没有清掉对应的键值对,导致一批本应该被回收的对象一直被强引用拽住。
那次排错让我彻底明白一件事:很多Java开发说不出"引用类型分四种"到底意味着什么,认为这只是面试八股。但实际上,它是你在设计缓存、处理线程局部变量、做堆外资源管理时绕不开的底层机制。Java在JDK 1.2之后把引用分成了强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)四类,它们决定了GC在什么条件下回收一个对象,也决定了你的程序在内存压力下会表现出什么行为。
这篇文章不是我复述API文档,而是结合那几次实战排错和日常编码经验,把这四种引用的原理、使用场景、坑点一次说透。适合正在做Java后端、Android开发,或者准备深入理解JVM运行机制的读者。看完之后,你至少能回答这几个问题:什么时候选择软引用而不是弱引用?虚引用到底有什么用?为什么ThreadLocal的Key要设置成弱引用?这些都能对应到具体代码决策上。
2. 四种引用的划分依据:GC的可达性判定逻辑
先看本质。JVM判断对象是否应该回收,核心是"从GC Roots出发是否可达"——如果一条引用链能从GC Roots走到某个对象,这个对象就活着;链条断了,它就变成可回收状态。四种引用类型本质上是对"可达性"路径强度分级,调整GC决定回收时的优先级。
按照Java的官方定义:
- 强引用:最常见的
Object obj = new Object()。只要强引用还存在,GC就永远不会回收该对象。即使内存不足,JVM宁可抛出OutOfMemoryError也不动它。 - 软引用:用
SoftReference包装。在系统内存充足时,软引用对象不会被回收;但内存不足、即将发生OutOfMemoryError之前,GC会把只被软引用指向的对象回收掉。 - 弱引用:用
WeakReference包装。只被弱引用指向的对象,生命周期只存活到下一次GC之前。只要GC发生,无论内存是否充足,这些对象都会被回收。 - 虚引用:用
PhantomReference包装。它不能通过get()取得被引用对象(返回永远是null),唯一的作用是在对象被回收后收到一个系统通知,一般配合ReferenceQueue使用。
这个分级可以类比成公司里的离职交接:
- 强引用是正式员工,公司没有裁员不会让他走,就算业务萎缩也只能降到最低绩效;
- 软引用是外包员工,公司资金紧张时优先砍外包;
- 弱引用是临时工,每次绩效评估都可能会被清退;
- 虚引用是离职前的手续审批流程,人已经确定要走了,你只是要通过流程听到一个"他已经走了"的通知。
这张表可以帮你快速建立全局印象:
| 引用类型 | 类名 | 回收时机 | 能否通过get()拿到对象 | 典型使用场景 |
|---|---|---|---|---|
| 强引用 | 无专用类 | 永不回收(不考虑人为置null) | 能 | 普通局部变量、成员变量、静态变量 |
| 软引用 | SoftReference | 内存不足时回收 | 能(回收前) | 内存敏感型缓存、大对象缓存 |
| 弱引用 | WeakReference | 每次GC时回收 | 能(回收前) | ThreadLocal的Key、WeakHashMap、防止内存泄漏 |
| 虚引用 | PhantomReference | 对象即将销毁时通知 | 永远返回null | 堆外内存清理、对象销毁周期跟踪 |
2.1 Reference类与ReferenceQueue的配合关系
所有软、弱、虚引用都继承自java.lang.ref.Reference抽象类。这个抽象类里有一个ReferenceQueue字段——它非常关键。当你创建SoftReference、WeakReference或PhantomReference时,可以在构造函数里传入一个ReferenceQueue。当被引用的对象被GC回收后,JVM会把这个Reference对象本身(而不是被引用的对象)放入队列中。
这意味着什么?假设你创建了一个WeakReference<byte[]> ref = new WeakReference<>(data, queue)。只要data失去了强引用,下次GC回收后,ref这个对象就被放进queue。你轮询这个队列,如果queue.poll()能拿到一个Reference,就知道那个原始对象已经被回收了。
很多人以为队列是为了通知"对象被回收了",对,这是它的核心功能。但它更重要的是提供一个生命周期追踪机制:你可以在原始对象被回收后,做额外的清理动作。比如在虚引用场景下,实际对象内存已经释放了,但与之关联的堆外内存、文件句柄等系统资源还没清理,你就可以通过队列得知对象死亡,然后趁机做清理收尾。
2.2 为什么不是只有强引用一种?
有人会问:把回收决定权交给GC不行吗,干嘛要分这么细?答案是:不同数据对内存的"忍耐程度"不同。有些数据丢了会严重影响功能(比如交易的订单状态缓存),有些数据丢了顶多重新计算一次(比如实时计算的临时结果),还有些数据需要的是"对象死的时候告诉我一声"(比如操作系统句柄的释放监督)。
如果全部用强引用,内存不足时所有对象一视同仁,GC就只能年老代频繁Full GC,然后还是不够就OOM,完全没有腾挪空间。而软引用、弱引用给了GC在"对象保留"与"内存释放"之间的中间档位,让程序能主动声明自己的内存容忍度,进而换取更平滑的内存衰减策略。
3. 强引用与软引用:最常用的两个等级到底差在哪
3.1 强引用:一切默认行为
其实大多数情况下你写的代码都是强引用,不需要额外讨论。比如:
List<Order> orders = loadFromDb(); Map<String, Session> sessionMap = new HashMap<>();orders和sessionMap一旦被某个存活线程栈或静态域持有,JVM就不会动它们引用到的对象。这是Java默认的引用机制,也最容易被滥用——很多内存泄露就是强引用被"无意中持有"造成的。
一个典型的例子是全局单例保存了大量不必要的对象:
public class CacheManager { private static Map<String, Object> cache = new HashMap<>(); public static void put(String key, Object value) { cache.put(key, value); } }如果你把变化频繁的对象塞进去,又忘了移除,这个HashMap会无限膨胀。因为cache是静态强引用,GC拿它毫无办法。优化方向之一就是:在确定对象的生命周期比全局容器短时,改用弱引用作为Map的Value,或者用定时清理、覆盖策略,从源头掐断不需要的引用。
3.2 软引用:内存敏感缓存的正确打开方式
SoftReference的典型应用是缓存那些重建成本高、但系统允许在内存出现压力时丢弃的数据。比如图片缩略图、网络请求的响应体、从数据库加载出来的大对象,这些对象有两点共同特征:
- 获取成本高(IO、网络、数据库查询);
- 丢失后用户只是多等一会儿,不会导致业务错误。
为它们设计软引用缓存,可以在内存宽裕时保留,在内存紧张时自动被GC回收,从而避免OOM。
下面是一个简单的软引用缓存实现思路:
public class SoftCache<K, V> { // 注意:这里不是要求大家直接用map套软引用,而是在设计时要考虑“清除无效引用”的问题 private final Map<K, SoftReference<V>> cache = new HashMap<>(); public synchronized V get(K key) { SoftReference<V> ref = cache.get(key); if (ref == null) return null; V value = ref.get(); if (value == null) { // 软引用已被回收,顺手移除,避免无效条目占内存 cache.remove(key); } return value; } public synchronized void put(K key, V value) { SoftReference<V> ref = new SoftReference<>(value); cache.put(key, ref); } }你必须注意一个问题:软引用被GC回收后,ref.get()返回null,但Map中对应的条目仍然存在。如果不清理,这个Map里会堆积无数key -> SoftReference的空壳,每次查询还要走一遍,反而浪费内存。所以上面的get()里做了"发现null就移除"的处理。更稳妥的做法是配合ReferenceQueue,后台线程监听队列并主动清理Map。
另一个容易忽略的事实是:软引用在内存不足时回收,但回收时机并不精确。JVM没有规定必须在内存阈值达到多少时立刻回收所有软引用,只是保证在抛出OOM之前回收。不同垃圾收集器(CMS、G1、ZGC)对软引用处理也有细微差异,在某些GC下软引用的清理可能延迟到Full GC。因此依赖软引用当"免死金牌"是不靠谱的,它只能作为缓解手段,不能替代对缓存容量的主动控制。
3.3 强引用与软引用的选择边界
需要决定"用强引用还是软引用"时,我的经验是问自己三个问题:
- 这个数据如果缺失,用户需要等多久才能重建?
- 这个数据存不存在并发安全风险?
- 缺失后是否有挫败感或正确性问题?
如果重建成本极低(比如几毫秒内查库),但对象量大,我倾向直接用强引用数组/Map并配合LRU主动淘汰,而不是软引用。软引用的回收是由GC触发,不可预测,业务上反而难以控制缓存命中率波动。相反,如果重建成本高,又必须限制内存占用,用软引用做一个"无显式淘汰策略的兜底缓存"是合理的。
举个实际场景:渲染引擎加载贴图时,如果贴图量大,可以把它包装在软引用中。当内存不足时,GC回收部分贴图的像素数据,引擎再通过Disk或者网络重新加载,用户最多看到短暂的空白,不会崩溃。我见过有团队对这个场景强行用LRU+强引用,结果缓存大小设置得太保守导致命中率极低,设置得太宽又撑爆内存,最后换成软引用兜底才解决了问题。
4. 弱引用与虚引用:看似边缘实则决定生死线的机制
4.1 弱引用:生命周期只到下一次GC
相比软引用,弱引用的回收条件更苛刻:只要发生GC,就可能回收。哪怕内存充足,也会回收。听起来弱引用很"弱",但它用于解决某些特定的生命周期场景非常合适。
最著名的案例是ThreadLocal的内部实现。ThreadLocalMap中的Entry继承了WeakReference<ThreadLocal<?>>,也就是说ThreadLocal对象的Key是弱引用。这意味着,如果外部没有强引用指向ThreadLocal对象(通常我们使用ThreadLocal实例作为key),那么在下一次GC时,这个Key就会被回收,Entry变成key == null的无用条目,为后续清理创造条件。
为什么要这么做?因为线程池中线程存活时间很长,线程的ThreadLocalMap也一直存在。如果Map中的Key对ThreadLocal对象是强引用,哪怕业务代码已经不再使用这个ThreadLocal变量,这个ThreadLocal对象也不会被回收,还会阻止它所引用的值对象被回收,从而造成内存泄漏。用弱引用做Key,允许ThreadLocal本身在失去外部引用后自然死亡,这是一种"让临时变量不拖累生命周期"的机制。
使用弱引用时有几个陷阱:
WeakReference.get()可能返回null,使用前必须判空,否则可能NPE。- 弱引用并不能阻止其引用对象被回收,所以它不适合用来"保住"某个核心对象。
- 和软引用一样,持有WeakReference的容器如果没有清理逻辑,也会堆积无用的引用空壳。
4.2 弱引用的经典搭档:WeakHashMap
WeakHashMap就是基于弱引用设计的典型容器。它的Entry继承WeakReference,Key被GC回收后,Entry会自动被移除(下一次操作时)。适合做"对象生命周期与外部分离"的缓存,比如和某个类的对象绑定的元信息。
举个例子:如果写一个监控平台,需要为每个正在运行的任务保留一个状态对象,任务完成后状态对象不再有强引用,那么用WeakHashMap<Task, Status>很自然。任务对象被GC回收后,状态条目也会自动消失,避免手动移除的麻烦。
但是注意:WeakHashMap的Value会持有Key的强引用,存在经典的"Value强引用Key导致Key无法回收"的问题。假设把value设置成某个引用了key的对象,那么即使Key没有外部强引用,也会因为Value的引用而存活,最终导致弱引用失效。实际使用中,要么避免Value反向引用Key,要么把Value也包成弱引用。
4.3 虚引用:你永远拿不到对象,但你能知道自己失去了它
PhantomReference是所有引用中最特殊的一个:
get()方法永远返回null,所以你别想通过它访问对象;- 它必须和
ReferenceQueue配合使用; - 当被引用的对象被回收后,JVM会把PhantomReference对象放入队列。
那虚引用凭什么被归为引用类型?它真正的用途是:当对象被回收,你会收到一个队列通知,此时可以执行一些后续操作。听起来像"对象死亡监听器"。
最让人熟知的虚引用应用是NIO的DirectByteBuffer。DirectByteBuffer对应的堆外内存不归GC管,如果只在finalize或Cleaner机制里释放,时机太晚且不确定。JDK在DirectByteBuffer中使用了虚引用Cleaner来追踪直接缓冲区的生命周期:当DirectByteBuffer对象本身被GC回收后,虚引用对象会被放入队列,监视器线程发现后调用Cleaner.clean()释放对应的堆外内存。如果没有这套机制,堆外内存就无法在对象回收时及时释放,造成物理内存持续增长。
自己实现一个虚引用追踪的模板大概长这样:
public class PhantomResourceGuard { private final ReferenceQueue<Object> queue = new ReferenceQueue<>(); private final Set<PhantomReference<Object>> refs = Collections.synchronizedSet(new HashSet<>()); public void track(Object target, Runnable cleanupAction) { // 自定义的PhantomReference子类,额外保存清理动作 PhantomReference<Object> phantomRef = new PhantomReference<>(target, queue) { // 无法继承字段,一般通过外部Map做关联,这里示意使用自写子类 }; // 这里为了示例,简化了逻辑:把清理动作保存在一个关联结构中, // 实际项目建议用一个"包装对象"保存引用和动作。 refs.add(phantomRef); attachedActions.put(phantomRef, cleanupAction); } // 后台线程调用 public void monitor() throws InterruptedException { Reference<?> ref = queue.remove(); if (ref instanceof PhantomReference<?>) { // 从attachedActions取出清理动作并执行 Runnable action = attachedActions.remove(ref); if (action != null) { action.run(); } refs.remove(ref); } } }要注意,虚引用触发通知的时机是"对象已不可达且被回收"之后,而不是回收之前。因此清理动作不能试图访问对象内容,否则会失败。它只适合释放与对象关联的外部资源。
5. 引用队列的实战玩法:从资源释放到泄漏监控
5.1 如何用ReferenceQueue实现缓存自清理
前面提到,无论是软引用还是弱引用,都会有"引用对象本身堆积在容器中"的问题。用ReferenceQueue可以很优雅地解决。核心思路是:
创建引用时传入同一个ReferenceQueue,启动一个后台线程不断从队列中取出Reference,再从缓存Map中移除对应的键。
实现一个带队列清理的弱引用缓存:
public class RefQueueCache<K, V> { private final Map<K, WeakReference<V>> cache = new HashMap<>(); private final ReferenceQueue<V> queue = new ReferenceQueue<>(); public void runCleaner() { Thread cleaner = new Thread(() -> { while (!Thread.currentThread().isInterrupted()) { try { Reference<? extends V> ref = queue.remove(1000); if (ref != null) { // 需要一种方式从ref反推出键K // 常见方案是自定义WeakReference子类,持有K } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }, "ref-queue-cleaner"); cleaner.setDaemon(true); cleaner.start(); } }由于ReferenceQueue只存放引用本身,不自带键信息,如果要在清理时知道对应的Key,得自定义WeakReference子类,让它持有Key。或者用一个反查MapMap<Reference<?>, K>。这种做法我用的比较多,可以避免遍历整个缓存Map找出哪条引用已被清理。
5.2 用引用类型做对象生命周期监控
除了缓存清理,引用队列还能做"对象生命周期监控"。比如你想验证系统里某个对象是否被正常回收:
WeakReference<BigObject> ref; ReferenceQueue<BigObject> queue; // 初始化时 BigObject obj = new BigObject(); queue = new ReferenceQueue<>(); ref = new WeakReference<>(obj, queue); obj = null; // 释放强引用 // 手动触发GC(生产环境不建议) System.gc(); // 随后检查 Reference<? extends BigObject> polled = queue.poll(); if (polled != null) { System.out.println("BigObject已被回收"); }我在测试环境写内存泄漏自动化用例时,喜欢用这种机制跑断言:创建测试对象,持有其弱引用,调用可能泄漏的方法后主动触发GC,再检查弱引用是否已被回收。如果预期应该被回收的对象在弱引用里依然存活,大概率说明代码里多了一条强引用路径。
5.3 引用队列与Cleaner的边界
如果只讲引用队列,不提Cleaner就少了一块。JDK 9之后finalize()被废弃,推荐使用java.lang.ref.Cleaner。Cleaner内部也利用虚引用机制,但它的API更友好——通过register(target, Runnable action)注册清理动作,当target被GC回收时,action会被执行。
比如管理一个不安全的本地文件锁:
Cleaner cleaner = Cleaner.create(); FileLockHandle handle = new FileLockHandle(); Cleaner.Cleanable cleanable = cleaner.register(handle, () -> { // 释放文件锁 });用Cleaner的好处是不需要自己维护ReferenceQueue和后台线程,但它的执行也依赖GC时机,不能保证确定性。如果你需要在某个精确时间点解锁,还是应该用try-with-resources或显式关闭,而不是依赖Cleaner。
6. 高频场景决策:缓存、ThreadLocal、堆外内存到底用哪种引用
6.1 缓存的引用选型决策树
虽然前面介绍了很多概念,但落到具体代码里,很多人依然会纠结:我这个缓存到底应该用软引用、弱引用还是不用引用。我自己的决策流程是这样的:
先判断缓存数据的"重建成本"。如果重建成本高,且内存充足时愿意多占用内存换取高速命中,优先考虑软引用缓存。如果重建成本低或者要求强一致性,不要用引用来做缓存控制,直接使用带淘汰策略的强引用容器。
然后判断数据是否有关联生命周期。比如缓存一条数据依赖某个对象的存在,该对象销毁后缓存数据就没有意义,用弱引用甚至WeakHashMap让它自动消失。反之,缓存的生命周期要跟随容器运行时,数据本身不允许被GC乱回收,就必须用强引用。
最后判断是否需要"回收通知"。如果需要资源释放、日志记录或监控回调,用虚引用或引用队列。
以下是我在不同实际项目里采用过的方案,供你参考:
| 场景 | 选择 | 原因 |
|---|---|---|
| 全局用户会话缓存 | 强引用 + 过期时间/清理任务 | 会话不能随意丢失,且需要精确控制失效 |
| 内存中加载的图片像素缓存 | 软引用 | 重建自磁盘速度中等,内存不足时可丢 |
| 与类实例绑定的临时状态 | WeakHashMap | 类实例消失后,状态自动失效 |
| 大块堆外ByteBuffer | 虚引用 + Cleaner | 跟踪堆外内存释放,不能依赖finalize |
| ThreadLocal变量本身 | 内部使用弱引用Key | 避免线程池场景下ThreadLocal无法回收 |
6.2 ThreadLocal场景的深度剖析
ThreadLocal是弱引用最经典的实战场。你创建了一个ThreadLocal变量,然后在线程池中执行任务,如果某个线程复用了,ThreadLocalMap中的Entry会一直被该线程持有。如果ThreadLocal对象被外部强引用指向,它不会死,Entry有效;一旦业务代码不再引用它,ThreadLocal对象失去强引用,Entry的Key变为null,可后续如果是新任务又设置了新的ThreadLocal值,老的Entry依然存在,值对象却不会被回收,这就是所谓的内存泄漏源头。
因此,正确使用ThreadLocal有两条铁律:
- 在一个请求/任务处理完成后调用
remove(),主动清除当前线程的ThreadLocalMap条目。这一步比依赖弱引用更可靠,因为弱引用只回收Key,如果值对象还被强引用持有,它不会自动消失。 - 不要在static字段中长时间保存ThreadLocal实例而不调用remove,尤其是配合线程池使用时。
6.3 堆外内存的虚引用实战
如果你用NIO的ByteBuffer.allocateDirect(size)做I/O,要考虑堆外内存回收问题。DirectByteBuffer对象本身在堆内,但它引用的直接内存不在堆内。JVM通过一个Cleaner虚引用对象跟踪DirectByteBuffer,当DirectByteBuffer变得不可达,Cleaner才会跑清理逻辑。
自己写底层库时,如果要复用这套机制管理自定义的堆外资源,需要确认资源释放是不可重入的(clean只能执行一次),并且不能在"对象还活着"时误触发清理。我的建议是优先使用Cleaner,而不是自己实现PhantomReference,因为Cleaner内部已有监控线程和异常处理,更稳定。只有需要极其严格的队列回调顺序时,才回到ReferenceQueue的方案。
7. 排错实战:如何定位"该回收却回收不掉"的引用问题
7.1 从Dump分析到引用通路判断
如果程序出现疑似"引用没被正确回收"的问题,我的排查路径一般是:
先用jmap -dump:live,format=b,file=heap.hprof <pid>导出一个存活对象的堆快照。这里需要注意,-dump:live会先触发一次Full GC,但仅保留存活对象,所以如果你的对象本来就该被GC回收却依然存活,用-dump:live反而看不到它。正确的做法是导出完整堆(去掉live参数),再用MAT或VisualVM看不带live的Dump,才能判断是否有"本应回收却存活"的对象。
然后在MAT中使用"Find GC Roots"功能,看这个对象的引用链。沿着引用链逐级往上找,直到找到某个容器或静态变量,基本就能定位是哪条引用路径把它保住了。
7.2 验证SoftReference回收可能性
想验证软引用到底会不会被GC回收,最简单的实验代码是这样:
SoftReference<byte[]> soft = new SoftReference<>(new byte[8 * 1024 * 1024]); byte[] strong = new byte[8 * 1024 * 1024]; soft = ??? // 这里需要看代码设计但注意:如果你在测试软引用时创建了足够大的新数组并触发内存分配失败,JVM可能直接OOM,而不是先把软引用对象回收掉——这是因为软引用对象所在的代还没到尝试回收软引用的时机,或者内存分配失败发生在老年代回收之前。在真实JVM中,软引用回收的具体规则依赖GC实现,测试时要小心变量。
实际教给团队的方法是:通过JMX获取Heap内存使用量,逐步降低可用内存(用一个大数组占据堆空间),观察SoftReference.get()何时返回null。这样能直观体会到"内存不足时软引用被清理"的大致临界点,但不要期望它是一个精准阈值。
7.3 排查WeakHashMap意外存活
如果你用了WeakHashMap,却发现"键不再被外部引用,但条目还在",很可能Value引用回Key,或者Key被另一个集合意外持有。排查时要注意:
- 检查Key对象是否重写了
equals/hashCode,有没有无意的静态缓存? - 检查Value对象是否间接持有了Key引用(比如Value内部有一个指向Key的listener)。
- 调试时通过反射拿到
WeakHashMap的内部Entry数组,逐个查看Entry的referent。如果referent非null,说明还有强引用指向Key。
7.4 引用清理过程中的并发安全问题
最后一个容易被忽略的坑:多个线程同时操作引用队列和缓存容器时,可能出现"一个引用被重复出队"或"新对象放入容器后发现旧引用清理与此冲突"。推荐的做法是:
- 对引用集合(缓存Map、队列)使用
synchronized或ConcurrentHashMap保护; - 清理线程要做成daemon线程,防止阻塞应用退出;
- 不要在ReferenceQueue回调里做耗时操作,否则会拖慢整个清理进度;
- 谨慎使用
Reference.reachabilityFence,在JDK 9+中,如果想阻止对象在方法结束前被回收,需要手动建立可达性栅栏。很多人在方法参数上持有引用就不注意了,但JIT可能优化掉局部引用导致对象提前被回收,这在虚引用场景尤其需要重视。
8. 根据个人经验总结的选型清单与几个容易掉进去的坑
这里把最关键的实操建议压缩成清单。
关于软引用:
- 不要在软引用对象里引用超大数组的同时,等着它自动被回收,请务必合理设置堆大小和缓存条目上限;
- 不要依赖
SoftReference做精确缓存淘汰,它不适合需要强一致命中率的场景; - 可以和
LinkedHashMap配合写LRU软引用缓存,内存不足时天然淘汰,而LRU负责淘汰最久未用。
关于弱引用:
- 使用前一定要检查
get()是否为null; - 适合做"与外部生命周期绑定"的元数据;
- 注意Value强引用Key的坑,必要时将Value设计成只持有业务数据而非Key;
- 配合ReferenceQueue使用时,要防止ReferenceQueue被用户线程独吞,导致清理处理不及时。
关于虚引用:
- 很少需要在业务代码中直接用
PhantomReference,多数是底层的Cleaner机制; - 如果你要自己写Cleaner,放弃在
PhantomReference中访问对象内容的念头; - 虚引用无法阻止对象被回收,所以不要用它做强引用替代器。
关于ReferenceQueue:
- 队列中的Reference对象会持有自身引用,不会自动从队列移除;你必须主动poll/remove,否则Reference对象也会堆积;
- 队列的一条Reference可能包含附件信息,建议在自定义引用子类中带上关联Key或清理动作,少用Map做二次映射。
8.1 我最常写的一个"防抖"示例
最后分享一个我经常写进基础设施里的工具:一个基于弱引用的任务状态管理器,专门用来跟踪那些"异步任务结束后,状态对象必须被回收"的场景。
public class TaskStatusHolder { private final ConcurrentHashMap<TaskKey, WeakReference<TaskStatus>> statuses = new ConcurrentHashMap<>(); private final ReferenceQueue<TaskStatus> queue = new ReferenceQueue<>(); public void putTask(TaskKey key, TaskStatus status) { // 自定义WeakReferenceWithKey持有key statuses.put(key, new WeakReferenceWithKey(key, status, queue)); fireCleaner(); } private void fireCleaner() { WeakReference<?> ref; while ((ref = (WeakReference<?>) queue.poll()) != null) { if (ref instanceof WeakReferenceWithKey) { TaskKey k = ((WeakReferenceWithKey) ref).key; statuses.remove(k); } } } }这个工具在线上已经扛了好几个大型任务调度场景。有了它,任务完成、状态对象失去外部引用后,TaskStatus的内存会被快速释放,而不用等待手动remove。后来把statuses从强引用Map换成基于引用队列自清理的版本后,整个调度模块的老年代内存曲线明显平滑了下来。
8.2 四类引用的最终决策心法
说来说去,四种引用类型的本质是给GC一条不同强度的"释放建议"。我在选型时心里一直记着一句话:强引用是默认,软引用是退让,弱引用是跟随,虚引用是告别。
你的代码对某个内存数据的珍惜程度,决定了你用哪一种引用。如果数据丢了系统直接崩溃,请用强引用并主动控制容量;如果丢了会心疼但能重来,用软引用;如果数据应该随着某个外部对象的消亡而消亡,用弱引用;如果你只想知道它什么时候死,甚至要在死后帮忙收尸,用虚引用。
搞懂它们之后,再看到堆Dump里那些不该存活的对象,你就能顺着引用链找到藏在某个容器里的隐藏强引用。于我而言,理解这四种引用不仅是面试知识点,更是在真正的高并发内存环境下和JVM协作的接口。希望这篇分享能帮你少走几回我走过的弯路。