← 返回博客
2026-09-03 10:10:00

强软弱虚四种引用:软引用做缓存、弱引用防泄漏、虚引用管堆外

强软弱虚四种引用:软引用做缓存、弱引用防泄漏、虚引用管堆外

前面讲判活时,回收器问的是「从 GC Roots 还能不能走到这个对象」。四种引用就是给这条「能走到」加四种强度:强引用走不到就回收,软引用内存不足才清,弱引用下一次 GC 就清,虚引用最弱、还专门用来在对象死后发通知。这篇用 JDK 26 的真实实验把四者全跑一遍,重点落在两个高频考点:软引用缓存到底怎么被清、ThreadLocal 的内存泄漏为什么是弱引用「背锅」。

四种引用先摆上桌

引用回收时机典型用途
强引用 Strong不可达就回收,绝不因内存不够而让路日常 new,绝大多数引用
软引用 SoftReference内存够时不主动清;快不够时优先清它腾地方内存敏感型缓存
弱引用 WeakReference只要没了强/软引用,下一次 GC 就回收缓存 key、WeakHashMap、ThreadLocal 的 key
虚引用 PhantomReference对象回收后进队列通知(get 永远 null)堆外内存 / 资源的回收通知

后三种统称「引用对象」(reference object),它们的宿主对象叫 referent(被引用者)。理解整篇的关键就一句:引用对象本身也是对象、要自己活得下去,你必须在代码里用强引用攥住这个 WeakReference,它才有资格去弱引用别的东西——这个「引用链强度」的视角,下面每个实验都会用到。

实验一:弱引用 + ReferenceQueue,回收后给你发通知

JDK 26 实测(System.gc() 后看 get 和队列):

weak 建好后 get()=true
weak 断强引用+System.gc 后 get()=null,队列取到=true

WeakReference wr = new WeakReference<>(obj, queue) 建好后 wr.get() 还能拿到对象;把唯一的强引用 obj 置 null、System.gc() 之后,wr.get() 变 null——对象被回收了;同时 queue.poll() 取到的正是这个 wr 自己——回收动作发生时,弱引用对象被自动塞进你注册的 ReferenceQueue。这就是「回收通知」机制:你不是被动的「get 到 null 才知道它死了」,而是能收到一条明确的「它已经被回收」的入队消息。

ReferenceQueue 的典型用途是清理缓存里的死项。比如一个「key → WeakReference」的缓存,value 被回收后 key 还孤零零留在 map 里占内存,正确做法就是后台从 ReferenceQueue 里把已回收项 poll 出来,连同它在 map 里的 key 一起删掉——否则 map 只进不出,就是泄漏。记住这个模式,下面 ThreadLocal 的坑和它是一回事。

实验二:软引用,内存足时清不动、内存紧时优先清

弱引用是无条件的下一次 GC 就清。软引用则多一道「看内存脸色」的判断。先看内存充足时:

soft 内存充足 System.gc 后 get()=true(true=还活着,软引用不因 GC 而清)

一个刚建好、还被 System.gc() 关照过的软引用对象,内存够用的情况下活得好好的。软引用不是「GC 就清」,而是「GC 到内存实在不够、你又是最该被牺牲的缓存时,才清你」。HotSpot 用一个参数控制清的早晚:SoftRefLRUPolicyMSPerMB,默认 1000——含义是「每 MB 堆内存,允许软引用对象在最近一次访问后存活约 1000ms」;它被最近访问过、或者堆还宽裕,就不清;它很久没人碰、堆又吃紧,才成为第一个被牺牲的。

真正能看出软引用价值的是压力对照。同一个请求——先占 16MB 的缓存、再向一个 -Xmx64m 的堆要 40MB,只是缓存用软引用还是强引用持有,命运完全不同(JDK 26,两轮结果一致):

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),所以软引用只适合「重建成本能接受」的缓存。

实验三:虚引用,get 永远 null,唯一作用是「它死了通知你」

phantom 建好后 get()=null(虚引用 get 永远 null)
phantom 断强引用+System.gc 后队列取到=true(true=对象已回收,队列通知到了)

PhantomReference 有两点和弱引用截然不同:get() 永远返回 null——你根本不可能通过虚引用摸到那个对象,它纯粹是个「监视器」;同时它必须配 ReferenceQueue,唯一的功能就是对象被回收后把自己塞进队列,让你知道「那个对象死了」。为什么要一个拿不到对象的监视器?因为它的经典用途是管理不受 GC 管的外部资源:堆外内存、文件句柄这类资源,GC 只能回收那个 Java 壳对象,壳死了不代表底层堆外内存被释放,必须有人显式去 free。虚引用把「壳对象被回收」这个时机变成一条队列通知,收到通知就去释放底层资源——时机正好卡在对象死后、且引用本身还活着能携带清理信息。

