JVM引用类型与缓存丢失:软引用、弱引用如何导致数据凭空消失?
2026/9/16 0:35:37 网站建设 项目流程

你有没有遇到过这种情况:代码里明明还拿着某个缓存对象的引用,日志也能打出来对象还在,可一到内存紧张的时候,缓存里的值就跟商量好了似的“集体蒸发”了。第一次碰上这事,我还以为是GC出了什么问题,后来仔细查了一圈,才发现问题不在GC,而在我们对“引用”这两个字的理解上。

这事放在JVM里尤其典型。平时咱们说的“有引用就不会被回收”其实只说对了一半,因为在JVM的内存模型里,引用不是只有一种。强引用确实不会随便被回收,但还有软引用、弱引用、虚引用,它们的行为完全不同。很多缓存框架,尤其是本地缓存,底层用的是弱引用或软引用,目的就是让JVM在内存紧张时能“腾地方”。结果就是:引用对象还在,引用指向的内容却没了。

这篇文章就借这个现象,把JVM引用类型、内存回收机制、缓存框架背后的设计逻辑,还有排查这类问题的思路一次讲清楚。做Java后端、写中间件、或者自己搭本地缓存的同学,看完应该能少踩不少坑。

1. 先搞懂JVM里“引用”到底怎么算

1.1 强引用:你以为的“不会被回收”绝大多数是指它

先从最熟悉的强引用说起。咱们平时写Object obj = new Object(),这个obj就是一个强引用。只要这个强引用还存在,并且从GC Roots出发是可达的,那么JVM就算内存真的要爆了,也不会去回收这个对象。这一点是JVM内存回收规则的底线。

之前遇到过一个小伙伴特别困惑:他的缓存是放在一个static Map里的,key和value都是强引用,结果内存紧张时缓存里的数据照样丢了。后来一查,根本不是GC干的,而是他用了类似Caffeine的缓存框架,框架默认配置了最大容量和过期时间,到了上限就被主动清理了。强引用本身不会让对象“凭空消失”,但缓存框架可以用“清除强引用”的方式来腾内存。

强引用的好处是稳定,坏处也很明显:如果一直不释放,JVM堆内存会被慢慢吃满,最终触发OutOfMemoryError。很多线上OOM事故,追根溯源就是缓存对象被强引用死死拽住,GC只能眼睁睁看着却回收不了。所以强引用适合“必须一直存在”的数据,但绝对不适合做无上限的缓存存储。

要判断一个对象会不会被回收,光看有没有引用还不够,得看引用类型和可达性。JVM的GC Roots通常包括线程栈上的局部变量、静态变量、JNI引用等,如果从这些根出发能到达某个对象,这个对象就是可达的。强引用可达到的对象,GC永远不动它;弱引用可达到的对象,GC下一次执行就回收。

1.2 软引用、弱引用、虚引用:三种“随时可能消失”的引用

它们仨才是本文的主角。为了让你快速建立认知,先上一个对比表:

引用类型回收时机典型场景对应类
强引用只要可达,永远不会回收普通对象、核心业务数据默认引用
软引用内存不足时,OOM之前可能回收大对象缓存、图片缓存SoftReference<T>
弱引用下一次GC时,只要对象只被弱引用可达就回收临时映射、缓存辅助数据WeakReference<T>
虚引用对象被回收时收到通知,主要用于跟踪对象回收堆外内存回收、对象生命周期监控PhantomReference<T>

这里面最常见、也最容易让人栽跟头的是软引用和弱引用。简单类比一下:强引用像一份永久合同,只要合同在手,房子不能拆;软引用像政府因紧急项目可以征用的房子,平时没事,真到关键时刻就得让位;弱引用更像便利贴,每天下班保洁阿姨就会把便利贴撕掉,不管你上面写了多重要的提醒。

关键是,软引用和弱引用都有一个共同点:Reference对象本身还是存在的。你拿到的WeakReference对象没有变成null,还能继续调用get(),但get()返回的内容已经是null了。这就是“引用明明还留着,数据却说没就没了”的最直接原因。

虚引用更特殊一些,它的get()永远返回null,它存在的意义只是告诉你“对象已经被回收了”。所以平时做业务开发基本碰不到虚引用,更多是在做JVM监控、堆外内存回收时才会用到。

2. 为什么缓存明明“有引用”还是被回收掉

2.1 真相一:你拿的引用跟容器里的引用强度不一样

