2026/10/9 4:06:02

Java软引用详解:创建、回收时机与缓存应用实践

Java软引用详解:创建、回收时机与缓存应用实践 先从一个线上事故说起。去年我维护的一个老项目功能很简单就是把运营上传的Excel报表解析后缓存起来给后续查询用。当时图省事直接用一个static MapString, Object强引用挂着结果每次运营批量上传几百个文件老报表还没被查询完JVM 的堆内存就肉眼可见地飙了上去最后直接 OOM。后来排查的时候发现问题的根源不是报表文件太大而是我根本没用软引用SoftReference。软引用这个知识点Java 基础面试题里几乎必考软考Java也喜欢出但很多同学对它的理解就停留在“内存不足时会被回收”这一句概念上。真到用的时候怎么创建、怎么配合队列、回收时机到底怎么触发、JVM 内部是怎么处理的全是模糊的。这篇文章我就结合实际排查经验把软引用对象的创建以及对象回收这件事完整拆一遍看完你至少能直接上手改造自己的缓存代码。1. 软引用到底是什么Java 为什么要专门搞一套引用体系1.1 从可达性角度看四种引用Java 里的引用不止强引用一种。JVM 通过“可达性分析”判断对象是否存活而对象是否可达取决于从 GC Roots 出发沿着不同的引用链能否找到它。从强到弱一共有四种引用类型回收时机典型用途实现类强引用永不回收除非不可达日常new出来的对象无软引用内存不足时回收缓存、大对象SoftReference弱引用下一次 GC 就会被回收缓存、ThreadLocal 等WeakReference虚引用随时可能被回收主要配合队列跟踪销毁管理直接内存、监控对象回收PhantomReference软引用处于一个很微妙的位置它比强引用“脆弱”因为当系统内存不够时会优先被清理它又比弱引用“顽强”因为只要内存够GC 不会动它。1.2 软引用解决的核心痛点想象你有一个 App用户每次打开都要加载一张 5MB 的封面大图。如果用强引用缓存同时打开几十篇文章几十个 5MB 的对象全堆在内存里低端手机直接崩如果不用缓存每次打开都重新读磁盘、解码页面卡顿到没法用。软引用就是为这种“内存敏感型缓存”设计的缓存对象还在但内存吃紧时JVM 会先把这些“占着坑又不常用”的软引用对象回收掉保证核心业务不 OOM。用一句话总结就是用空间换时间但要给空间设一条底线。这个概念其实很像现实中的清仓策略。仓库里存着一批配件正常时期保留着随取随用一旦仓库容量告急最先被扔掉的不是正在生产线上急需的零件而是那些囤了很久、最近没人领用的库存。1.3 面试题里常出现的语境在面试和软考题里关于软引用最常见的考察方式是让你对比强引用和软引用的对象创建方式、回收机制或者让你设计一个图片缓存方案。比如这样一道题请说明SoftReferenceString的创建过程以及当System.gc()被调用时被软引用包裹的对象是否一定会被回收这道题的陷阱就在“是否一定”四个字上。很多人想当然地认为GC 就会回收软引用对象。实际上软引用对象的回收取决于当前堆内存是否达到临界值以及被引用对象距上次访问的时间跨度。这个细节后面会展开。2. 软引用对象的创建两种构造方式与正确姿势2.1 基本创建方式软引用对象的创建并不复杂核心类就是java.lang.ref.SoftReference。它提供了两个构造方法// 方法一只传入实际对象 SoftReferenceHeavyObject ref new SoftReference(new HeavyObject()); // 方法二传入实际对象 引用队列 ReferenceQueueHeavyObject queue new ReferenceQueue(); SoftReferenceHeavyObject refWithQueue new SoftReference(new HeavyObject(), queue);第一个构造方法好理解就是用一个软引用把真实对象包起来。第二个构造方法多了ReferenceQueue这个队列的作用是当软引用包裹的对象被 JVM 回收后SoftReference对象本身会被自动放入队列。你可以通过轮询这个队列及时清理缓存表里对应的条目避免缓存 Map 越积越大。实际开发中我强烈建议使用第二种方式。因为如果你用了MapString, SoftReferenceHeavyObject这种结构对象被 GC 回收后Map 里的 key 和软引用对象仍然占着内存。如果不通过队列做二次清理相当于缓存层本身也在泄漏只是慢一点而已。2.2 创建后必须做的一件事断掉强引用这是新手最容易犯的错误。看下面这段代码HeavyObject obj new HeavyObject(); SoftReferenceHeavyObject softRef new SoftReference(obj); // 此时 obj 仍然是强引用这里的obj变量仍然强引用着HeavyObject实例软引用形同虚设GC 永远不会回收这个对象。必须手动把强引用置为nullHeavyObject obj new HeavyObject(); SoftReferenceHeavyObject softRef new SoftReference(obj); obj null; // 关键一步让对象只保留软引用只有当原对象只剩下软引用或者完全不可达时它才处于“软可达”状态JVM 才允许在内存不足时回收它。这个细节很多有工作经验的老开发都会踩尤其是从 C 转过来的同学总觉得引用计数会自动处理Java 里可没这回事。2.3 get() 方法的语义创建完软引用后想拿到原来的对象调用get()方法HeavyObject cached softRef.get(); if (cached ! null) { // 对象还在直接使用 } else { // 对象已被回收需要重新加载 }get()返回null就说明底层对象已经被回收了这时需要重新构建对象并重新放入软引用。这一判空逻辑是缓存的命中率核心所在。有一点必须注意当你拿到非空对象后如果把它赋值给一个强引用变量并长期持有相当于又把它“变回”了强引用对象。此时 GC 同样不会回收它软引用缓存的意义就丢失了。正确的做法是取出来用完就丢局部作用域迅速结束不要让变量活的太长。3. 软引用对象什么时候被回收触发时机与 JVM 参数3.1 回收的核心触发条件前面说过软引用对象在内存充足时不回收。那具体“内存不充足”是什么意思HotSpot 的实现里有一套基于SoftRefLRUPolicyMSPerMB参数的策略。简单说JVM 在 GC 时会评估两个因素当前堆内存是否快要耗尽软引用对象自从上次被访问到现在经过了多长时间。如果堆内存经过一次 GC 后仍然处于紧张状态JVM 会比较激进把大部分软引用对象直接清理掉如果堆内存还够用JVM 会计算每个软引用对象对应的“空闲允许时间”超过这个时间窗口的对象会被清理而最近刚被访问过的对象会保留。这就像清理手机相册手机存储快满的时候系统会提示删掉几年前的视频如果存储还够系统一般不会主动删你最近刚拍的照片。3.2 关键配置SoftRefLRUPolicyMSPerMB 详解HotSpot 提供了一个 JVM 参数来控制软引用的回收激进程度-XX:SoftRefLRUPolicyMSPerMBN这个参数的含义是堆中每 1MB 空间允许软引用对象存活的时间单位是毫秒。默认值是1000。举个例子假设 JVM 堆大小为 1GB1024MB那么软引用对象距离上次访问时间如果超过1000ms * 1024 1024000ms也就是大约 17 分钟就会在 GC 时被清理。堆越大这个绝对时间窗口就越长这也是为什么大堆环境下软引用对象往往能存活很久。如果你希望软引用对象在压力大时被更快回收可以调小这个值比如-XX:SoftRefLRUPolicyMSPerMB512如果你希望软引用对象尽量多存活一段时间可以把值调大-XX:SoftRefLRUPolicyMSPerMB2000但这里有个矛盾点调得太大内存压力下回收线程需要遍历更多软引用做判断GC 耗时上升调得太小缓存命中率下降业务上需要频繁重建对象。我个人的经验是除非你做了软引用相关压测否则保持默认 1000 就好不要为了优化而去动它收益很小风险很高。3.3 跟 System.gc() 的关系很多人误以为手动调用System.gc()会立刻回收软引用对象。实际上System.gc()只是建议 JVM 执行一次 Full GC是否真正执行、执行时是否回收软引用完全由当前内存阈值决定。在内存充足的情况下调用System.gc()后软引用对象依然被保留只有在内存不足或 JVM 主动清理时它们才会被回收。这也是软引用和弱引用最本质的行为差异。弱引用在下一次 GC 时基本必死不管内存够不够软引用则先看内存压力再看 LRU 时间窗口。所以通常可以把软引用理解为“可降级的缓存”弱引用则更倾向于“临时索引”。3.4 不同垃圾收集器下的表现差异软引用的回收策略在不同 GC 实现下也有细微差别。比如CMS处理软引用时会遵循 LRU 时间策略同时考虑堆内存剩余情况。G1行为类似 CMS但在并发标记阶段会额外跟踪所有软引用。ZGC / Shenandoah在内存回收阶段对软引用的处理更加高效低延迟优先。生产环境现在已经很少看到纯 CMS 了大部分要么是 G1要么是带 ZGC 的现代 JDK。G1 下如果你的堆内存设置太小软引用被清理的频率会很高缓存命中率会受影响。最好通过-Xlog:gcref*JDK 11看看软引用实际的清理频率。4. 软引用对象回收的完整链路从 JVM 内部到业务代码4.1 引用对象在 JVM 中的特殊处理很多人以为软引用只是包了一层GC 的时候判断一下“内存够不够”就行。实际上HotSpot 内部对Reference对象做了非常特殊的处理专门要保证对象被回收后引用链还能正确断掉。JVM 在标记阶段会区分普通的强引用和SoftReference、WeakReference等特殊引用。对应软引用的话JVM 会尝试将软引用当成一个普通 GC Roots 节点来遍历。但如果不希望它继续持有引用的对象就会将其内部指向对象的指针清空cleared。这个清空动作并不是 GC 线程当场执行的而是先把引用对象放入一个全局的pending链表等待一个专门的线程去处理。这一步设计得挺巧妙。如果 GC 线程自己去操作业务引用对象会引入大量同步开销还可能跟业务线程的get()冲突。不如在安全点把pending链表挂好剩下的事交给后台线程慢慢做。4.2 ReferenceHandler 线程与 pending 队列JVM 启动时会创建一个名为Reference Handler的高优先级守护线程。它做的事情非常固定从全局pending链表中取走一批被 GC“标记为可清理”的引用对象根据引用对象的类型决定是否把它放入对应的ReferenceQueue如果创建软引用时传入了队列这个引用最终会被追加到队列尾部如果引用对象属于PhantomReference它还会触发后续的清理逻辑。对于软引用队列通知的意义在于业务代码不用反复遍历缓存 Map 判断每个get()是否为 null只要你定期从ReferenceQueue里取出这些软引用对象再根据软引用对象上携带的信息比如通过继承SoftReference加上一个 key 字段去缓存 Map 里剔除对应的条目。4.3 一个实用的队列消费示例既然讲到了队列我贴一段我项目里实际用过的清理代码public class SoftCache { private final MapString, SoftReferenceHeavyObject cache new ConcurrentHashMap(); private final ReferenceQueueHeavyObject queue new ReferenceQueue(); public void put(String key, HeavyObject value) { SoftReferenceHeavyObject ref new SoftReference(value, queue); cache.put(key, ref); } public HeavyObject get(String key) { SoftReferenceHeavyObject ref cache.get(key); return ref null ? null : ref.get(); } public void cleanUp() { SoftReferenceHeavyObject ref; while ((ref (SoftReferenceHeavyObject) queue.poll()) ! null) { // 通过遍历找到这个ref在map里的key然后删除 cache.entrySet().removeIf(entry - entry.getValue() ref); } } }这里有几个值得说的点queue.poll()是异步队列中取出已回收的引用不阻塞。判断entry.getValue() ref时用引用比较而不是equals因为这里要精确找到同一个软引用对象。removeIf在线程安全方面要看 map 的实现如果是ConcurrentHashMap高并发下可能需要上锁或者改用迭代器模式。实际更优雅的做法是继承SoftReference并额外存一个key字段这样从队列里拿到引用时直接就能知道该删除哪个 key不用全表扫描。4.4 get() 方法底层为什么要判空SoftReference.get()之所以要判空是因为 JVM 在回收底层对象时会直接将软引用内部的 referent 字段置为null。你再去调用get()时拿到的就是null。这里有个非常隐蔽的坑referent 字段被清空后软引用对象本身并不被回收。它依然存在于你的缓存 Map 里占着一个 key 和一个引用对象的内存。如果 Map 一直不清除这些废掉的软引用对象最终会导致 Map 无限膨胀表现为“软引用对象没有泄漏但缓存 Map 泄漏了”。这也是为什么必须配合 ReferenceQueue 做清理。5. 软引用的试金石经典应用场景与实战案例5.1 图片与大对象缓存最经典的场景就是图片缓存。Android 早期官方文档推荐用软引用保存大的 Bitmap后来因为回收时机不可控才转向 LRU 强引用。但是服务端场景里软引用依然很好用。比如你有一个报表导出功能用户导出的 Excel 对象很大几 MB 到几十 MB如果把历史上所有导出文件都强引用缓存基本扛不住。这时可以把每个待下载的文件对象用软引用缓存内存充足时秒开内存紧张时文件自动被回收用户重新点击时再重新生成一份即可。ConcurrentHashMapString, SoftReferenceFileContent fileCache new ConcurrentHashMap();5.2 第三方缓存框架中的 softValues现在很多缓存框架已经内置了软引用支持不需要自己造轮子。比如Caffeine.softValues()表示缓存值用软引用持有GuavaCacheBuilder.softValues()同样支持MyBatis 二级缓存可以通过配置evictionSOFT让缓存使用软引用Spring 自带的SoftReferenceMap在一些内部场景使用。拿 Caffeine 举例CacheString, HeavyObject cache Caffeine.newBuilder() .maximumSize(1000) .softValues() .build();配置了软引用后Caffeine 会额外维护 size 统计当软引用对象被 GC 回收时也能自动从缓存中移除。这类框架比自己实现的ConcurrentHashMap SoftReference要靠谱得多因为它们已经在处理并发、过期、清理策略这些细节了。5.3 哪些场景不适合用软引用软引用不是万能钥匙下面这些场景要慎用或避开高频小对象缓存软引用对象的创建和管理有一定开销如果缓存命中率极高、对象本身又很小直接用强引用哈希表更好省去get()判空和重建的额外消耗。需要确定性清理的业务比如订单会话、事务状态这些必须强引用绝不能让 JVM 在内存紧张时悄悄回收掉。可预测且生命周期明确的场景既然生命周期明确直接用显式移除优化即可软引用反而让行为不可控。内存压力很大的容器部署堆本身很小软引用缓存形同虚设每次 GC 都清空缓存命中率几乎为 0。这种环境应该考虑外部缓存Redis、本地文件而不是软引用。当时我在报表项目里还发现一个有意思的现象配置了软引用之后高峰期 Full GC 次数并没有减少反而有所增加——因为这些被软引用包裹的大对象仍然需要 GC 扫描和最终回收没有从根本上降低对象分配频率。后来我把短期热点数据拆出来用强引用缓存长期冷数据走软引用才真正把 GC 压力降下来。6. 常见问题排查与软引用使用避坑清单6.1 Java 面试和软考题里的高频问题我把这几个月我们团队面试候选人时最爱问的几个软引用问题整理了一下问题关键答案软引用和弱引用回收时机的区别软引用在内存不足时才回收弱引用在下一次 GC 时回收创建软引用时为什么要传 ReferenceQueue对象被回收后引用对象本身可被追踪清理避免缓存 Map 泄漏软引用对象被回收后get() 返回值是什么返回 null需要用这个信号重建缓存软引用是否一定能避免 OOM不能。软引用只回收 Java 堆内存中可达的对象如果 OOM 来自堆外内存或者引用对象本身太多OOM 照样发生设计图片缓存用什么数据结构常见回答 LRU 软引用/弱引用组合或纯粹的 LRU 强引用缓存其中“软引用是否一定能避免 OOM”这道题很多人只答“能”其实不全面。软引用保证的是在 GC 过程中优先清掉软引用占用的堆空间。但如果堆的总容量不够已经没有软引用可以清理或者 OOM 是线程创建、元空间耗尽、堆外内存溢出引起的软引用就无能为力了。所以软引用只能缓解部分 OOM不能当作“防止 OOM 的银弹”。6.2 我实际踩过的几个坑第一个坑是把软引用对象本身存进了缓存 Map但又用强引用持有实际对象。典型的错误代码是这样的SoftReferenceHeavyObject ref new SoftReference(obj); map.put(key, ref); // 后面 else 分支里又用了 obj return obj;这种逻辑在本地测试时一切正常因为堆还够用一上生产内存稍微吃紧对象就被回收了但代码还在用强引用obj返回数据。虽然 StrongReference 让对象又活了但缓存里靠软引用控制内存的初衷被破坏堆内存不减反增。第二个坑是软引用对象本身过多。设想一个场景缓存了 10 万个小型对象每个对象对应一个软引用。此时即使底层对象被回收了10 万个软引用对象仍然散落在堆里每个大约有 40 字节左右的开销不算大但也会占用几 MB 的内存。这就在提醒我们软引用不能解决“无限缓存 key”的问题它只解决“key 和 value 什么时候被清理”的问题。缓存条目的总量仍然需要外部控制。第三个坑是关于 GC log 的误读。之前排查一个老项目发现 GC 日志里频繁出现SoftReference相关内容有人就怀疑是软引用缓存设置不合理。后来越查越发现真正的问题是有个静态 Map 永远不会清理 key导致软引用对象越积越多。把清理逻辑补齐之后相关日志立刻下降。6.3 项目中如果要上软引用我建议的做法首先从规模上控制软引用出现的层级。不要整个系统到处都是new SoftReference()最好只出现在缓存模块内部。业务层永远只跟业务对象打交道不感知软引用这样后续替换成 Caffeine 或 Redis 也方便。其次一定要有监控。软引用缓存最怕的是“悄无声息地命中率下降”。我给缓存模块加了一个计数器记录get()返回 null 的次数并暴露成 Metric。如果发现返回 null 的频率突然上升多半是堆配置调整或者其他大对象占用了大量内存导致软引用被频繁清理就要及时分析堆转储。最后配合 ReferenceQueue 做清理的代码一定要在put操作时顺手触发一次cleanUp()而不是单独开一个定时线程去扫。因为put()本身就是高频操作趁这个时机做一轮队列清理对性能影响小也不用增加额外调度线程。我自己的写法是每次 put 最多清理 100 个引用对象避免一次性清理太多拖慢当前线程。6.4 软引用、弱引用、虚引用的选择心得做个简单的决策判断对象很重重建成本高但内存不够时可以丢弃选软引用对象是辅助性的、映射关系的临时索引GC 后自动清理也没什么选弱引用只想在对象被 GC 后收到通知不想保留对象的任何访问路径选虚引用配合ReferenceQueue使用。很多缓存框架的softValues()和weakValues()也对应着这两种策略。例如 Guava Cache 里的weakValues()对 key 使用弱引用value 也可以配softValues()实际调优时通常不会刻意去调SoftRefLRUPolicyMSPerMB而是先看 GC 日志、堆使用曲线和命中率再决定是换更大堆、换外部缓存还是把软引用改弱引用。我个人在实际项目中的体会是软引用的创建代码写起来很简单难的是理解它背后的两套时间尺度——业务层的缓存时间和 JVM 层的内存时间。对象创建时你决定的是“何时能被回收”而 JVM 决定的是“什么时候真的回收”。只有把这两套机制对齐软引用才能真正发挥缓存作用。如果你也在用软引用做缓存我建议你先把 ReferenceQueue 的清理逻辑补上再观察一个完整 GC 周期里的命中率变化这个数据会告诉你值不值得继续用下去。