Caffeine本地缓存未设上限引发OOM:JVM内存排查与修复实战
2026/9/10 9:33:21 网站建设 项目流程

1. 事故现场:告警里的"GC overhead"和突然变慢的接口

凌晨1点47分,手机钉钉告警群连续弹了十几条消息。某某服务GC耗时超过90%,接口平均耗时从80ms直接跳到8秒,紧接着是一轮又一轮的Full GC。值班同事在群里说了一句"服务卡死了,重启过了,又卡了",我就知道这不是一个普通的偶发问题。

先说下当时的部署背景。这是一个活动运营平台的后端服务,部署了4个节点,每台机器8G堆,用的JDK11。接口主要面向运营后台和内部管理端,平时并发不算高,但大促前会有运营批量配置活动的操作。出事那天的流量高峰比平时高了三倍左右,然后GC就开始失控了。

1.1 一开始只是CPU曲线抬高了

事故的第一现象不是OOM,而是CPU飙升。监控面板上GC线程的CPU占用持续飙高,接口却越来越慢。这是典型的"垃圾回收器在疯狂干活但活干不完"的状态。

为什么GC频繁会导致接口慢?因为无论CMS还是G1,在做垃圾回收时都会发生Stop The World,尤其是Full GC,所有业务线程都要停下等垃圾回收线程跑完。当老年代里塞满了长期存活对象,回收器反复扫描、标记、整理,又每次都只能回收很少的空间,整个服务就陷入"回收-卡顿-再回收-再卡顿"的恶性循环。

当时第一反应是"是不是有人写了慢SQL没加索引"或者"哪个接口发版引入了死循环"。但慢SQL查询和数据库负载都正常,这就把问题推回到了应用自身的JVM内存上。

1.2 Full GC频率爆炸后的连锁反应

我登上机器先跑了一条命令:

jstat -gcutil <pid> 1000 10

输出的数据非常难看:老年代(O区)使用率显示100%,Full GC次数一直在涨,而且每次Full GC耗时都在好几秒。关键点在于:老年代使用率在Full GC之后几乎没有下降,说明堆里存的是大量"存活对象",而不是可以随手丢弃的垃圾对象。

看到这个数据,我的第一判断是"内存泄漏或者内存里攒了不该攒的大对象"。紧接着监控就报了OOM——java.lang.OutOfMemoryError: GC overhead limit exceeded,然后实例开始自动重启。

1.3 先止血:重启 + 临时加大堆,但不解决根因

团队首先把服务重新拉起,同时把某台机器的堆从8G临时调到了12G,想用扩容换时间。服务确实恢复了一段时间,缓存重建后一切看似正常,但到了第二波流量高峰,GC又开始恶化。

这里有个教训:如果你的OOM是"增长型"的,重启和大堆只是把爆炸时间往后推。只要代码里那个疯狂占用堆的逻辑没改,数据量再涨一截,OOM就会再炸一次。临时扩容只是给了我们排查问题的窗口,而不是解决事故的手段。

2. 定位链路复盘:从GC日志到堆转储,锁到"本地缓存"这个真凶

这起事故真正花时间的不是重启,而是从GC异常一路定位到Caffeine这个本地缓存组件。我复盘一下完整的排查链路,包含用的工具、看到的现象和每一步判断的依据。

2.1 第一步:jstat确认GC状态,判断是泄漏还是大对象堆积

前面提到的jstat -gcutil只是第一层。为了看更细的JVM堆行为,我接着看了GC日志:

jstat -gc <pid> 1000 10

这里重点观察几个增长率:Eden区每秒分配多少、经过几次Young GC后晋升到老年代的对象有多少、老年代空间占用曲线。

当天观察到的现象是:每次Young GC之后,进入老年代的对象体积非常大,说明有一部分对象从出生开始就是大对象,直接绕过Eden进入老年代,或者Eden区容量不足以容纳它们。同时老年代使用率下降不明显,说明这些对象不是短命对象,而是长期停留在堆里。

这时候我心里已经有了嫌疑对象:ConcurrentHashMap承载的缓存结构。因为本地缓存的特性就是"长期存活、持续增长、不主动清理",和GC日志里的表现完全吻合。

2.2 第二步:jmap导出堆转储,别在高峰期乱来