很多人查缓存丢失问题时,会习惯性看自己代码里是不是还有变量指向缓存对象。比如从缓存里取了一个对象,存在局部变量里,然后发现对象没了,第一反应是“不可能啊,我这里明明还引用着它”。

这里恰恰有个盲区:你引用的对象,和缓存容器底层持有的引用,根本不是一个强度。

WeakHashMap举例。这个类的Entry继承自WeakReference<Object>,也就是说,它的key是弱引用,而不是强引用。当你把一个key放入WeakHashMap,如果外部没有任何强引用还握着这个key,那么下一次GC时,这个key就会被回收。key没了,对应的Entry就变成了无效Entry,后续在访问Map时会被清理掉。

所以你会看到这样的现象:业务代码里还持有Map的引用,甚至还能打印出Map的size,但里面的数据已经没了。因为你拿的只是Map本身这个对象,而Map内部对key的持有是弱引用,对value的持有虽然是强引用,但key都没了,value也失去了存在的意义,会被一并处理掉。

这个设计不能说错,WeakHashMap本来就不是给业务缓存用的,它的初衷是让需要“额外附属信息”的场景不会拖累内存。比如某个框架要记录每个Class对应的元数据,如果这个Class被卸载了,元数据也应该跟着消失,用WeakHashMap就很合适。但你要是拿它来当业务缓存,那就等于默认接受了“缓存数据随时可能消失”的前提。

2.2 真相二:Key和Value的引用强度不一致,导致“半个身子先没了”

比整个缓存消失更诡异的,是缓存结构还在,但值已经取不出来了。这种情况通常发生在weakKeysweakValues配置上,也就是key和value的引用强度不对称。

举一个所有Java后端都不陌生的例子:ThreadLocalThreadLocalMap里的Entry继承自WeakReference<ThreadLocal<?>>,key是弱引用,value是强引用。线程存活期间,如果外部对ThreadLocal的强引用断掉了,key会被GC回收,但value还被Entry里的强引用握着,这就产生了经典的内存泄漏问题。这也是为什么官方一直建议用完ThreadLocal后要调用remove()

回到缓存场景,假如你用的缓存框架配置了weakKeys,key只有弱引用。此时key被回收后,对应的value即使还是强引用,也取不出来了,因为根本匹配不到原来的key。反过来,如果配置的是weakValues,value被回收后,缓存结构还在,但get()返回的就是null。

这里有个很容易被忽略的细节:当value被回收时,缓存框架不一定立刻清理掉那个Entry,它可能在下一次访问或者后台清理任务时才顺手扫掉。所以你可能看到缓存Map里还有这个key,但取值已经是null了。这就是“缓存结构还在、内容却没了”的典型状态。

2.3 真相三:不是GC动手,是缓存框架自己的驱逐策略

还有一种情况,和JVM引用类型完全没有关系,纯粹是缓存框架在做“主动驱逐”。很多本地缓存框架比如Guava Cache、Caffeine,默认就有容量上限和过期时间,一旦缓存条目数量超过设定值,就会按照LRU、LFU之类的策略淘汰一部分数据。

这类驱逐策略在内存紧张时尤其明显。比如你设置了maximumSize(1000),当缓存数量接近上限,框架就会开始回收;如果内存进一步紧张,回收会更激进。从业务代码的角度看,缓存就是“说没就没了”——但你翻GC日志,可能一次Full GC都没有。

甚至更底层一点,如果缓存的数据被放到堆外内存(比如DirectByteBuffer),那堆内GC根本管不到这块内存。堆外内存的回收依赖于Cleaner机制,这块内存紧张时,清理时机更加难以从堆内GC日志里看出来。排查这类问题,得把监控范围从堆内扩展到物理内存层面。

所以遇到缓存丢失问题,先不要急着怪JVM、怪GC,先确认一下缓存框架有没有设置容量限制、过期策略、weakKeysweakValues这些配置。很多时候,“谁干的”其实就写在配置代码里。

3. 从“感觉”到“定位”:排查缓存丢失的正确姿势

3.1 拿到GC日志,确认到底有没有GC发生

很多线上问题排查第一步就卡在“凭感觉”。感觉内存紧张了,感觉GC频繁了,感觉缓存被回收了。感觉不能用来定位问题,还是得落到数据上。

先把GC日志打开。JDK 8及之前的版本用-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log,JDK 9之后推荐用统一的日志方式:

