Skip to content

强引用/软引用/弱引用/虚引用,你真的用对了吗?

关键词:Java 引用类型、GC、ReferenceQueue、缓存设计、内存泄漏

问题

Java 的四种引用类型——强引用、软引用、弱引用、虚引用——分别是什么?它们对 GC 行为有什么影响?各自在实际项目中有哪些典型应用场景?面试官问「ThreadLocal 为什么用弱引用作为 key」时,你要怎么回答?

很多开发者写了几年 Java,对引用的理解还停留在「强引用就是 new 对象,其他几种面试背一下就行」。但真正到了线上排查内存泄漏、设计缓存系统、或者写 Netty 的堆外内存管理时,对这些引用的理解深度直接决定了你能否快速定位问题。

四种引用强度递减

Java 从 JDK 1.2 开始提供了四种引用级别,强度从高到低排列:

1. 强引用(Strong Reference)

最常见的引用方式:Object obj = new Object()。只要强引用还存在,GC 永远不会回收被引用的对象,哪怕抛出 OutOfMemoryError

java
// 强引用:JVM 宁可 OOM 也不回收
List<byte[]> cache = new ArrayList<>();
while (true) {
    cache.add(new byte[1024 * 1024]); // 堆满后报 OOM,不会自动回收
}

生产血泪史:某团队在 Spring Bean 中把 Map 声明为 static final,往里面塞了上千万个用户 Session 对象,以为 GC 会自动回收。结果 CMS GC 的 concurrent mode failure 天天触发,每次 FGC 耗时 10+ 秒,接口大面积超时。最后用 MAT 分析才找到这个静态 Map。强引用在「你以为它会回收」的时候最危险。

2. 软引用(SoftReference)

内存充足时不回收,内存不足时(即将 OOM 前)GC 会回收软引用指向的对象。适合做内存敏感缓存——比如图片缓存、大对象缓存。

java
// 软引用实现内存缓存
public class SoftCache<K, V> {
    private final Map<K, SoftReference<V>> cache = new ConcurrentHashMap<>();

    public void put(K key, V value) {
        cache.put(key, new SoftReference<>(value));
    }

    public 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); // 已被 GC 回收,移除失效条目
        }
        return value;
    }
}

3. 弱引用(WeakReference)

下一次 GC 时无论内存是否充足,只要对象只有弱引用指向它,就会被回收。最经典的场景是 ThreadLocalMap 的 key。

java
// 弱引用:GC 一次就回收
WeakReference<Object> ref = new WeakReference<>(new Object());
System.out.println(ref.get()); // 有值
System.gc();
System.out.println(ref.get()); // null(已被回收)

// ThreadLocalMap 的 Entry 本质就是 WeakReference<ThreadLocal>
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

4. 虚引用(PhantomReference)

最弱的引用,不能通过 get() 获取对象(始终返回 null)。唯一作用是在对象被回收时收到一个系统通知,用于堆外内存的回收

java
// 虚引用:不能 get 到对象,只在回收时通知
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> ref = new PhantomReference<>(new Object(), queue);
System.out.println(ref.get()); // 始终 null

// 当 Object 被回收后,ref 会被放入 queue
// Cleaner(DirectByteBuffer 的清理器)就是基于这个机制

引用队列(ReferenceQueue)的作用

软引用、弱引用、虚引用都可以关联一个 ReferenceQueue。当引用对象被回收后,对应的 Reference 对象会被 JVM 入队到 ReferenceQueue 中,应用线程可以轮询队列做后续清理。

java
// ReferenceQueue 配合使用示例
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();
WeakReference<byte[]> ref = new WeakReference<>(new byte[1024], queue);

