ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JVM引用类型与缓存丢失:软引用、弱引用如何导致数据凭空消失?

JVM引用类型与缓存丢失:软引用、弱引用如何导致数据凭空消失? 你有没有遇到过这种情况代码里明明还拿着某个缓存对象的引用日志也能打出来对象还在可一到内存紧张的时候缓存里的值就跟商量好了似的“集体蒸发”了。第一次碰上这事我还以为是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之前可能回收大对象缓存、图片缓存SoftReferenceT弱引用下一次GC时只要对象只被弱引用可达就回收临时映射、缓存辅助数据WeakReferenceT虚引用对象被回收时收到通知主要用于跟踪对象回收堆外内存回收、对象生命周期监控PhantomReferenceT这里面最常见、也最容易让人栽跟头的是软引用和弱引用。简单类比一下强引用像一份永久合同只要合同在手房子不能拆软引用像政府因紧急项目可以征用的房子平时没事真到关键时刻就得让位弱引用更像便利贴每天下班保洁阿姨就会把便利贴撕掉不管你上面写了多重要的提醒。关键是软引用和弱引用都有一个共同点Reference对象本身还是存在的。你拿到的WeakReference对象没有变成null还能继续调用get()但get()返回的内容已经是null了。这就是“引用明明还留着数据却说没就没了”的最直接原因。虚引用更特殊一些它的get()永远返回null它存在的意义只是告诉你“对象已经被回收了”。所以平时做业务开发基本碰不到虚引用更多是在做JVM监控、堆外内存回收时才会用到。2. 为什么缓存明明“有引用”还是被回收掉2.1 真相一你拿的引用跟容器里的引用强度不一样很多人查缓存丢失问题时会习惯性看自己代码里是不是还有变量指向缓存对象。比如从缓存里取了一个对象存在局部变量里然后发现对象没了第一反应是“不可能啊我这里明明还引用着它”。这里恰恰有个盲区你引用的对象和缓存容器底层持有的引用根本不是一个强度。拿WeakHashMap举例。这个类的Entry继承自WeakReferenceObject也就是说它的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的引用强度不一致导致“半个身子先没了”比整个缓存消失更诡异的是缓存结构还在但值已经取不出来了。这种情况通常发生在weakKeys或weakValues配置上也就是key和value的引用强度不对称。举一个所有Java后端都不陌生的例子ThreadLocal。ThreadLocalMap里的Entry继承自WeakReferenceThreadLocal?key是弱引用value是强引用。线程存活期间如果外部对ThreadLocal的强引用断掉了key会被GC回收但value还被Entry里的强引用握着这就产生了经典的内存泄漏问题。这也是为什么官方一直建议用完ThreadLocal后要调用remove()。回到缓存场景假如你用的缓存框架配置了weakKeyskey只有弱引用。此时key被回收后对应的value即使还是强引用也取不出来了因为根本匹配不到原来的key。反过来如果配置的是weakValuesvalue被回收后缓存结构还在但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先确认一下缓存框架有没有设置容量限制、过期策略、weakKeys、weakValues这些配置。很多时候“谁干的”其实就写在配置代码里。3. 从“感觉”到“定位”排查缓存丢失的正确姿势3.1 拿到GC日志确认到底有没有GC发生很多线上问题排查第一步就卡在“凭感觉”。感觉内存紧张了感觉GC频繁了感觉缓存被回收了。感觉不能用来定位问题还是得落到数据上。先把GC日志打开。JDK 8及之前的版本用-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/to/gc.logJDK 9之后推荐用统一的日志方式-Xlog:gc*:file/path/to/gc.log:time,uptime,level,tags -Xlog:gcheapdebug: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这样一旦发生OOMJVM会自动把堆快照落盘。日常排查也可以手动触发jmap -dump:live,formatb,fileheap.hprof pid拿到heap.hprof文件后用MAT打开在Dominator Tree支配树里搜索你的缓存类名就能看到这个缓存对象在整个堆里的持有关系。重点看两点一是缓存对象被谁引用引用路径是否包含WeakReference、SoftReference二是缓存对象内部是否有大量已经被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]; WeakReferencebyte[] 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 { MapString, 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后端现在用得很广。CacheString, 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一定要配置合适的过期策略和容量上限。有人可能会问那到底什么时候该用weakValues、softValues我的建议是默认不用除非你有非常明确的诉求。比如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,formatb,fileheap.hprof pid # 查看JVM启动参数确认GC日志、堆大小等关键配置 jcmd pid VM.flags如果你用的JDK版本比较高jmap -histo和jmap -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当成常态把重建逻辑做好不管是强引用、弱引用还是显式驱逐都不会成为系统的隐患。
返回列表