
7月JVM调优案例合集——10个生产环境GC问题的根因与解法一、从线上告警到根因定位7月份我们团队处理的JVM相关线上告警共23次其中18次直接或间接与GC行为异常相关。排查这些问题的过程中我们发现一个规律绝大多数GC问题不是JVM参数的锅而是业务代码中的隐性bug——比如未关闭的连接、不断膨胀的ThreadLocal、错误的缓存策略——导致的。GC调优的第一步不是调参而是排查代码。本文挑选7月份最具代表性的10个GC问题案例从现象、根因、解法三个维度展开。大部分案例使用的是JDK 17 G1GC少量案例涉及JDK 11 CMS。先给出一张问题矩阵图帮助快速定位二、内存泄漏类案例4个案例一ThreadLocal引发的元凶现象某交易服务在发布后48小时内Old Gen使用率从30%线性增长至95%最终触发Full GC导致服务不可用。排查过程通过jmap -histo:live抓取存活对象快照发现大量OrderContext对象被强引用无法回收。再通过jmap -dump:formatb,fileheap.hprof导出堆dumpMAT分析显示引用链为Thread → ThreadLocalMap → OrderContext。根因代码中使用了线程池 ThreadLocal但任务执行完毕后没有调用remove()清理/** * 正确的 ThreadLocal 使用模式 * 线程池场景下必须在 finally 块中清理 */ Component public class OrderContextHolder { private static final ThreadLocalOrderContext CONTEXT new ThreadLocal(); public void executeInContext(OrderContext context, Runnable task) { CONTEXT.set(context); try { task.run(); } catch (Exception e) { log.error(任务执行异常, e); throw new RuntimeException(订单上下文处理失败, e); } finally { // 关键线程池环境下必须显式调用 remove() // 否则 ThreadLocalMap 的 Entry 将一直持有 OrderContext 的强引用 CONTEXT.remove(); } } public static OrderContext getCurrent() { return CONTEXT.get(); } }案例二静态HashMap的无声膨胀现象某配置服务运行3天后Full GC频率从每小时1次增至每5分钟1次。根因代码中有一个static final MapString, ConfigCache用于缓存配置但没有任何淘汰策略Key配置项名称持续增长导致Map无限膨胀。解法改用Caffeine Cache并设置大小限制和过期时间/** * 使用 Caffeine 替换静态 HashMap避免缓存无限膨胀 */ Configuration public class CacheConfig { Bean public CacheString, ConfigCache configCache() { return Caffeine.newBuilder() // 最大条目数限制防止 OOM .maximumSize(10_000) // 写入后30分钟过期 .expireAfterWrite(30, TimeUnit.MINUTES) // 使用弱引用ValueGC时自动清理 .weakValues() .removalListener((key, value, cause) - { log.debug(配置缓存淘汰: key{}, cause{}, key, cause); }) .build(); } }案例三本地缓存策略失控现象某商品服务在促销期间Old Gen突然飙升至95%触发Full GC。根因代码为每个商品ID维护了一个ArrayList作为本地缓存促销期间商品数量暴增10倍导致内存占用失控。解法将本地缓存迁移到Redis并设置最大内存限制和淘汰策略。如果必须使用本地缓存至少需要设置-XX:MaxHeapFreeRatio和缓存条目的最大数量。案例四数据库连接池泄漏现象某报表服务在运行12小时后Young GC后存活对象数量异常高导致对象频繁晋升Old Gen。排查jstack显示大量线程处于BLOCKED状态等待数据库连接。数据库连接池配置为最大200但实际活跃连接已全部占满。根因代码中通过try-with-resources获取连接但某个分支逻辑在catch块中又尝试获取新连接发送告警而这个告警连接没有正确关闭。解法告警逻辑应使用独立的连接池不与业务连接池混用同时接入连接池监控如HikariCP的setMetricRegistry。三、GC参数不当类案例3个案例五Heap过小导致频繁GC现象某新上线的网关服务CPU使用率持续80%以上通过jstat -gc观察到每分钟Young GC达40次以上。根因部署时默认Heap为512MB但实际业务需要缓存路由表和限流计数器512MB完全不够。频繁Young GC导致CPU大量消耗在复制存活对象上。解法将Heap扩大至4GB-Xms4g -Xmx4gYoung GC频率降至每分钟3次CPU使用率降至15%。案例六G1 Mixed GC暂停时间过长现象使用G1GC的服务在流量高峰期Mixed GC的暂停时间超过500ms。根因G1默认的目标暂停时间-XX:MaxGCPauseMillis200与实际Mixed GC的工作量不匹配。高峰期Old Gen中大量Region需要回收G1在200ms内无法完成于是不断推迟导致堆积。解法将-XX:MaxGCPauseMillis调整为100ms追求更低延迟同时减小-XX:G1HeapRegionSize从默认的4MB到2MB。Region更小意味着单次Mixed GC回收的粒度更细暂停时间更可控。案例七GC线程数过多导致上下文切换开销现象某32核服务器上GC线程占用大量CPU时间反而拖慢业务处理。根因JVM默认的-XX:ParallelGCThreads在32核机器上自动设置为2632 * 5/8导致26个GC线程同时工作产生严重的锁竞争。解法手动设置-XX:ParallelGCThreads8和-XX:ConcGCThreads2GC吞吐量不降反升因为减少了上下文切换。四、业务流量冲击类案例2个案例八瞬时大对象分配现象某接口在一次导出任务中触发Full GC。根因导出接口一次性将5万条数据库记录封装为一个大JSON对象约200MB这个大对象直接进入Old Gen触发Full GC。解法改为流式导出——使用StreamingResponseBody逐批读取和写入每批1000条内存占用控制在10MB以内。案例九峰值流量触发Promotion Failure现象大促期间某服务频繁出现Promotion Failed日志Survivor区空间不足导致对象直接晋升Old Gen。根因Survivor区S0/S1的空间太小默认Eden的1/8约128MB导致Young GC后存活对象无法放入Survivor区直接进入Old Gen引发过早晋升。解法调整-XX:SurvivorRatio3S0:S1:Eden 1:1:3增大Survivor区比例让年轻代中的临时对象有更多机会在Survivor区中被淘汰。五、第三方库缺陷类案例1个案例十Jackson TypeFactory缓存泄漏现象某JSON处理密集的服务在运行一周后Metaspace使用超过800MB。根因Jackson的TypeFactory对每个新的泛型类型组合都会创建新的JavaType对象并缓存长时间运行后缓存持续增长。这是一个已知的Jackson行为——对动态生成的泛型类型不会自动清理。解法升级Jackson至2.15新版本对TypeFactory缓存做了LRU限制同时在代码层面避免动态构造泛型类型改用预定义的TypeReference常量。10个案例总结ThreadLocal案例一、静态集合膨胀案例二、缓存失控案例三、连接泄漏案例四都属于代码层面的问题占比最高。GC参数不当案例五七和流量冲击案例八九是次常见类型。真正由JVM自身缺陷导致的问题案例十反而是最罕见的。所以还是那句话生产环境的GC问题排查先看代码再看参数最后再看JVM本身。