// 等 GC 回收后,从队列中取出被回收的引用做清理
new Thread(() -> {
    try {
        Reference<? extends byte[]> removed = queue.remove();
        System.out.println("引用被回收了,可以做清理:" + removed);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}).start();

JVM 内部:引用对象的处理流程

理解 JVM 怎么处理这四种引用,比背 API 重要得多:

流程时序图(文字描述):

1. 用户线程创建 Reference 对象,关联 ReferenceQueue(可选)
2. JVM 在 GC 标记阶段,遍历 GC Roots 的可达性分析
3. 根据引用强度分层处理:
   - 强引用可达 → 不回收
   - 软引用可达,但内存不足 → 加入 ReferenceHandler 的 pending 链表
   - 弱引用可达(无强引用链)→ 加入 ReferenceHandler 的 pending 链表
   - 虚引用可达 → 对象即使 reachable 也被加入 pending 链表
4. ReferenceHandler 守护线程(优先级最高)从 pending 链表取出 Reference
5. 如果有 ReferenceQueue,将 Reference 入队;Cleaner 则直接执行 clean()
6. 应用线程或 Finalizer 线程从 ReferenceQueue 中 poll()/remove() 做后续处理

关键点:ReferenceHandler 是 JVM 内部的高优先级守护线程,它在 Reference.java 的静态初始块中启动。Pending 链表是由 GC 线程在标记阶段填充的,ReferenceHandler 持续消费这个链表。

深度分析:那些容易踩的坑

软引用的回收时机没那么简单

很多人以为软引用就是「OOM 前一刻回收」,实际上 JVM 有更复杂的判定公式:

heap_free >= (max_heap - last_used) * soft_ref_ratio_policy

-XX:SoftRefLRUPolicyMSPerMB=1000(默认值)意味着:最近 1 秒内被访问过的软引用对象会保留,超出这个时间窗口且堆空间紧张时才会回收。这个参数在高并发缓存场景中非常关键——如果调得太小,缓存命中率会急剧下降;调得太大,缓存几乎不回收,跟强引用没区别。

实战案例:某电商平台的商品详情缓存,用 SoftReference 包装商品 POJO 对象,QPS 5000+。默认 SoftRefLRUPolicyMSPerMB=1000 时,高峰期 GC 频繁回收软引用,导致大量请求穿透到 DB,DB 连接池被打满。调整为 -XX:SoftRefLRUPolicyMSPerMB=10000(10 秒)后,缓存命中率从 60% 回升到 92%,DB 压力骤降。

不同 GC 对软引用的处理差异

GC 收集器对软引用的处理实际影响
Serial / ParallelFull GC 时检查并清理回收集中,吞吐高但延迟大
CMS并发标记阶段清理,但 Old Gen 空间不足时触发 Full GC 回收软引用+大堆容易 concurrent mode failure
G1在 Mixed GC 的标记阶段处理,按 Region 粒度回收更平滑,但触发阈值可配置
ZGC并发处理,几乎不暂停软引用在 ZGC 下存活时间更长

ThreadLocal 内存泄漏的根源

这是面试高频题,也是实际线上最容易踩的坑。ThreadLocalMap 的 Entry 用 WeakReference<ThreadLocal> 作为 key,目的是防止 ThreadLocal 对象本身无法被回收。

但问题不在 key,在 value

java
// 典型的内存泄漏场景
ThreadLocal<byte[]> local = new ThreadLocal<>();
local.set(new byte[1024 * 1024]); // value 是强引用

// 用户线程结束后,ThreadLocal 的弱引用被回收
// 但 Entry 中的 value 是强引用,不手动 remove() 就不会释放
// 只要线程还活着(比如线程池中的线程),value 就一直存在

生产血泪史:某公司用 Spring 的 RequestContextHolder(内部用 ThreadLocal)+ 线程池处理请求,每个请求往 ThreadLocal 里存了一个 4MB 的 JSON 对象。线上 200 个 Tomcat 线程,每个都 leak 4MB,堆内存 2GB 的机器,FGC 每 5 分钟一次,每次 3 秒以上。根源就是没有在 finally 里 remove()

解决方案只有一条:用完必须 remove()。在 try-finally 中包起来:

java
ThreadLocal<byte[]> local = new ThreadLocal<>();
try {
    local.set(new byte[1024 * 1024]);
    // 业务逻辑...
} finally {
    local.remove(); // 必须清理,否则内存泄漏
}

面试追问:为什么 JDK 不把 Entry 的 value 也设计成弱引用?——如果 value 也是弱引用,那 ThreadLocal.get() 可能随时返回 null,业务代码就不可靠了。所以设计上就是 key 弱引用防止 ThreadLocal 本身泄漏,value 强引用要求开发者手动管理生命周期。

DirectByteBuffer 的虚引用回收

Netty 大量使用堆外内存(Direct Memory),如果 DirectByteBuffer 对象被 GC 回收,但其引用的堆外内存没有被释放,就会造成堆外内存泄漏。

JDK 的设计方案是:DirectByteBuffer 关联一个 Cleaner(虚引用子类),当 DirectByteBuffer 对象被回收时,Cleaner 通过 ReferenceQueueReferenceHandler 线程取出,并执行清理任务释放堆外内存。

java
// DirectByteBuffer 的构造(简化版)
DirectByteBuffer(int cap) {
    super(-1, ...); // 空分配
    long base = unsafe.allocateMemory(cap); // 分配堆外内存
    // 关联一个 Cleaner,当 DirectByteBuffer 被回收时清理堆外内存
    cleaner = Cleaner.create(this, new Deallocator(base, cap, ...));
}

生产血泪史:Netty 应用频繁出现 OutOfMemoryError: Direct buffer memory,但堆内存正常。排查发现:业务代码中每次请求都 new 了一个 ByteBuf,但忘记 release(),导致 DirectByteBuffer 对象积压在堆中无法被 GC 回收(因为强引用链还在),Cleaner 永远不会触发,堆外内存持续增长直到耗尽。用 -XX:MaxDirectMemorySize=512m 配合 -XX:+PrintGCDetails 和 NMT(Native Memory Tracking)才定位到问题。

堆外内存排查步骤

  1. -XX:NativeMemoryTracking=summary 启动
  2. jcmd <pid> VM.native_memory summary 查看 DirectBuffer 用量
  3. 如果 DirectBuffer 持续增长但堆内 DBB 对象数正常,说明 Cleaner 无法触发(DBB 对象被强引用链挂着)
  4. -Djdk.nio.maxCachedBufferSize=262144 限制池化缓冲区大小

缓存框架的引用策略对比

如果面试官问你「为什么不用裸 WeakReference 做缓存」,你要能说出这些:

缓存框架引用策略内存淘汰机制生产推荐度
裸 WeakReference弱引用无,依赖 GC 时机❌ 不推荐
裸 SoftReference软引用依赖 GC + SoftRefLRUPolicy⚠️ 勉强可用
Guava Cache softValues软引用软引用 + LRU 访问顺序✅ 推荐单机
Caffeine weakKeys/weakValues/softValues弱/软引用全量 Window-TinyLFU 淘汰✅ 推荐单机
Redis / 分布式缓存精确过期+LRU/LFU✅ 分布式中推荐

为什么 Caffeine 比裸 SoftReference 强:Caffeine 的 softValues() 策略在软引用之上叠加了 Window TinyLFU 淘汰算法——不只是依赖 GC 回收,还会主动按访问频率淘汰低频条目。这意味着即使软引用对象还没被 GC 回收,低频条目也会被主动清除,避免缓存膨胀后 GC 频繁触发。

总结

引用类型回收时机典型场景注意事项
强引用永不回收(除非引用断开)new 对象、常规变量注意静态集合的隐性泄漏
软引用内存不足时回收内存敏感缓存回收时机受 SoftRefLRUPolicyMSPerMB 控制;不同 GC 行为不同
弱引用下一次 GC 就回收ThreadLocalMap key记得 remove() 避免 value 泄漏
虚引用对象回收时通知DirectByteBuffer 清理不能 get 到对象,只能做通知;排查堆外内存泄漏的关键

参考资料

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。