-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags -Xlog:gc+heap=debug:file=/path/to/gc-heap.log:time,uptime,level,tags

跑上一段时间,等缓存“丢失”问题复现后,再打开日志分析。重点看两个信息:一是GC发生的频率和类型,二是GC前后老年代和年轻代的使用率变化。

如果GC日志里确实有比较频繁的Young GC甚至Old GC,那缓存被回收的可能性就很大;如果GC日志安安静静,几乎没什么GC,但缓存还是没了,那就要把注意力转向缓存框架本身的驱逐逻辑,或者堆外内存。

另外,jstat是个特别实用的轻量级工具,在服务器上直接执行:

jstat -gcutil <pid> 1000 20

每秒输出一次,连续输出20次,能很清楚看到Eden、Survivor、Old区的占比变化,以及YGC、FGC的累计次数和耗时。如果FGC次数在短时间暴涨,那基本可以断定堆内内存压力很大,软引用和弱引用在这个阶段被回收是再正常不过的事。

3.2 用堆Dump和MAT看引用链

GC日志只能告诉你“内存有压力”,但它不能告诉你“某个缓存对象到底是被谁持有着”。要想把这个链条彻底搞清楚,必须做堆Dump分析。

线上环境建议提前设置-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/heap.hprof,这样一旦发生OOM,JVM会自动把堆快照落盘。日常排查也可以手动触发:

jmap -dump:live,format=b,file=heap.hprof <pid>

拿到heap.hprof文件后用MAT打开,在Dominator Tree(支配树)里搜索你的缓存类名,就能看到这个缓存对象在整个堆里的持有关系。重点看两点:一是缓存对象被谁引用,引用路径是否包含WeakReferenceSoftReference;二是缓存对象内部是否有大量已经被GC但还没被清理的entry。

我遇到过一个奇怪现象:缓存类还在,但对象数量对不上。Dump出来以后才发现,原来缓存Map里有一堆key对应的value已经被清掉了,但entry对象本身还留在Map中,导致Map像漏水的桶,看起来有东西,实际取出来全是null。这种“僵尸entry”很消耗内存,也会让缓存命中率报表看起来特别难看。

3.3 一个可以自己复现的“引用还在,数据消失”实验

光说不练容易隔靴搔痒,我来写一个能直接复现的示例,你可以在本地拿小堆跑一遍。