为了看清堆里到底是什么对象,必须拉一份堆转储文件。我用的是:

jmap -dump:live,format=b,file=/tmp/app.hprof <pid>

这里一定要提醒大家:线上导出堆转储要谨慎。虽然-dump:live只保留从GC Roots可达的对象,但触发执行时也可能因为堆过大、机器IO压力大而卡顿,甚至会导致更严重的Full GC。所以我的习惯是:如果服务已经濒临OOM,先确认还有多少堆余量再执行dump;如果堆已经满到临界点,优先重启恢复服务,挂上-XX:+HeapDumpOnOutOfMemoryError等待下次OOM时自动生成快照,而不是继续在濒死实例上操作。

当时dump出来的文件大小是7.2G。下载到本地之前,我先用du -h确认了文件大小,然后在本地用6G堆的MAT去打开它。这里顺便说一句,如果你用的是8G以内的堆,建议MAT的MemoryAnalyzer.ini里的-Xmx也调到至少4G,否则打开大dump文件时会直接卡死或爆内存。

2.3 第三步:MAT里看Dominator Tree,Caffeine直接吃掉将近一半堆

用MAT打开堆转储后,主要看三个地方:

  1. Leak Suspects报告:MAT会直接列出它怀疑的内存泄漏入口和保留大小。
  2. Dominator Tree(支配树):按"对象及其持有对象的总保留大小"从大到小排序。
  3. Top Consumers:看哪个包、哪个类占用的堆百分比最高。

我刚打开Leak Suspects报告,第一个嫌疑就是com.github.benmanes.caffeine.cache.LocalCacheFactory相关的对象,保留大小显示占整个堆的70%左右,也就是说堆里超过一半的对象,都是被一个Caffeine本地缓存给拖着。

再点进Dominator Tree看了具体节点的字段,这个Caffeine的缓存结构下面挂着几十万个缓存条目,每个条目value是一个聚合统计对象,一个对象占几十KB。合在一起就是灾难。

看到这里,嫌疑已经非常明确了:这个本地缓存正在无上限地吸收所有请求查询出来的汇总结果,把堆当成了无限大的存储池。

2.4 补充确认:代码里搜一下缓存定义,果然没有maximumSize

定位到具体组件后,我立刻在代码库里搜索Caffeine的实例化代码。找到的代码大概是这样的:

private final Cache<String, AggregatedData> dashboardCache = Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(30)) .build();

全项目里搜了一遍,这个缓存的newBuilder()后面只设置了expireAfterWrite没有设置maximumSizemaximumWeight。而业务代码里每次生成大屏聚合数据后,都会执行dashboardCache.put(key, data),key是租户ID + 大屏ID + 时间片的组合,value是一个几十KB的聚合对象。

流量高峰时,不同的租户、不同的大屏、不同的时间片在不断产生新的key,旧key的过期时间是30分钟,这就意味着30分钟内活跃请求产生的全部聚合对象都会堆在内存里。老年代被打满只是时间问题。

3. 根因拆解:为什么Caffeine不设上限,就会变成"堆内存黑洞"

如果只把问题归为"忘了加maximumSize",那这篇博文的价值就少了一半。更关键的是要理解Caffeine在没有容量上限时到底是怎么工作的,以及为什么很多人会惯性写错。

3.1 maximumSize不设的时候,Caffeine到底怎么工作

Caffeine的Cache接口设计里,maximumSize(long)是用来控制条目数的。但Caffeine这个实现在很多场景下都很"智能",它的核心驱逐算法是TinyLFU,会记录每个key的访问频率,并在容量达到上限后,通过频率信息来决定淘汰哪些条目。

问题是:当你不设置maximumSize时,Caffeine根本不会启用基于大小的驱逐逻辑。从行为上讲,它就像一张无限大的Hash Table,key可以不断增加,value可以不断堆积,直到把JVM堆耗尽。

源码层面,Caffeine构建器里的maximumSize默认值是Long.MAX_VALUE,也就是"无限大"。所以Caffeine.newBuilder().build()创建的缓存,和new ConcurrentHashMap()在"能不能无限增长"这个维度上是一致的——只要没有其他约束,它会一直吃堆。

