使用Caffeine缓存导致频繁Major GC 不调整堆和缓存大小的解决方案
问题背景
在项目中使用 caffeine cache 实现缓存能力,配置如下:
cache = Caffeine.newBuilder() .expireAfterWrite(6, TimeUnit.MINUTES) .maximumSize(50_0000.get()) .recordStats() .build();
当前缓存内存占用约600MB。
缓存读取逻辑如下:
ReadOnlyHashTable v = itemPropsCache.getIfPresent(key);
若读取未命中,则从Redis加载数据:
// load from redis ... ReadOnlyHashTable table = new ReadOnlyHashTable('${redisVlue}'); itemPropsCache.setCache(key, table);
经GC日志排查与JVM堆转储分析,老年代内存持续增长,最终触发Full GC。
推测原因:由于配置了6分钟写入过期策略,缓存对象在年轻代经过多次GC达到 MaxTenuringThreshold 阈值后会晋升到老年代,每6分钟就会在老年代产生约500MB的待回收垃圾,待对象过期后才会被清理。
已尝试CMS和G1垃圾回收器,均未达到理想的回收效果。
CMS回收器JVM参数配置
-server -Xmx6g -Xms6g -XX:NewRatio=1 -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:MaxTenuringThreshold=15 -XX:SurvivorRatio=3 -XX:+ParallelRefProcEnabled -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection -XX:+HeapDumpOnOutOfMemoryError -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/export/Logs/gc.log -XX:+PrintTenuringDistribution

G1回收器JVM参数配置
-server -Xmx6g -Xms6g -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/export/Logs/gc.log -XX:+PrintTenuringDistribution

请问在不调整堆内存大小、不调整缓存大小的前提下,有什么可行的解决方案?
解决方案
按优先级从高到低落地:
- 补全Caffeine主动过期清理配置
Caffeine默认的过期条目清理是懒触发的,仅在缓存执行读、写、更新操作时顺带清理关联分片的过期数据,没有访问到的过期条目会一直占用内存无法释放。在构造Cache时追加.scheduler(Scheduler.systemScheduler())配置,会启用JVM内置的单线程调度线程池,默认每秒逐分片轮询清理过期条目,过期对象不会长时间滞留堆内存,绝大多数场景下这个配置就能解决老年代堆积问题。Java 8环境可以自行创建单线程守护的ScheduledExecutorService,传入Scheduler.forScheduledExecutorService()实现同样的定时清理效果。
额外建议把手动判空+put的加载逻辑替换成Caffeine原生的cache.get(key, k -> { /* 从Redis加载数据并返回ReadOnlyHashTable的逻辑 */ }),原生加载逻辑是原子性的,不会产生重复对象,也能和缓存的清理、统计逻辑完全对齐,避免自定义封装的setCache逻辑残留多余强引用。 - 调整JVM代际参数,让缓存对象尽量在年轻代完成回收
现有CMS参数里MaxTenuringThreshold=15设置过高,6分钟生命周期的缓存对象很容易在多次Young GC后晋升到老年代。先统计业务的Young GC间隔,把MaxTenuringThreshold调整到对应值,保证缓存对象从写入到过期的全生命周期都在年轻代,不会晋升到老年代。比如Young GC平均间隔为40秒,把阈值设为8,对象最多在年轻代存活320秒(约5分20秒),还没晋升就已经过期被Young GC回收。
现有参数里Survivor区单块大小为600MB,刚好能容纳全量缓存的600MB占用,不用担心Survivor放不下导致的提前晋升问题。 - 优化GC参数,避免老年代回收不及时触发Full GC
如果继续用CMS,把-XX:CMSInitiatingOccupancyFraction调低到60,老年代占用到60%就启动后台并发标记和清理,不要等内存占比过高才触发,避免老年代预留空间不够容纳浮动垃圾触发Full GC。
如果用G1,现有参数完全没有做针对性优化,追加以下配置:
自适应IHOP会根据历史回收效率提前启动混合回收,把老年代的过期缓存分批次在混合GC阶段回收掉,不会等到堆内存占用过高触发Full GC。-XX:MaxGCPauseMillis=200 -XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=40 -XX:InitiatingHeapOccupancyPercent=35 -XX:+G1UseAdaptiveIHOP -XX:G1MixedGCCountTarget=8 -XX:G1MixedGCLiveThresholdPercent=80 - 排查多余强引用
检查自定义封装的itemPropsCache类逻辑,确认没有在Caffeine本身的存储结构之外,用其他List、Map等结构持有缓存值的强引用;检查是否有缓存值被其他业务逻辑的长生命周期对象引用,导致过期后依然无法回收。
内容的提问来源于stack exchange,提问作者Feng Kai
相关产品推荐
相关产品推荐