JDK 里最出名的实现是 NIO 的 DirectByteBuffer:它背后的清理器从 JDK 8 的 sun.misc.Cleaner 到 JDK 9 起的 java.lang.ref.Cleaner,底层都是往缓冲区上挂一个虚引用,缓冲区不可达被回收时触发清理逻辑去 free 那块堆外内存。这也是为什么说堆外内存的回收时机是「跟着壳对象的 GC 走」,虚引用就是那根把两者绑在一起的线。注意这里不需要你手写——Cleaner 已经封装好,Cleaner.create(obj, runnable) 注册一个清理动作即可。

弱引用的实战考点:ThreadLocal 到底怎么泄漏

软引用做缓存是「该不该留」的取舍,弱引用最经典的实战场景则是 ThreadLocal。先看 JDK 26 里 ThreadLocalMap 的 Entry 到底长什么样(javap 反编译):

class java.lang.ThreadLocal$ThreadLocalMap$Entry extends java.lang.ref.WeakReference<java.lang.ThreadLocal<?>>

每个线程自己的 ThreadLocalMap 里,一个条目就是一个 Entry,而 Entry 的key(那个 ThreadLocal 对象)是弱引用。设计意图很清晰:ThreadLocal 对象本身通常挂在某个类的 static 字段上、由类加载器强引用着,这是正常的;可一旦你把它设成 null(或它所在的类被卸载),key 不再有强引用,弱引用的 key 就能被 GC 清掉,避免「ThreadLocal 对象自己泄漏在 ThreadLocalMap 里」。

那为什么网上人人喊 ThreadLocal 会内存泄漏?因为泄漏的根本不在 key,在 value。看结构: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,过期条目就一直在)。
  • 正确姿势:用完 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、只能配合队列在对象回收后收到通知,是堆外内存/资源的回收钩子,DirectByteBuffer 的 Cleaner 就是它的落地。判断该用哪种,看对象该在哪种情况下死:内存不够时死 → 软,下次 GC 就死 → 弱,死了要通知 → 虚,都不满足就是强。

本文关键词:JVM、四种引用、软引用缓存、ThreadLocal、ReferenceQueue

核心收获一句话:四种引用其实是同一条规则的四档强度——软引用等内存不够再牺牲、弱引用下次 GC 就清、虚引用只负责死后通知,选型看「它该在哪种情况下死」;ThreadLocal 泄漏的真相是弱引用 key 管不住强引用 value,记得用完 remove。下一步可以把项目里「长期不清理的 Map 缓存」翻出来:能重建就换软引用、key 语义弱就换 WeakHashMap,并顺手 check 一遍所有 ThreadLocal 有没有在 finally 里 remove。

附:实验代码

弱/软/虚三个最小实验合在一个类里(JDK 26 直接跑):

import java.lang.ref.*;

/** 四种引用实测:弱引用回收+队列、软引用内存足不清、虚引用死后通知。JDK 26。 */
public class ReferenceLab {
  public static void main(String[] x) {
    ReferenceQueue<Object> q = new ReferenceQueue<>();
    Object obj = new Object();
    WeakReference<Object> wr = new WeakReference<>(obj, q);
    System.out.println("weak 建好后 get()=" + (wr.get() != null));
    obj = null; forceGc(3);
    System.out.println("weak 断强引用+System.gc 后 get()=" + wr.get() + ",队列取到=" + (q.poll() == wr));

    obj = new byte[2 * 1024 * 1024];
    SoftReference<byte[]> sr = new SoftReference<>((byte[]) obj);
    forceGc(3);
    System.out.println("soft 内存充足 System.gc 后 get()=" + (sr.get() != null));

    q = new ReferenceQueue<>();
    obj = new Object();
    PhantomReference<Object> pr = new PhantomReference<>(obj, q);
    System.out.println("phantom 建好后 get()=" + pr.get());
    obj = null; forceGc(5);
    System.out.println("phantom 断强引用+System.gc 后队列取到=" + (q.poll() == pr));
  }

  static void forceGc(int times) {
    for (int i = 0; i < times; i++) { System.gc(); try { Thread.sleep(20); } catch (InterruptedException e) {} }
  }
}

软/强压力对照(JDK 26,堆固定 -Xmx64m -XX:G1HeapRegionSize=4m):

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((soft ? "soft" : "strong") + ":先占 16MB 再要 40MB -> " + r);
    if (soft) System.out.println("  16 个软引用被清掉 " + cleared + " 个");
    else System.out.println("  16 个强引用数组原样存活,一个都清不掉");
  }
}
javac -encoding UTF-8 ReferenceLab.java && java ReferenceLab
javac -encoding UTF-8 SoftVsStrong.java
java -Xmx64m -XX:G1HeapRegionSize=4m SoftVsStrong soft     # 40MB 成功、软引用全清
java -Xmx64m -XX:G1HeapRegionSize=4m SoftVsStrong strong   # OutOfMemoryError

软引用那轮的成功不是必然——如果 JVM 判断清了缓存也腾不出 40MB,照样 OOM。它给你的是「先牺牲缓存」的机会,不是免死金牌,正文把它说成「台阶」就是这个意思。