从“缓存命中率骤降”这个诡异现象说起。先说结论如果你在项目里用了 Guava Cache 的weakKeys()并且缓存键对象没有被其他强引用长期持有那么缓存“消失”不是玄学而是垃圾回收的正常副作用。我最近排查一个订单聚合查询服务时就栽在这上面——接口偶尔慢得离谱一查缓存命中率几乎为零但代码逻辑反复看都没问题缓存没有过期、没有手动失效、容量也没打满。最后定位到源头居然是几行不起眼的CacheBuilder.newBuilder().weakKeys()。这篇文章就围绕这个问题展开讲讲弱引用键为什么会导致缓存无法命中、完整的排查链路是什么、正确的修复姿势有哪些以及什么场景下才真正适合用weakKeys()。如果你是刚接触 Guava Cache或者已经在生产环境被缓存命中率折腾过这篇应该能帮你少走不少弯路。1. 症状现场命中率归零缓存像被“凭空清空”先说现象。这个订单聚合查询服务的逻辑大致是从上游接口拉取一批订单详情构建聚合后的视图对象然后以订单 ID 为键写入本地缓存缓存时间为 5 分钟。服务本身是单机多线程用 Guava Cache 做进程内缓存代码看起来没有任何问题。压测阶段一切正常但上线运行一段时间后监控上出现一个怪信号缓存命中率从最初的 90% 以上一路掉到不足 10%有时候直接是 0%。问题接口的 P99 延迟从几十毫秒飙到几百毫秒数据库和上游服务的压力明显变大。一开始我怀疑是过期时间写错了或者容量设置太小。检查expireAfterWrite(5, TimeUnit.MINUTES)和maximumSize(10000)之后发现配置并没有问题。接着怀疑是不是 TTL 写得太短把 5 分钟改成 30 分钟情况依旧。再怀疑是不是有人误调用了invalidateAll()全局搜了一遍invalidate的调用点也没有。当时一度以为是 JVM 堆内存不足导致缓存被 LRU 疯狂淘汰但CacheStats里的evictionCount并没有异常上涨。真正把问题暴露出来的是加了一行临时日志打印cache.asMap().size()。结果让人惊讶刚写入一条数据后size()能正常显示 1但下一秒在没有任何过期、淘汰操作的情况下size()直接变回 0。缓存不是“被清空”而是刚放进去的条目自己消失了。顺着这条线索往底层看很快就找到了罪魁祸首——缓存构建时用了weakKeys()而写入缓存时用的订单实体对象在业务方法结束后就再也没有任何强引用了。1.1 为什么“垃圾回收后缓存条目消失”看起来像“没有缓存”weakKeys()的作用是让缓存对 Key 只持有弱引用而不是默认的强引用。弱引用的特点是只要对象只有弱引用可达下一次垃圾回收时就会被回收。用大白话理解默认情况下JVM 里的一条缓存条目就像抽屉里放了一张写着 Key 的纸条谁要取东西只要纸条还在就能对照着找到而weakKeys()就像把纸条用铅笔轻轻放在抽屉里风一吹GC 一跑纸条就可能被吹走东西虽然还在抽屉里但你已经不知道它是哪个 Key 的了。所以当你写入缓存时用的是OrderQueryVO这样的对象作为 Key哪怕它重写了equals()和hashCode()只要这个 Key 对象没有其他地方强引用它GC 一旦发生Guava Cache 内部保存的那个 Key 引用就得被清掉。等下一次请求来了用一个新的、逻辑上完全相等的 Key 对象去查缓存缓存根本无法完成匹配自然返回null。这个过程对应用层是完全透明的表面上看起来就是“缓存没有命中”不会抛异常也不会留下明显的错误日志。这也是这个坑特别隐蔽的原因——你看到的只是性能指标恶化真正的问题发生在 JVM 堆内部的内存管理层面。1.2 一个最小复现示例为了验证这个判断我写了一个极简的复现用例你也可以直接跑一下看看效果package demo.cache; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import java.util.concurrent.TimeUnit; public class WeakKeyRepro { private static final CacheOrderKey, String CACHE CacheBuilder.newBuilder() .weakKeys() .expireAfterWrite(5, TimeUnit.MINUTES) .build(); private static class OrderKey { private final String orderId; OrderKey(String orderId) { this.orderId orderId; } Override public boolean equals(Object obj) { if (this obj) { return true; } if (!(obj instanceof OrderKey)) { return false; } OrderKey other (OrderKey) obj; return this.orderId.equals(other.orderId); } Override public int hashCode() { return orderId.hashCode(); } } public static void main(String[] args) throws Exception { OrderKey key new OrderKey(ORDER-001); CACHE.put(key, 订单详情); System.out.println(刚写入时命中 CACHE.getIfPresent(key)); System.out.println(asMap size CACHE.asMap().size()); // 关键让 key 失去强引用等待 GC key null; System.gc(); Thread.sleep(1000); OrderKey lookupKey new OrderKey(ORDER-001); System.out.println(GC 之后命中 CACHE.getIfPresent(lookupKey)); System.out.println(asMap size CACHE.asMap().size()); } }我本地 JDK 8 上跑这个例子的输出是刚写入时命中订单详情 asMap size 1 GC 之后命中null asMap size 0注意这里用了System.gc()来让 GC 可预期地发生实际上线上不调System.gc()只要 GC 正常发生结果也是一样的只是时机不那么确定。2. 弱引用机制与 Guava Cache 的实现细节这个坑之所以容易踩是因为很多开发同学对“弱引用”的理解停留在“可以被 GC 回收”的层面但没有进一步想清楚缓存条目能否被匹配取决于 Key 对象的存活状态而不取决于值对象的状态。2.1 从 Java 引用级别说起Java 对象从强到弱分为四种引用级别强引用、软引用、弱引用、虚引用。其中弱引用WeakReference的特点是只要被引用对象没有被强引用指向下一次 GC 时就会被回收无论当前堆内存是否充足。我们可以这样类比强引用是“我用绳子把气球拴在手上”绳子不断气球就不会飞走弱引用是“我只有一把气球的使用券券丢了气球就飞了”。搞缓存的时候大多数开发者希望的是用绳子把气球拴住——也就是用强引用缓存 Key保证只要缓存没失效Key 就在但weakKeys()把你的绳子换成了纸巾风一吹就没了。从WeakReference的实现来说GC 在回收对象时会把该对象的弱引用放入其关联的ReferenceQueue中。Guava 的缓存内部正是利用这个机制来清理已经被 GC 回收的 Key 对应条目的。2.2 Guava 内部WeakKeyReference的工作方式看 Guava 源码CacheBuilder.weakKeys()设置之后LocalCache创建条目时并不是直接把 Key 存成强引用而是用WeakEntry打包一下。WeakEntry内部持有一个WeakKeyReference这个弱引用对象指向真实的 Key。Java 的WeakReference有一个get()方法返回被引用的对象如果对象已经被 GC 回收get()返回null。Guava 在每次缓存访问读、写、清理时会定期从ReferenceQueue中取出已被回收的弱引用把对应的缓存条目标记为待清理然后从 segment 的哈希表中移除。关键点来了当 GC 回收了 Key 之后这个条目即使还在哈希表里它内部的WeakKeyReference.get()也已经返回null。后续查询缓存时无论你传入的查询 Key 在逻辑上和原 Key 多么相等Guava 都无法通过引用去定位到原来的 Key——原 Key 本体已经不存在了比较的基准都没了。用一句话总结weakKeys()的本质是让 Key 的存活期与外部强引用绑定而不是与缓存生命周期绑定。外部强引用没了缓存条目就跟着“魔法失效”。2.3 为什么强引用模式下不会有这个问题反过来看默认情况下 Guava Cache 的 Key 是强引用保存的。即使写入缓存的 Key 对象在外部的局部变量已经超出了作用域只要缓存条目没被淘汰、过期这个 Key 对象就一直被缓存内部的强引用拽着不会被 GC 回收。也就是说缓存本身的语义是“只要条目在Key 就在”。这个语义对业务开发来说是最符合直觉的——我把一个 Key 放进缓存那么在用同样的 Key 查询时就应该命中除非明确删除或过期。而weakKeys()把这个语义改成了“如果除了缓存之外没人再用这个 Key那 GC 就把 Key 收掉对应的条目也随之不可查询”。这是和默认直觉最大的一次偏离。我画了一条对比线方便你理解引用策略Key 存活依赖GC 后缓存条目命中率影响适用场景默认强引用缓存条目本身条目正常存在无绝大多数业务缓存场景weakKeys()外部强引用条目被清理/不可命中可能导致严重 miss需要避免 Key 长期驻留内存的场景3. 完整排查链路从“性能恶化”到“根因确认”很多人在这个坑里卡住是因为问题发生时没有一条清晰的排查路径。我把整个排查过程完整复盘一下每个步骤对应的怀疑对象和排除方法都列出来你在自己的系统里遇到类似问题时可以直接套用。3.1 第一步先确认缓存统计Guava 的Cache接口提供了stats()方法返回CacheStats对象里面有hitCount()、missCount()、hitRate()等关键指标。我在问题服务上加了一个定时任务每 30 秒打印一次缓存统计CACHE.stats().requestCount(); // 总请求次数 CACHE.stats().hitCount(); // 命中次数 CACHE.stats().missCount(); // 未命中次数 CACHE.stats().evictionCount(); // 显式淘汰次数打印结果让我排除了一大半常见原因。expireAfterWrite是否过短如果是 TTL 问题hitRate会呈现“周期性波动”也就是刚写入的那几十秒命中率高接近过期时间时命中率断崖下降。实际观察没有这个规律。maximumSize是否过小如果是容量淘汰evictionCount会随着命中率下降而明显上升。实际evictionCount几乎为 0。是否有人调用invalidateAll()查了代码调用链没有手动失效逻辑。此时能确定缓存条目不是被显式驱逐而是被“隐藏淘汰”了。3.2 第二步打印asMap()观察条目数量CACHE.asMap().size();这条命令会返回当前缓存中存活的条目数量。如果发现刚写入时size()是 1过一小会儿GC 发生几次之后变成 0那么问题就和 GC 强相关。我当时的观察结果是只要短时间多次触发 GCsize()就持续归零但如果手动用强引用把 Key 对象一直持有比如把它们放进一个全局List里size()就始终正常。这一步基本可以把问题锁定在“GC 导致缓存条目消失”的范围内。3.3 第三步复现并对照RemovalCauseCacheBuilder生命周期比较长的话缓存被自动清理时会产生一个RemovalNotification。如果你的缓存构建时注册了removalListener可以在这里打印被移除的原因CacheBuilder.newBuilder() .weakKeys() .removalListener(new RemovalListenerObject, Object() { Override public void onRemoval(RemovalNotificationObject, Object notification) { System.out.println(key notification.getKey() , cause notification.getCause()); } }) .build();Guava 里有一个专门的枚举值RemovalCause.COLLECTED意思是“垃圾回收器回收了 Key 或 Value”。如果你在日志里看到causeCOLLECTED那就实锤了。注意如果缓存里同时启用了weakValues()值对象被 GC 回收也会触发COLLECTED。排查时需要区分是 Key 侧还是 Value 侧的问题。这个点后文也会展开。3.4 第四步走查业务代码找到 Key 对象的“生命周期破口”最后一步也是最需要耐心的把所有CACHE.put(...)和CACHE.getIfPresent(...)的调用点都找出来逐个检查作为 Key 传入的对象来源。我当时发现的一个典型错误模式是这样的public OrderDetailAggregateVO getOrderDetail(String orderId) { OrderKey key new OrderKey(orderId); // 每次请求新建 Key 对象 OrderDetailAggregateVO cached CACHE.getIfPresent(key); if (cached ! null) { return cached; } OrderDetailAggregateVO vo loadFromUpstream(orderId); CACHE.put(key, vo); // 写入后 key 局部变量出作用域 return vo; }这段代码里key在方法结束后就没有任何强引用了。如果缓存构建用了weakKeys()这个 Key 在下一次 GC 时就被回收缓存条目也跟着失效。下一次同样的orderId来了又新建一个OrderKey对象查不到再写一次再被回收……形成“永远写不进缓存”的恶性循环。这里还有个很隐蔽的细节如果只是临时打日志看CACHE.getIfPresent(key)在同一个方法内因为局部变量key还活着大概率是能命中的但换个方法、换个请求线程再来查很可能就是null。所以排查时千万别只在“刚写入的地方”验证要在独立的新请求路径里验证。4. 修复方案让缓存语义符合“业务直觉”定位到根因之后修复方案其实有很多种但每种方案的成本和风险不一样。我按推荐优先级逐一说明。4.1 最直接的修复去掉weakKeys()如果业务代码对缓存的预期就是“只要缓存没过期同一业务键就该命中”那最正确的做法就是把weakKeys()去掉使用默认的强引用模式。CacheOrderKey, OrderDetailAggregateVO cache CacheBuilder.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .build();强引用模式下缓存完全由自身的容量策略、过期策略控制不依赖外部环境是否还持有 Key 对象行为符合大多数人的直觉。你可能担心“强引用 Key 会不会让内存占用变得很高”答案是会但这是缓存的正常职责。缓存本身就是要用内存换性能如果不想让对象一直驻留应该用expireAfterAccess、maximumSize、weakValues()等机制来限制而不是在 Key 侧做手脚。4.2 更稳的业务建模使用不可变轻量值做 Key即使去掉了weakKeys()如果 Key 对象本身太重、太容易被外部临场创建还是会引发一些偶发问题。我的推荐是Key 尽量使用不可变的值对象并且这些对象最好能由业务 ID 稳定推导。比如把 Key 设计成OrderKey内部只包含String orderId并且连同equals()、hashCode()、toString()一起实现好。查询缓存的入口统一收敛public class OrderKey { private final String orderId; public OrderKey(String orderId) { this.orderId orderId; } // equals / hashCode 基于 orderId }这样每次请求虽然会新建 Key 对象但在强引用模式下新键与缓存内旧键逻辑相等getIfPresent能正确命中。如果你的业务键真的非常轻量直接用Long、String也可以。Guava 缓存的键不强制要求自定义对象能少一层封装就少一层。4.3 如果确实需要弱引用让 Key 被外部长期强引用持有虽然我不推荐但有些系统确实因为特殊原因必须对 Key 使用弱引用——比如 Key 对象本身非常占用内存并且业务能保证这些 Key 在其他地方有长期引用。在这种情况下核心不是修CacheBuilder而是保证 Key 的存活周期和缓存使用周期一致。具体做法可以是使用全局单例、枚举、常量池里的对象作为 Key专门维护一个 Key 存储结构如ConcurrentHashMap持有所有活动 Key 的强引用等业务不再需要时再手动解引用利用CacheLoader在加载时返回的同一个 Key 也在别处被引用。但我要提醒一句这种方案非常脆弱。你必须对每一条 Key 的引用链了如指掌否则只要漏了一条线上就会再次出现“缓存神秘失效”。相比这个难度去掉weakKeys()的改动成本要小得多。4.4 一个治标不治本的备选把 Key 包装成“强引用快照”存在 Value 侧有人想到既然 Key 会被回收那我能不能在 Value 里存一份 Key 的副本查询的时候用副本去匹配听起来好像可以但实际上帮不了你。因为缓存的哈希表结构决定了一次查询必须用 Key 去定位桶位bucket如果你内部 Key 已经没了桶位本身可能还在但 Guava 无法在桶里找到可比较的对象。就算 Value 里存了 Key 的“复印本”那也只能在 hashCode 相同的情况下碰运气命中语义完全不可控。我在实践中见过有人这么干最后不仅没解决问题还把缓存命中逻辑搞成了一团乱麻维护成本巨高。除非你有非常特别的需求否则请绕开这条路。4.5 修复后的验证修复完weakKeys()之后我把优化后的代码重新发到测试环境同样跑压测脚本观察缓存统计CacheStats stats CACHE.stats(); System.out.println(hitRate stats.hitRate()); System.out.println(missCount stats.missCount()); System.out.println(evictionCount stats.evictionCount());压测结果从原来的命中率个位数恢复到 95% 以上missCount稳定在一个合理水平接口延迟也回到正常区间。这里有个小技巧验证时不要只看一次请求是否命中因为weakKeys()导致的不命中是异步的、依赖于 GC 的。最好的验证方式是把服务挂在一个独立环境里连续跑几十分钟模拟真实流量观察CacheStats的趋势。如果命中率保持稳定没有出现“写入后低命中率周期性归零”的现象才算真正修复。5.weakKeys()到底适合什么场景很多读者看到这里可能会问既然weakKeys()这么坑为什么 Guava 还要提供这个选项答案是它并非一无是处只是适用范围非常窄。在特定场景下它的语义恰恰是你要的。5.1 从“内存占用”角度思考默认强引用模式下缓存会一直持有 Key 对象这会造成两个问题如果 Key 对象很大比如某个业务实体包含了大量字段并且很多字段是冗余的可变数据那么即使缓存长期不用Key 本身也占着内存如果缓存用了很长的 TTL旧 Key 长期滞留在缓存里可能导致内存占用偏高影响 JVM 堆的稳定性。weakKeys()可以避免这个内存占用让无人引用的 Key 尽快释放。但前提是有别的强引用在维持你业务所需的 Key 存活。5.2 一个合理的使用案例假设你有一个全局的“用户会话对象”整个进程中所有人都在持有它这些对象会长时间有效。你想为这些会话对象提供额外的属性缓存但又不希望旧的、已经不再使用的会话对象在缓存中永驻。此时可以用weakKeys()CacheSession, SessionProfile profileCache CacheBuilder.newBuilder() .weakKeys() .maximumSize(5000) .build();因为Session对象本身会被进程的会话管理器强引用只要会话存在缓存 Key 就活着一旦会话失效连接会话管理器也不再持有它GC 时缓存 Key 也会被回收缓存条目自然清理。这种场景下weakKeys()的语义是合理的——你希望缓存的条目和“外部对象的生命周期”同步而不是和缓存 TTL 同步。5.3 不适合weakKeys()的场景反过来如果你没有外部强引用兜底就不要用。常见的不适合场景包括每次请求创建的查询条件对象、临时 DTO 对象作为缓存 Key基于方法参数 new 出来的自定义 Key方法结束后局部变量失效任何你希望“逻辑相等即可命中”的场景比如“同订单号查缓存”。这个判断标准很简单如果你拿不出一个“能保证和缓存同寿命”的外部强引用那就不要用weakKeys()。5.4 和weakValues()的对比有人经常把weakKeys()和weakValues()搞混或者一起用这里做个澄清对比。选项控制的是谁被 GC 回收后的效果典型使用场景weakKeys()Key 对象整个条目失效无法查询外部强引用保证 Key 存活weakValues()Value 对象条目仍然存在但值变null值对象很大需要尽快释放内存weakKeys()weakValues()两者任一侧被 GC 回收都会导致条目失效很少使用风险叠加特别注意如果你单独用了weakValues()你也可能会遇到“值明明是刚算好的过一阵就查不到”的问题。本质上和 Key 被回收造成的现象类似都是引用生命周期没有管理好。6. 类似的原生坑equals/hashCode、显式 GC 与缓存命中错觉排查完这个 case 之后我又整理了三个与此相关、容易踩晕的细节一并分享。6.1 不要用“可变对象”当 KeyweakKeys()之外equals()/hashCode()的实现也是缓存命中率的大坑。如果 Key 是可变的且业务代码在缓存使用过程中修改了 Key 的内部字段那么即使缓存没被 GC 影响查询时也可能出现“逻辑相等但 hashCode 变了”导致找不到桶位的现象。简单说放进缓存时hashCode()算出桶位 A修改字段后hashCode()变了查询时算出桶位 B自然命中不了。这和弱引用回收一样会造成“缓存神秘失效”的观感。所以不管用不用weakKeys()Key 对象都应该是不可变的至少不要在缓存生命周期内通过外部代码修改它的字段。6.2 显式调用System.gc()会放大问题调试时System.gc()会让弱引用回收立即暴露出来。但在生产环境里千万不要通过显式 GC 来“甩锅”或“验证”缓存问题。理由有两个显式 GC 在很多 JVM 上会触发 Full GC暂停时间可能长达几百毫秒对线上服务是重大风险即使不显式调用JVM 的自动 GC 也完全可能触发弱引用回收所以线上问题依然存在。我建议调试时用System.gc()是为了快速复现线上环境则通过CacheStats、RemovalCause日志来观测。6.3 别把“局部变量命中”误判成“缓存正常”复现这个问题时很容易出现一个错觉我明明在同一个方法里先 put 再 get能拿到值为什么换了个线程就命中不了原因就是局部变量强引用了 KeyGC 还没有介入或即使介入当前方法栈里的局部变量还会“拖住”这个对象。你只有在“新的调用上下文”中用一个新的 Key 去查才能暴露问题。建议写一个独立的查询接口请求里不携带原来的 Key 强引用专门用来验证“跨请求命中率”。7. 我的最终建议用什么姿势设计 Guava 缓存虽然这是个具体的weakKeys()问题但背后反映的是一个通用原则缓存设计时要先把 Key 的生命周期想清楚再决定用什么引用策略。我在实际项目中现在的默认约定是优先使用String/Long这类不可变轻量对象作为 Key既不容易踩 equals/hashCode 的坑也不会有“对象被 GC”的困扰如果 Key 确实需要自定义对象确保它不可变并重写equals()和hashCode()不主动给缓存加weakKeys()除非能明确说出“谁在外边强引用着这个 Key”并且这个外部强引用生命周期能兜底用removalListener记录RemovalCause.COLLECTED一旦未来再出诡异缓存问题日志里能直接给出答案。最后再分享一个小的自查技巧如果哪天你的 Guava 缓存命中率突然下降并且代码逻辑看不出任何问题先不要急着改 TTL 或者加容量直接看一眼CacheBuilder的配置搜索weak相关关键字往往不到一分钟就能定位到这个“隐形杀手”。