import java.lang.ref.WeakReference; public class WeakReferenceDemo { public static void main(String[] args) throws Exception { byte[] bigData = new byte[8 * 1024 * 1024]; WeakReference<byte[]> ref = new WeakReference<>(bigData); System.out.println("GC前 ref.get() != null: " + (ref.get() != null)); // 断开强引用,让bigData只被弱引用可达 bigData = null; System.gc(); Thread.sleep(1000); System.out.println("GC后 ref.get() != null: " + (ref.get() != null)); System.out.println("ref对象本身是否为null: " + (ref == null)); } }

运行这段代码,输出会非常直白:GC前ref.get()还能拿到对象,GC后返回false,但ref这个对象本身还在。这就是最典型的“引用在,内容没了”场景。

有意思的是,在小堆和大堆下运行结果可能不一样。如果堆空间特别充足,System.gc()也不一定会立刻回收弱引用对象,但实际上只要是弱引用可达的对象,在GC时被回收的概率极高。如果你把WeakReference换成SoftReference,在堆空间充足时,GC后get()可能还能拿到对象,这就是软引用和弱引用在回收时机上的差异。

再看一个WeakHashMap的版本:

import java.util.Map; import java.util.WeakHashMap; public class WeakHashMapDemo { public static void main(String[] args) throws Exception { Map<String, byte[]> cache = new WeakHashMap<>(); String key = new String("user_1001"); cache.put(key, new byte[8 * 1024 * 1024]); System.out.println("GC前 size = " + cache.size()); // 断开key的强引用 key = null; System.gc(); Thread.sleep(1000); System.out.println("GC后 size = " + cache.size()); } }

运行后你会发现,GC后WeakHashMap的size可能变成了0。但如果把强引用保留,GC后size依然是1。这说明同一个Map,同一个key,强引用在不在,结果天差地别。

4. 具体场景:本地缓存到底该怎么选型

4.1 弱引用/软引用适合的缓存场景

现在你已经知道弱引用和软引用会让缓存“说没就没”,那它们是洪水猛兽吗?当然不是。关键看你拿它们做什么。

弱引用适合的场景,是“这个数据丢了我也无所谓,重建成本极低”的辅助数据。比如一个框架想在运行时记录每个线程最近访问过的某些信息,这些信息丢了下次重新记录就行,那就可以用弱引用关联线程对象和附属信息,尽量不阻止线程对象被回收。

软引用适合的场景,是“我希望尽量复用,但实在内存不够也愿意放弃”的大对象缓存。比如图片的原始字节、比较耗时的查询结果,这些对象重建成本高,但又不是绝对不能丢。用软引用可以在内存充裕时提升性能,在内存紧张时避免OOM。

我用过软引用缓存一批从数据库查出来的大JSON字符串,效果在堆内存比较充足时确实不错,命中率高、大对象不会被反复加载。后来把堆内存缩小测试,发现软引用在大压力下也不怎么稳定,GC一频繁,缓存命中率跟着往下掉,系统反而因为反复加载数据导致整体性能下降。

所以我的建议是:软引用、弱引用适合做“辅助加速”,不适合做“业务主链路”的依赖。业务主链路的缓存,得有明确容量上限和过期策略,不能把命运交给GC的心情。

4.2 显式缓存框架才是业务缓存的正确选择

业务缓存的正确做法,是用成熟的缓存框架把缓存行为管起来。比如Caffeine,配置非常灵活,而且性能比Guava Cache好不少,Java后端现在用得很广。

Cache<String, byte[]> cache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(); // 读取缓存,miss则加载 byte[] data = cache.get("key_1", k -> loadFromDB(k));

这类框架最大的好处是“可预期”。容量达到上限,就按LRU/LFU淘汰;超时了,就按时间淘汰。无论内存压不压力,行为都是一致的,不会突然在某个时间点集体消失。

如果你是在Spring环境里,那更方便,直接用@Cacheable注解,底层换CacheManager实现就行。但要注意,Spring Cache默认的实现很多是基于ConcurrentHashMap的,如果没配TTL,缓存会一直膨胀,最终可能变成内存杀手。所以用Spring Cache一定要配置合适的过期策略和容量上限。

有人可能会问,那到底什么时候该用weakValuessoftValues?我的建议是:默认不用,除非你有非常明确的诉求。比如Caffeine配置里,weakValues会在value只有弱引用可达时回收,softValues会在内存不足时回收,但它们都让缓存的行为变得不可预期,命中率也难保证。真到了内存紧张的地步,优先考虑调小maximumSize,或者优化业务逻辑,而不是把缓存做成“随时会丢”的状态。

4.3 缓存分级:可丢、可重建、不可丢

如果不做区分,一股脑把所有数据都往一个缓存里塞,出了问题是早晚的事。比较靠谱的做法是把缓存数据分成几档。

第一档是“不可丢”的缓存,比如系统配置、用户权限这类数据。丢了会影响功能正确性,所以要用强引用,同时配上较长的TTL或者手动刷新机制。这类缓存的容量通常比较小,即使一直保留也不会对内存造成太大压力。

第二档是“可以重建但成本不低”的缓存,比如商品详情、订单列表的查询结果。用显式容量上限+过期时间,让缓存框架按LRU或时间策略淘汰。内存紧张时,淘汰掉一些也没关系,业务可以通过重新加载来恢复,但不能频繁丢失。

第三档是“丢了完全无所谓”的缓存,比如一些临时计算结果、辅助标记。这种可以直接用弱引用,或者干脆不缓存。

核心原则就一句话:越不可丢的数据,越应该用显式策略控制,而不是依赖GC帮你去判断。GC只知道内存够不够,不知道你的业务数据有多重要。

5. 常见问题与避坑经验

5.1 常见问题速查表

把这几年遇到过(也帮别人排查过)的缓存问题进行汇总,施耐庵一句话:对症下药之前,先确认药方对不对。

现象可能原因对策
缓存值变成null,但缓存对象本身还在底层用了弱引用/软引用,或者配置了weakValues/softValues检查缓存容器实现和配置,确认引用类型
缓存用着用着整体消失了缓存框架设置了maximumSize或过期策略,触发了淘汰调整容量上限、过期时间,检查淘汰指标
缓存Map的size很大,但get命中率极低大量Entry的key或value已被回收,变成“僵尸entry”换用显式缓存框架,启用清理机制
频繁Full GC,缓存命中率暴跌堆内存压力过大,软引用被大量回收调大Xmx、减小缓存容量、优化业务对象大小
线程存活但ThreadLocal缓存的数据一直在,内存无限增长ThreadLocalMap的Entry持有value强引用且未调用remove用完调用remove,改用try-finally确保清理
同一个缓存应用,重启后数据消失本地缓存本身就是进程内缓存,生命周期随进程需要持久化的数据用分布式缓存,如Redis
堆内内存正常,但物理内存持续涨存在堆外内存/直接内存,不在堆GC管理范围内结合NMT或内存监控排查DirectByteBuffer的分配

5.2 排查缓存问题的命令行工具箱

排查缓存问题用到最多的几个命令,我整理了一下,方便你直接抄作业。

jps先找到Java进程ID,这个没什么好说的。然后依次使用:

# 查看堆使用情况和GC次数 jstat -gcutil <pid> 1000 10 # 查看堆中对象占用排行,找出占用最大的对象类型 jmap -histo <pid> | head -30 # 导出堆dump,配合MAT分析 jmap -dump:live,format=b,file=heap.hprof <pid> # 查看JVM启动参数,确认GC日志、堆大小等关键配置 jcmd <pid> VM.flags

如果你用的JDK版本比较高,jmap -histojmap -dump依然是可用的。线上执行导dump命令要小心,-dump:live会触发一次Full GC,如果是大堆、高峰期,可能对线上有影响,建议在业务低峰期操作,或者提前规划好演练时间。

堆dump分析工具方面,MAT是主力,VisualVM也能看。MAT里比较实用的几个视图:Histogram看对象数量、Dominator Tree看持有关系、Thread Overview看线程栈。如果是分析缓存相关的问题,先搜缓存类名,再右键Path to GC Roots,就能看到完整的引用链,很容易定位到是强引用、弱引用还是别的什么在起作用。

5.3 我踩过的几个坑

第一个坑:拿WeakHashMap当本地缓存用。当时图它简单,引入即用,也没仔细看文档。结果一到线上,GC一跑,缓存里数据丢了个干净,业务方反馈“数据一会儿有一会儿没有”。从那以后我就记住了,WeakHashMap不是业务缓存工具,它的语义是“不强持有key”,和业务缓存的预期完全不一样。

第二个坑:用SoftReference缓存大对象,但没控制缓存数量。每个软引用对象本身虽然不阻止内存回收,但如果堆内存长期很紧张,软引用对象会不断被回收又不断被重新创建,造成缓存命中率为0,还增加了GC负担。后来改成硬件缓存框架,同时限制缓存数量,缓存命中率才稳定下来。

第三个坑:只关注了堆内缓存,忽略了堆外缓存。有些网络框架和异步组件会把数据分配在直接内存上,堆内怎么看都正常,堆外却已经爆炸。排查到最后才发现是代码里分配了大量DirectByteBuffer,回收又依赖Cleaner机制,导致物理内存被吃满。从那以后我养成了一个习惯:排查内存问题时不只看堆内使用率,还看物理内存和堆外内存的监控数据。

第四个坑:缓存key用了用户ID这种可变的对象,或者没重写equals/hashCode方法导致缓存永远命中不了。这种问题不算GC的锅,但同样会让缓存“看起来像没了”——其实是get的时候匹配不到而已。排查问题的时候,先确认key对象的hashCode和equals行为,再怀疑引用回收。

第五个坑:把缓存过期和缓存回收混为一谈。缓存过期是时间到了,主动失效;缓存回收是内存不够,被GC或框架淘汰。这两者虽然都会造成缓存读取失败,但排查方向完全不同。

我做排查时有一个习惯:遇到缓存没了,先问三个问题——缓存框架是什么?配置了什么策略?GC日志里有什么?这三个问题问完,大部分问题都能定位到具体原因。

最后再分享一个小技巧:在给缓存框架做选型和配置时,建议把“缓存miss之后的兜底逻辑”当成正常流程来设计,而不是异常流程。缓存本来就是用来加速的,不是用来保证数据存在的。如果每次读取缓存都必然能拿到数据,那系统反而是脆弱的,因为一旦缓存失效,业务就可能跟着崩。把miss当成常态,把重建逻辑做好,不管是强引用、弱引用,还是显式驱逐,都不会成为系统的隐患。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询