3.2 被忽略的驱逐机制:Caffeine淘汰不是"满了就立刻扔"

就算你设置了maximumSize,还有一个容易踩的机制:Caffeine的大小驱逐不是精确同步触发的

Caffeine为了减少写锁与竞争,把驱逐动作设计成了"惰性维护"模式。它不会在put()的瞬间就立刻检查有没有超过容量上限、超额条目是否已经被移除;而是在后续读写操作触发维护动作时,再批量处理需要驱逐的条目。如果某个缓存条目在超出上限后被写入了,但没有后续读写操作去触发维护,这些条目依然会留在内存里占用空间。

这一点和Guava Cache有所区别。Guava的驱逐会在写操作时尽量同步移除过期或超限条目,但Caffeine为了高并发性能,更倾向于在cleanUp()或维护阶段处理驱逐。这也是为什么在测试Caffeine缓存时,你可能会发现"超过上限了怎么还没淘汰"的现象。

理解了这一点,修代码的时候就会知道:不能只依赖容量驱逐做实时精确控制,还要接受"延迟淘汰"这个现实,并在年轻代GC时配合expireAfterWrite等过期策略一起使用。

3.3 你以为只是多存几个对象,但Caffeine节点本身就很贵

还有一个很多人不会主动算的账:Caffeine里一个缓存条目并不是"一个Key + 一个Value引用"那么轻量。

Caffeine底层存储使用的是扩展的ConcurrentHashMap节点,每个节点除了key和value外,还要存储访问频率信息、过期时间戳、状态标记等元数据。在JDK11下,一个简单的Caffeine节点可能比HashMap中的一个普通Entry多占用几十甚至上百字节。当你缓存的是几十万条数据时,单是这些元数据就会占据几百MB堆内存。

如果你缓存的是大对象——比如一个聚合统计后的JSON串、一个大ArrayList或一个包含嵌套结构的POJO——那每个条目的内存占用就非常可观了。我们这次事故里的value平均50KB,几十万条就是几十个GB的堆用量,8G堆怎么可能扛得住。

很多开发对"本地缓存"有误解:觉得它就是"往Map里塞对象",有个引用而已,实际上缓存框架为了支持功能特性,付出的额外内存开销比想象中大得多。当你决定做一个大容量缓存时,先估算这条"单位内存成本",比事后优化重要得多。

3.4 为什么当时会写出这个"裸缓存":三个常见的开发直觉误区

我在排查时复盘了当时写这段代码的思考过程,基本逃不过这三个误区:

  1. "本地缓存不是会自动淘汰吗?"如果只设了过期时间,没设容量上限,那么它只会按时间淘汰。只要key不断新增,堆就会持续增长。自动淘汰不等于自动限制堆内存。

  2. "缓存的对象都很小,几万个也没问题吧?"单看条目数可能觉得"几万不就算什么",但一旦value是几十KB的聚合对象,几万条目乘以几十KB就是GB级别。规模估算是缓存设计的必要环节,不是可有可无的优化。

  3. "反正是本地缓存,最多影响一个实例。"这句话在单机时没有大问题,但线上服务往往多个实例同时堆积,一个实例OOM崩溃后,流量会转移到其他实例,连锁反应之下整个集群都容易雪崩。我们这次就是一台机器OOM后,负载均衡转发到其他节点,其他节点也陆续开始高GC。

这些直觉不是完全没有道理,但它们的前提是"缓存有明确的容量上限或淘汰策略作为兜底"。没有兜底的缓存,就像没有刹车的车,平时没事,一旦路况复杂就失控。

4. 修复实操:把Caffeine关进笼子,这套配置我一直在用

定位到根因后,修复方案其实很清晰:给所有Caffeine缓存加上完整的容量、过期、监控策略。但"加一个maximumSize"只是及格线,我更建议按下面这套思路来配置。

4.1 最基础的配置:maximumSize + expireAfterWrite组合

修复后我们使用的第一版配置是这样:

private final Cache<String, AggregatedData> dashboardCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .removalListener((key, value, cause) -> log.warn("Dashboard cache eviction. key={}, cause={}", key, cause)) .build();

