强软弱虚四种引用:软引用做缓存、弱引用防泄漏、虚引用管堆外
前面讲判活时,回收器问的是「从 GC Roots 还能不能走到这个对象」。四种引用就是给这条「能走到」加四种强度:强引用走不到就回收,软引用内存不足才清,弱引用下一次 GC 就清,虚引用最弱、专门用来在对象即将回收时发通知。这篇用 JDK 26 的真实实验把四者全跑一遍,重点落在三个高频考点:软引用缓存到底怎么被清、弱引用和虚引用的真正差别在哪(顺带拆掉一个流传很广的 Java 8 旧结论),以及 ThreadLocal 的内存泄漏为什么是弱引用「背锅」。
命令行都是不带循环、不带管道、不带&&的普通命令,macOS / Linux / Windows PowerShell 里逐行照抄即可(PowerShell 5.1 不支持&&,所以本文全部拆成单独两行)。Windows 中文控制台若把中文输出显示成乱码,先执行chcp 65001切成 UTF-8 代码页。
四种引用先摆上桌
| 引用 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用 Strong | 不可达就回收,绝不因内存不够而让路 | 日常 new,绝大多数引用 |
| 软引用 SoftReference | 内存够时不主动清;快不够时优先清它腾地方 | 内存敏感型缓存 |
| 弱引用 WeakReference | 只要没了强/软引用,下一次 GC 就回收 | 缓存 key、WeakHashMap、ThreadLocal 的 key |
| 虚引用 PhantomReference | 对象将回收时进队列通知(get 永远 null) | 堆外内存 / 资源的回收通知 |
后三种统称「引用对象」(reference object),它们的宿主对象叫 referent(被引用者)。理解整篇的关键就一句:引用对象本身也是对象、要自己活得下去,你必须在代码里用强引用攥住这个 WeakReference,它才有资格去弱引用别的东西——这个「引用链强度」的视角,下面每个实验都会用到。
实验一:弱引用 + ReferenceQueue,回收后给你发通知
WeakReference 建好时 wr.get() 还能拿到对象;把唯一的强引用 obj 置 null、System.gc() 之后再看:
import java.lang.ref.*;
/** 弱引用:断掉强引用后下一次 GC 就回收,并把引用自己塞进队列。JDK 26。 */
public class WeakQueueLab {
public static void main(String[] x) throws Exception {
ReferenceQueue<Object> q = new ReferenceQueue<>();
Object obj = new Object();
WeakReference<Object> wr = new WeakReference<>(obj, q);
System.out.println("wr 自己 =" + wr);
System.out.println("建好后 get()=" + wr.get() + ",队列里 q.poll()=" + q.poll());
obj = null; forceGc(3);
System.out.println("GC 后 get()=" + wr.get() + ",队列里 q.poll()=" + q.poll());
}
static void forceGc(int times) throws InterruptedException {
for (int i = 0; i < times; i++) { System.gc(); Thread.sleep(20); }
}
}
javac -encoding UTF-8 WeakQueueLab.java
java WeakQueueLab
wr 自己 =java.lang.ref.WeakReference@8bcc55f
建好后 get()=java.lang.Object@14dad5dc,队列里 q.poll()=null
GC 后 get()=null,队列里 q.poll()=java.lang.ref.WeakReference@8bcc55f
三行输出把「回收通知」这件事拆得很直白:
- 第一行先记下
wr自己长什么样:java.lang.ref.WeakReference@8bcc55f。@后面那串是 JVM 给它的 identity hash(和System.identityHashCode(o)的十六进制值完全相等,实测验证过)。 - 第二行对象还在:
wr.get()拿得到java.lang.Object@14dad5dc,而q.poll()是null——队列还是空的,没有任何东西入队。 - 第三行对象没了:
wr.get()变成null,同时q.poll()吐出一个java.lang.ref.WeakReference@8bcc55f——和第一行的wr是同一个 hash,进队列的就是它本人。
注意第三行的信息量比「队列取到=true」大:q.poll() 是从 null 变成我自己,你不光知道「有东西入队了」,还能亲眼确认入队的是哪个对象。这就是回收通知机制——你不是被动地「get 到 null 才知道它死了」,而是能收到一条明确的「它已经被回收」的入队消息。
(关于 @ 后面的 hash:它在同一台 JVM 上反复跑是稳定的——HotSpot 默认用确定性的线程内 PRNG 生成 identity hash(hashCode=5),所以同样分配顺序会得到同样的值,本机连跑三次完全一致。换 JDK 版本或改动分配顺序就会变,但这不影响结论:要看的只是「第一行和第三行是不是同一个值」。)
ReferenceQueue 的典型用途是清理缓存里的死项。比如一个「key → WeakReference
实验二:软引用,内存足时清不动、内存紧时优先清
弱引用是无条件的下一次 GC 就清。软引用则多一道「看内存脸色」的判断。先看内存充足时:
import java.lang.ref.*;
/** 软引用:内存充足时 System.gc 也清不动它。JDK 26,默认堆直接跑。 */
public class SoftLab {
public static void main(String[] x) throws Exception {
Object obj = new byte[2 * 1024 * 1024];
SoftReference<byte[]> sr = new SoftReference<>((byte[]) obj);
for (int i = 0; i < 3; i++) { System.gc(); Thread.sleep(20); }
System.out.println("soft 内存充足 System.gc 后 get()=" + (sr.get() != null));
}
}
javac -encoding UTF-8 SoftLab.java
java SoftLab
soft 内存充足 System.gc 后 get()=true
一个刚建好、还被 System.gc() 关照过的软引用对象,内存够用的情况下活得好好的。软引用不是「GC 就清」,而是「GC 到内存实在不够、你又是最该被牺牲的缓存时,才清你」。HotSpot 用一个参数控制清的早晚:SoftRefLRUPolicyMSPerMB,默认 1000——含义是「每 MB 堆内存,允许软引用对象在最近一次访问后存活约 1000ms」;它被最近访问过、或者堆还宽裕,就不清;它很久没人碰、堆又吃紧,才成为第一个被牺牲的。
真正能看出软引用价值的是压力对照:先占 16MB 的缓存、再向一个 -Xmx64m 的堆要 40MB,只是缓存用软引用还是强引用持有,命运完全不同:
import java.lang.ref.*;
import java.util.*;
/** 先占 16MB 缓存,再要 40MB:软引用被清、强引用 OOM。用法:java SoftVsStrong <soft|strong> */
public class SoftVsStrong {
public static void main(String[] x) {
boolean soft = x[0].equals("soft");
List<SoftReference<byte[]>> softKeep = new ArrayList<>();
List<byte[]> hardKeep = new ArrayList<>();
for (int i = 0; i < 16; i++) {
byte[] b = new byte[1024 * 1024];
if (soft) softKeep.add(new SoftReference<>(b)); else hardKeep.add(b);
}
String r;
try { byte[] big = new byte[40 * 1024 * 1024]; big[0] = 1; r = "40MB 分配成功"; }
catch (OutOfMemoryError e) { r = "OutOfMemoryError:40MB 放不下"; }
int cleared = 0;
if (soft) for (SoftReference<byte[]> s : softKeep) if (s.get() == null) cleared++;
System.out.println("mode=" + (soft ? "soft" : "strong") + ":先占 16MB 缓存,再要 40MB -> " + r);
if (soft) System.out.println(" 16 个软引用被清掉 " + cleared + " 个");
else System.out.println(" 16 个强引用数组原样存活,一个都清不掉");
}
}
javac -encoding UTF-8 SoftVsStrong.java
java -Xmx64m -XX:G1HeapRegionSize=4m SoftVsStrong soft # 40MB 成功、软引用全清
java -Xmx64m -XX:G1HeapRegionSize=4m SoftVsStrong strong # OutOfMemoryError
mode=soft:先占 16MB 缓存,再要 40MB -> 40MB 分配成功
16 个软引用被清掉 16 个
mode=strong:先占 16MB 缓存,再要 40MB -> OutOfMemoryError:40MB 放不下
16 个强引用数组原样存活,一个都清不掉
强引用版:16MB 缓存死死占着,谁也别想动,40MB 大请求直接 OutOfMemoryError。软引用版:JVM 面临放不下的请求时,先把 16 个可牺牲的软引用缓存全清掉、把内存让出来,40MB 分配成功,进程活下来。这就是软引用的定位:给 JVM 一个「先牺牲缓存、别让进程死」的台阶。 代价是缓存内容随时可能丢,取用时必须容忍 get()==null 后重新加载(load-on-miss),所以软引用只适合「重建成本能接受」的缓存。
补一句:软引用那轮的成功不是必然——如果 JVM 判断清了缓存也腾不出 40MB,照样 OOM。它给你的是「先牺牲缓存」的机会,不是免死金牌,正文把它说成「台阶」就是这个意思。
实验三:虚引用,get 永远 null,作用是「死后通知你」
import java.lang.ref.*;
/** 虚引用:get 永远 null,唯一作用是回收后进队列通知。JDK 26。 */
public class PhantomLab {
public static void main(String[] x) throws Exception {
ReferenceQueue<Object> q = new ReferenceQueue<>();
Object obj = new Object();
PhantomReference<Object> pr = new PhantomReference<>(obj, q);
System.out.println("phantom 建好后 get()=" + pr.get());
obj = null;
for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(20); }
System.out.println("phantom 断强引用+System.gc 后队列取到=" + (q.poll() == pr));
}
}
javac -encoding UTF-8 PhantomLab.java
java PhantomLab
phantom 建好后 get()=null
phantom 断强引用+System.gc 后队列取到=true
PhantomReference 最扎眼的一点是 get() 永远返回 null——你根本不可能通过虚引用摸到那个对象,它纯粹是个「监视器」;它还必须配 ReferenceQueue,功能就是对象进入虚可达时把自己塞进队列,让你知道「那东西可以清理了」。为什么要一个拿不到对象的监视器?因为它的经典用途是管理不受 GC 管的外部资源:堆外内存、文件句柄这类资源,GC 只能回收那个 Java 壳对象,壳死了不代表底层堆外内存被释放,必须有人显式去 free。虚引用把「壳对象进入虚可达、即将被回收」这个时机变成一条队列通知,收到通知就去释放底层资源——时机正好卡在对象已 finalize、而引用本身还活着能携带清理信息。
JDK 里最出名的实现是 NIO 的 DirectByteBuffer:它背后的清理器从 JDK 8 的 sun.misc.Cleaner 到 JDK 9 起的 java.lang.ref.Cleaner,底层都是往缓冲区上挂一个虚引用,缓冲区不可达被回收时触发清理逻辑去 free 那块堆外内存。这也是为什么说堆外内存的回收时机是「跟着壳对象的 GC 走」,虚引用就是那根把两者绑在一起的线。注意这里不需要你手写——Cleaner 已经封装好,Cleaner.create(obj, runnable) 注册一个清理动作即可。
实验四:弱引用和虚引用到底差在哪,get() 永远 null 只是表象
前面三个实验连着看完,很容易得出一个结论:弱引用和虚引用都能配队列发通知,区别无非是虚引用的 get() 永远返回 null。这个说法在面试题里流传极广,但它只说了表象——get() 返回 null 是设计者故意为之,不让你在对象将走未走时把它摸回来复活,那会把 GC 的生命周期管理搅乱。真正的差异在于通知发生在对象生命周期的哪一站。
看 JDK 对「可达性」的原始定义(java.lang.ref 包 javadoc,JDK 26):
- 弱可达:不强、不软可达,但能顺着一条弱引用够到它。弱引用被清除之后,这个对象才变得「可被 finalize」。
- 虚可达:不强、不软、不弱可达,而且已经 finalize 完毕,还有虚引用指着它。
把这两句并排读,时序就出来了:
弱引用 -> 只剩弱引用,进入弱可达 -> GC 清除弱引用并入队 -> 对象这时才「可被 finalize」
↑ 通知来得早:finalize 都还没跑
虚引用 -> 只剩虚引用,进入虚可达(已 finalize) -> GC 清除虚引用并入队 -> 随即可回收
↑ 通知来得最晚:回收前的最后一站
于是两条结论:一是能不能摸到对象,弱引用在清除之前还能 get() 出来,虚引用永远拿不到;二是通知的早晚,弱引用在 finalize 之前就通知你(此时对象还有一次被「复活」的机会——finalize() 里可以把 this 存进 static 字段),虚引用等到 finalize 跑完、真没人可达了才通知,它是你能拿到的最后一个时机。
一个被传了很多年的旧说法:不少资料(包括大量面试题答案)会说「虚引用不会被 GC 自动清除,入队后必须手动 clear(),在 clear 之前它会一直阻止对象被回收」。这话在 Java 8 及以前是对的:那时虚引用的 referent 确实会被继续持有,所以官方文档把它的用途描述成 pre-mortem(死前)清理。Java 9 起(OpenJDK JDK-8071507)改成和软引用、弱引用一样由 GC 自动清除,用途描述也随之改成 post-mortem(死后)清理。也就是说,现在还把「必须手动 clear()」当考点背,背的是过期知识。
光看文档容易将信将疑,跑一段就知道:
import java.lang.ref.*;
import java.lang.reflect.*;
/** 虚引用实测:get() 永远 null,但入队时 GC 会自动清除它。JDK 26。
* 需要开反射:--add-opens java.base/java.lang.ref=ALL-UNNAMED */
public class PhantomClearLab {
public static void main(String[] x) throws Exception {
ReferenceQueue<Object> q = new ReferenceQueue<>();
Object obj = new byte[8 * 1024 * 1024];
PhantomReference<Object> pr = new PhantomReference<>(obj, q);
Field rf = Reference.class.getDeclaredField("referent"); // 反射直读 referent
rf.setAccessible(true);
System.out.println("建好后 get()=" + pr.get());
System.out.println("建好后 refersTo(obj)=" + pr.refersTo(obj));
System.out.println("建好后 referent 非 null=" + (rf.get(pr) != null));
obj = null;
for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(30); }
System.out.println("GC 后入队=" + (q.poll() == pr));
System.out.println("GC 后 referent 非 null=" + (rf.get(pr) != null));
}
}
javac -encoding UTF-8 PhantomClearLab.java
java --add-opens java.base/java.lang.ref=ALL-UNNAMED PhantomClearLab
建好后 get()=null
建好后 refersTo(obj)=true
建好后 referent 非 null=true
GC 后入队=true
GC 后 referent 非 null=false
结果分三层读。get() 从建好那一刻起就是 null,跟清没清除无关,它压根不给这个入口;refersTo(obj) 还能认出 referent 是它——这是 JDK 16 起给 Reference 加的方法,专门用来在拿不到对象的前提下判断「你指的到底是不是这一个」;最关键的是最后两行,全程没调用过 clear(),GC 之后 referent 自己就变成 null 了,说明对象并没有被虚引用拖住,清除动作是 GC 自己做的。
clear() 的契约也顺手说清,免得从一个坑掉进另一个:手动调用 clear() 不会让这条引用入队,而且调用之后 GC 也不会再清除或入队它。所以别在收到通知之前随手 clear(),那会把通知弄丢。老资料里「从队列取到之后要 clear() 释放 referent」的建议,在 Java 8 是必需动作,JDK 9 之后已经是多余的了。
把两代差异收成一张表:
| 维度 | 弱引用 WeakReference | 虚引用 PhantomReference |
|---|---|---|
get() | 清除前还能拿到对象 | 永远返回 null |
| 通知时机 | 弱可达时:对象还没 finalize | 虚可达时:已 finalize,回收前最后一站 |
| 入队时 GC 会自动清除它吗 | 会 | 会(Java 8 及以前不会,Java 9 起改为会) |
| 能阻止对象被回收吗 | 不能 | 不能(Java 8 及以前能,Java 9 起不能) |
| 典型用途 | 缓存自动清理、WeakHashMap、ThreadLocal 的 key | Cleaner、堆外内存释放、替代 finalize |
一句话记住:弱引用是「它进了死区」的通知,虚引用是「它走完最后一程、马上要回收」的通知——两者都拦不住回收,区别在通知的早晚,以及虚引用连对象都不让你碰一下。
弱引用的实战考点:ThreadLocal 到底怎么泄漏
软引用做缓存是「该不该留」的取舍,弱引用最经典的实战场景则是 ThreadLocal。先看 JDK 26 里 ThreadLocalMap 的 Entry 到底长什么样(javap 反编译):
Compiled from "ThreadLocal.java"
class java.lang.ThreadLocal$ThreadLocalMap$Entry extends java.lang.ref.WeakReference<java.lang.ThreadLocal<?>> {
java.lang.Object value;
java.lang.ThreadLocal$ThreadLocalMap$Entry(java.lang.ThreadLocal<?>, java.lang.Object);
}
每个线程自己的 ThreadLocalMap 里,一个条目就是一个 Entry,而 Entry 的key(那个 ThreadLocal 对象)是弱引用。设计意图很清晰:ThreadLocal 对象本身通常挂在某个类的 static 字段上、由类加载器强引用着,这是正常的;可一旦你把它设成 null(或它所在的类被卸载),key 不再有强引用,弱引用的 key 就能被 GC 清掉,避免「ThreadLocal 对象自己泄漏在 ThreadLocalMap 里」。
Entry 的这两半是分开设计的,各管一件事,缺一不可:
- key 用弱引用,是防「ThreadLocal 对象自己」泄漏。 ThreadLocalMap 挂在线程上,假如 key 是强引用,map 就反过来强引用住了 ThreadLocal 对象——你在业务代码里把它置 null 也没用,只要线程还活着,那个 ThreadLocal 对象就被 map 攥着回收不掉。弱引用 key 把这条反向的强引用链剪断:ThreadLocal 对象失去外部强引用后,下一次 GC 就能清掉它,map 里只留一个 key 为 null 的空壳条目。
- value 用强引用,是保「数据真的存得住」。 ThreadLocal 的语义是「线程绑定变量」——set 进去就得能 get 回来,而且要一直有效到你主动 remove。这要求 value 必须有强引用兜底。反过来假设把 value 也做成弱引用:只要那个值没被别处强引用着,下一次 GC 就把它清了,你 set 完转个身
get()就返回 null。「线程绑定」这层语义直接成立不了,ThreadLocal 会退化成一个人人不敢用的废缓存。
所以这个结构不是「一半防泄漏、一半留了个洞」,而是两个目标的必然取舍:key 弱引用负责别让 ThreadLocal 对象赖着不走,value 强引用负责别让数据平白消失。真正的问题出在这两半的生命周期对不齐——key 可以被 GC 提前清掉,value 却要等线程结束或你手动 remove,中间那段「key 已经 null、value 还在占内存」的状态,就是泄漏的藏身处。
上面两段不是推测,回头看一眼那几行 javap 输出就清楚了:Entry 从父类继承来的是弱引用 key,而它自己声明的那个 java.lang.Object value 是个普通字段,不是 Reference 的子类——强引用就是这么来的。
那为什么网上人人喊 ThreadLocal 会内存泄漏?因为泄漏的根本不在 key,在 value。下面这段反射代码直接把这个结构翻出来:往 ThreadLocal 里塞 8MB,然后断掉对 ThreadLocal 对象的强引用、GC 一轮,再去看线程自己的 ThreadLocalMap 里那条 Entry 还剩什么:
import java.lang.ref.*;
import java.lang.reflect.*;
/** ThreadLocal 泄漏实测:弱引用 key 被清成 null,value 仍被 Entry 强持。JDK 26。
* 需要开反射:--add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.ref=ALL-UNNAMED */
public class ThreadLocalLeakLab {
static ThreadLocal<byte[]> TL = new ThreadLocal<>();
public static void main(String[] a) throws Exception {
TL.set(new byte[8 * 1024 * 1024]); // 往 ThreadLocal 里塞 8MB
WeakReference<Object> keyRef = new WeakReference<>(TL);
TL = null; // 断开唯一强引用
for (int i = 0; i < 5; i++) { System.gc(); Thread.sleep(30); }
System.out.println("ThreadLocal 对象还活着吗 (keyRef.get()!=null) = " + (keyRef.get() != null));
Field f = Thread.class.getDeclaredField("threadLocals"); // 拿到当前线程的 ThreadLocalMap
f.setAccessible(true);
Object map = f.get(Thread.currentThread());
Field tf = map.getClass().getDeclaredField("table");
tf.setAccessible(true);
Object[] table = (Object[]) tf.get(map);
Field rf = Reference.class.getDeclaredField("referent"); // Entry 的 key(弱引用referent)
rf.setAccessible(true);
int nullKey = 0; long held = 0;
for (Object e : table) {
if (e == null) continue;
Field vf = e.getClass().getDeclaredField("value");
vf.setAccessible(true);
Object val = vf.get(e);
if (rf.get(e) == null && val != null) { nullKey++; held += ((byte[]) val).length; }
}
System.out.println("Entry 中 key 已被清成 null 的条目数 = " + nullKey);
System.out.println("这些死条目的 value 仍被强持 = " + (held / 1024 / 1024) + " MB");
}
}
javac -encoding UTF-8 ThreadLocalLeakLab.java
java --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.lang.ref=ALL-UNNAMED ThreadLocalLeakLab
ThreadLocal 对象还活着吗 (keyRef.get()!=null) = false
Entry 中 key 已被清成 null 的条目数 = 1
这些死条目的 value 仍被强持 = 8 MB
三行结果就是泄漏的全貌:key 已经被 GC 清成 null(弱引用按设计干活了),可那 8MB 的 value 还稳稳地被 Entry 强持着。看结构:Entry 是弱引用 ThreadLocal(key),但 Entry 还强持着一个 value,而且 Entry 数组挂在线程自己的 ThreadLocalMap 上——只要这个线程还活着(线程池里的线程常常长期存活),key 虽然被清成 null,value 却一直被 Entry 强引用着、没人能回收。典型的泄漏链:线程池线程把一个大对象 set 进 ThreadLocal 用完不 remove,线程又一直活着复用它,那个大对象就永远赖在 ThreadLocalMap 里,直到线程死亡——而线程池线程基本不死。
所以两条结论要记牢:
- 弱引用的 key 解决的是「ThreadLocal 自身」的泄漏,解决不了「value 跟着活线程滞留」的泄漏。 value 的清理只能靠你手动
remove(),或者 ThreadLocalMap 在下一次 set/get 时顺带清掉那些 key 为 null 的过期条目(这是补救,不是保证——如果那个线程再也没碰过这个 ThreadLocal,过期条目就一直在,上面那 8MB 就是这么留下来的)。 - 正确姿势:用完 ThreadLocal 必须 remove,尤其在 try/finally 里 remove,配合线程池这种长生命周期场景。ThreadLocal 在线程池里的串值、复用风险,单元 7 线程池篇会专门展开,这里先记住「弱引用 key + 强引用 value = 半吊子防泄漏」这个结构。
顺带记一个同族类:WeakHashMap 的 key 就是弱引用,适合「key 没了条目自动消失」的场景;但它有个经典反模式——如果 value 反过来强引用了 key,key 就永远有强引用、永远清不掉,WeakHashMap 形同虚设。引用方向搞反,弱引用也救不了你。
怎么选:一张表加三个问题
| 引用 | 谁在引用 | 被清条件 | 拿得到对象吗 | 典型场景 |
|---|---|---|---|---|
| 强 | 你 | 不可达 | 能 | 一切默认 |
| 软 | 你 | 内存不足且长期没访问 | 能(可能已 null) | 可重建的缓存 |
| 弱 | 你 | 下次 GC(无强/软引用时) | 能(可能已 null) | key、ThreadLocal key、WeakHashMap |
| 虚 | 你 + 队列 | 对象将回收时 | 永远不能 | 堆外/资源的回收通知 |
选型问三个问题就够了:这个对象该不该因为内存不足而死? 该且可重建 → 软引用。该不该无条件下一次 GC 就死? 该 → 弱引用。我要不要死后知道、好去清理外部资源? 要 → 虚引用 + 队列。前两者都不适用、我也不需要通知 → 就是强引用,别为了用而用——引用对象本身也有开销。
面试速答
四种引用按强度是强软弱虚:强引用不可达才回收;软引用在内存不足时优先被清,靠 SoftRefLRUPolicyMSPerMB(默认 1000)控制清的早晚,适合可重建缓存;弱引用只要没了强/软引用、下一次 GC 就回收,回收后会进 ReferenceQueue,ThreadLocalMap 的 Entry 就是 WeakReference——弱引用解决了 ThreadLocal 自身的泄漏,但 value 仍被强持在活线程的 ThreadLocalMap 里,必须手动 remove 才能真正防泄漏;虚引用 get 永远 null、配合队列在对象将回收时收到通知(Java 9 起入队时 GC 会像清除弱引用一样自动清除它,不再需要手动 clear()),是堆外内存/资源的回收钩子,DirectByteBuffer 的 Cleaner 就是它的落地。判断该用哪种,看对象该在哪种情况下死:内存不够时死 → 软,下次 GC 就死 → 弱,死了要通知 → 虚,都不满足就是强。
本文关键词:JVM、四种引用、软引用缓存、ThreadLocal、ReferenceQueue
核心收获一句话:四种引用其实是同一条规则的四档强度——软引用等内存不够再牺牲、弱引用下次 GC 就清、虚引用在回收前的最后一站发通知(Java 9 起也由 GC 自动清除),选型看「它该在哪种情况下死」;ThreadLocal 泄漏的真相是弱引用 key 管不住强引用 value(实测:key 清成 null,8MB 的 value 还在),记得用完 remove。下一步可以把项目里「长期不清理的 Map 缓存」翻出来:能重建就换软引用、key 语义弱就换 WeakHashMap,并顺手 check 一遍所有 ThreadLocal 有没有在 finally 里 remove。