为什么maximumSizeexpireAfterWrite要组合使用?

  • maximumSize负责兜底容量:不管数据有多热、key有多多,缓存条目数超过上限就会触发驱逐,这是防止OOM的第一道防线。
  • expireAfterWrite负责数据时效:如果只有容量上限而没有过期时间,缓存里存的数据可能长期不更新,业务看到的还是旧数据。对运营平台的大屏聚合数据来说,10分钟内的新鲜度是可接受的。
  • 两者的配合还能减少"冲掉热点"的问题。如果只设容量上限,每次流量高峰可能会把高价值的热点数据挤出缓存,命中率剧烈波动;有了过期时间,旧数据会定期让位给新数据,容量驱逐的压力也小一些。

这里给的10分钟是一个例子,实际设置要根据业务容忍的滞后时间来决定。如果数据要求严格实时,就不该用本地缓存,而应该直接查库或走分布式缓存。

关于maximumSize具体设置多少,我提供一个快速估算方法:

  1. 先测出一个缓存value大致的堆占用。可以在本地写个小程序,构造一条数据后通过jol或直接压测估算。我们这条value大概50KB。
  2. 设定目标最大占用。我们当时希望这个缓存最多用500MB堆,那理论条目数就是:500MB / 50KB ≈ 10000条。
  3. 为了给GC和临时对象留空间,建议再打个五折,也就是5000条左右。

如果你不知道单条value多大,最稳的办法是先用小值上线,再通过recordStats()的监控数据看实际内存表现,慢慢调大。切忌上来就设一个"看起来够用"的百万级上限。

4.2 更进一步:用weigher按业务权重限制内存占用

有些场景下,maximumSize按条目数控制并不精确,因为每一条缓存对象的size差异可能非常大。比如一个key对应小配置对象(几KB),另一个key对应大报表对象(几百KB),用条目数控制内存时,实际内存占用会很不均匀。

Caffeine提供了maximumWeightweigher配合的机制,可以按对象的权重来限制总容量:

private final Cache<String, AggregatedData> dashboardCache = Caffeine.newBuilder() .maximumWeight(512L * 1024 * 1024) // 缓存总权重上限512MB .weigher((String key, AggregatedData value) -> value.getJsonBytes().length) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats() .build();

weigher返回的权重可以理解为"这个对象大约占多少字节",maximumWeight是总权重的上限。Caffeine在写入时会累加权重,超过上限后触发驱逐。

这里有几个注意点:

  • weigher的计算要轻量,不能在里面做复杂的序列化或数据库查询,否则每次写入缓存都会产生额外开销。
  • weigher不能返回负数,不然Caffeine会直接抛异常。
  • 权重值不要设得太大或太小,最好和实际内存单位对齐,比如用"字节数"或"KB数"来估算,方便后续换算。
  • 一旦启用了maximumWeight,就不应该再同时设置maximumSize,两者是二选一的关系。

4.3 别忘了监听eviction:淘汰了多少要心里有数

很多团队配置完maximumSize就完事了,但我建议一定要加removalListenerrecordStats。原因很简单:你不监控缓存,就不知道缓存有没有在正常工作,也不知道它有没有被频繁驱逐导致命中率崩掉

removalListener可以在缓存条目被移除时收到回调,输出移除原因。Caffeine移除原因主要有这几类:

移除原因含义是否需要关注
EXPLICIT手动invalidate删除通常不需要
REPLACEDkey对应的value被覆盖通常不需要
EXPIRED过期时间到了正常,但量级异常时需关注
SIZE容量超限被驱逐正常,但大量SIZE驱逐说明容量设置过小或数据增长过快

我们这次上线后,日志里就出现过一段时间的SIZE驱逐告警。通过eviction原因分析,我们确认新配置的5000条上限在大促期间不够用,然后才逐步调整到10000条。

recordStats()打开后,可以定时获取CacheStats并上报到监控系统。比较关键的指标是hitRateevictionCount

  • hitRate如果过低,说明缓存命中率差,大量请求都在穿透到数据库,此时要么数据过期时间太短,要么缓存容量太小。
  • evictionCount如果持续快速增长,说明缓存处于高频换入换出状态,可能把热点数据也淘汰掉了,需要增大容量或优化key设计。

4.4 复盘验证:上线后的指标对比和压测结果

配置修复后,我们分两步做了验证。

第一步是压测。用JMeter构造了和线上高峰相当的请求QPS,分别跑旧的裸缓存配置和新配置,观察JVM的堆占用曲线和GC频率。旧配置在半小时内老年代使用率就涨到了70%以上,Full GC频率持续升高;新配置下老年代使用率稳定在2G以内,Full GC基本消失,只有正常的Young GC。

第二步是灰度上线。先改一台机器的配置,观察三个指标:

  • 接口平均RT是否回落到正常水平
  • GC每秒耗时和Full GC频率是否稳定
  • 业务侧的大屏数据是否还在正常更新

灰度观察了一天后,才同步到所有节点。上线后老年代使用率曲线变得相当平缓,再也没有出现爬坡式增长。顺带说一句,我们还把堆从临时的12G调回了8G,说明修复不是靠堆容量硬扛,而是真的把内存占用控制住了。

5. 类似的OOM陷阱:这些"堆内存杀手"也在线上等着你

这次事故之后,我在处理其他线上OOM时,总结出了一些常见的堆内存失控场景,和Caffeine缓存有相似的触发逻辑。这里选两个最常见的说。

5.1 大文件分片上传时,为什么也会堆溢出

有一个经典场景:前端大文件分片上传,后端接口接收分片后,把每个分片的二进制内容临时存在内存里,等所有分片都传完之后再合并。分片数量的上限=文件总大小/单分片大小。如果单个文件是5G,后端又把每个分片的byte[]封在对象里放进List等着合并,那服务端堆很快就会被打满。

这种问题的本质和Caffeine缓存一样:内存中累积了大量生命周期较长的对象,且数量没有上限约束

常见的解决思路:

  1. 分片数据落盘到临时目录,而不是留在内存里,最后合并时再读文件。
  2. Part接口配合DiskFileItemFactory,设置内存阈值,超过阈值自动写入临时文件。
  3. 对同一文件的分片数量做限制,比如最多上传有限个分片,超出后拒绝合并请求。

如果你在处理类似场景,"ByteArrayOutputStream攒着所有分片然后一次性flush"这个写法,在文件小的时候很香,在文件大的时候就是在埋雷。

5.2 用POI大批量写Excel,把整表数据全攒在内存里的坑

另一个高频OOM场景是Excel导入导出。用Apache POI的XSSFWorkbook写大量数据时,所有行和单元格对象都会被保留在内存中,直到workbook.write()之后才算释放。一个几十万行的Sheet,单元格数量可能是几百万个,对象开销极大。

处理这种场景的核心思路有两个:

  1. 导出时使用SXSSFWorkbook,它内部会维护一个滑动窗口,只保留最近N行在内存中,超过窗口的行自动刷入临时文件。这是POI官方支持的流式写方案。
  2. 导入时尽量用XSSFReader流式读取,或分批读取一批处理一批,不要把整个工作簿都加载进来再处理。

这类问题的共性和Caffeine缓存事故完全一致:短期看数据量不多,放大到几十万、几百万条就突破了堆的临界点

5.3 通用排查OOM的最后检查清单

我处理过的线上OOM事故,无论表象是什么,最后基本都能归到下面这几类:

  • 静态集合/单例Map持有大量对象没有清理
  • 本地缓存没有设置容量上限或过期策略
  • ThreadLocal值对象在当前线程池里长期存活
  • 请求处理中把大文件/大对象整体加载到内存
  • 连接池或并发队列堆积了海量待处理任务

排查时我的固定顺序是:

  1. 先看监控确认是堆还是非堆Memory,哪个区满了。
  2. jstat看GC情况,判断是泄漏还是分配过多。
  3. 让OOM自动dump,或者手动jmap dump,别凭感觉猜。
  4. 用MAT分析Dominator Tree,找到保留内存最大的对象链。
  5. 在代码里重点搜staticCachenewBuilder()ThreadLocalList<byte[]>这些关键词。

最后再分享一个我自己现在写代码的习惯:每次new一个缓存对象,必须同时回答三个问题——容量上限是什么?过期策略是什么?淘汰原因会不会有人看到?只要这三个问题都能说清楚,类似"本地缓存把堆吃穿"的事故,基本就能提前拦住了。

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

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

立即咨询