☰
JVM调优实战:从Full GC频繁到老年代内存泄漏的排查与解决
2026/10/10 14:40:30 网站建设 项目流程

1. 问题初现:从一条告警说起

接手这套订单服务的时候,我隐约觉得它不太对劲。监控面板上,JVM老年代使用率画出来一条不断抬升的台阶曲线,每次Full GC之后往下掉一截,然后又被慢慢填满,隔一阵子再触发一次。接口的响应时间也跟着GC一起跳动,P99从正常的120毫秒慢慢爬到了500毫秒开外。这是一次典型的JVM调优实战场景,也是一次值得完整记录下来的踩坑过程——从告警出现、现场取证、根因定位,到参数调整和上线验证,每一个环节都有可以复用的判断依据和操作手法。对正在排查线上访问变慢、GC频发这类问题的开发者,这篇文章应该能提供一条清晰的排查路径。

1.1 现象描述与初步判断

某天凌晨,某公司订单服务的告警群弹出一条消息:JVM堆内存使用率超过90%,持续5分钟。值班同事的第一反应是重启实例,曲线暂时回落,但过了两三个小时又再次冲高。等我接手的时候,服务已经被重启了两轮。重启能解决问题吗?能,但只是把问题往后推,你必须搞清楚内存到底被什么占住了,否则再过几个小时又得来一次。

我拉出监控数据,几个特征值得记录:

  • CPU使用率不高,大概15%左右,基本排除纯计算热点导致的资源争抢。
  • 线程总数正常,没有出现线程数持续增长的现象。
  • GC统计曲线显示Full GC非常频繁,周期大约6到8分钟一次。
  • Full GC之后,老年代使用率只回落到70%左右,下一次Full GC的间隔越来越短。

这些特征加在一起,基本可以排除“流量突增”这种简单解释。老年代持续被填满,但Full GC的回收效果有限,更像是内存中有大量存活对象长期占据空间,也就是俗称的内存压力持续累积。我当时的判断是:老年代里有什么东西占着不放手——要么是某种缓存类对象,要么是某个集合在持续膨胀,要么是线程上下文或类加载层面的异常。

1.2 别急着调参数,先搞清楚状况

很多开发者一听JVM内存高,第一反应是把-Xmx调大。这个思路不能算完全错,但容易变成典型的“头疼医头”。堆内存调大以后,Full GC的触发阈值也会延后,表面上GC次数变少了,但内存可能被撑到更大规模才触发回收,单次停顿时间反而更长,对响应时间的影响更恶劣。而且堆内存调大还会连带影响容器规格、CPU核数、OOM风险的评估,牵一发动全身。

我处理这类问题有一个原则:先取证,再定位,最后才调参。至少要拿到三样东西:GC日志、线程栈、堆转储。GC日志告诉你回收的频率和耗时,线程栈告诉你有没有可疑的线程行为,堆转储告诉你内存里到底放了什么对象。三份材料一拼,问题通常就浮出水面了。

当时遇到一个比较被动的点:这台服务的GC日志并没有打开。我手里只有监控面板上的二手指标,看不到每次GC的细节。这个缺失在后面直接拖慢了排查速度,也让“所有服务默认开启GC日志”成了我后续的强制要求。

2. 现场取证:把JVM的底裤扒出来

2.1 用jstat先看GC健康度

拿到一台问题实例,我先用 jps -l 确认进程PID,然后用 jstat -gcutil 采样。jstat是JDK自带的小工具,不需要额外安装,非常适合线上粗查。

jps -l # 假设服务的进程PID是17621 jstat -gcutil 17621 1000 10

每秒钟采样一次,连续采10次。重点看E(Eden)、O(Old)、FGC(Full GC次数)、FGCT(Full GC总耗时)、GCT(GC总耗时)。我当时看到的输出大致是:Eden区在40%到90%之间波动,Old区持续在85%上下不回落,FGC在10次采样里涨了两次,每次Full GC耗时将近2秒。

这条信息价值很高。Eden波动是正常的,因为新生代天天有对象创建和回收;但老年代持续卡在85%高位,而且Full GC之后没有明显回退,说明老年代里确实存在大量存活对象,无法被回收。再配上“每次Full GC耗时接近2秒”的数据,大概率是存活对象过多,标记阶段耗时变长,或者是CPU资源被其他负载抢占,导致并发标记变慢。这时候需要进入线程层面看具体情况。

2.2 用top和jstack看线程在干什么

JVM调优不能只盯着内存,线程状态同样重要。如果老年代里的垃圾对象是某个线程不断产生的,那线程栈就是直接线索。我用 top -Hp 17621 把进程内的线程按CPU占用排序,发现有两个线程的CPU消耗明显偏高,PID分别是18452和18467,都占到20%左右。对一台平时只有15%负载的服务来说,这两个线程已经算异常。

接着把线程PID转成十六进制,再用 jstack 抓线程栈:

printf "0x%x" 18452 # 输出 0x4814 jstack 17621 > /tmp/thread_dump_$(date +%Y%m%d%H%M%S).txt

在线程dump文件里搜“nid=0x4814”,找到了对应线程。结果是一个任务调度线程正在执行订单导出逻辑,里面调用了第三方接口,连接超时时间设得特别长,导致线程长时间阻塞在等待响应上。这类阻塞一旦积压,任务队列里的数据会不断膨胀,最终体现为堆内存上涨。

需要说明的是,线程栈只能告诉我们“有线程在干什么”,不能直接告诉我们“内存里堆了哪些对象”。这一步的价值在于判断方向:如果线程栈里全是等待IO的线程,那重点看缓冲区和队列;如果是大量计算任务,那重点看局部变量和临时对象。当时的方向指向了任务队列和缓存容器,于是顺势进入堆转储阶段。

2.3 堆转储与离线分析

堆转储是JVM调优里最硬核的一步。我用 jmap 导出了一份堆快照:

# 生产环境谨慎操作,jmap dump会触发较长时间的STW jmap -dump:live,format=b,file=/tmp/heap_$(date +%Y%m%d%H%M%S).hprof 17621

加“live”参数的意思是先触发一次Full GC,把不可达对象清掉再去dump,只保留存活对象。这样做的好处是导出文件体积更小,分析存活对象分布更准确;代价是这次Full GC会让服务停顿。如果是核心链路实例,建议先摘流量再操作,或者放到业务低峰期执行,不要在大流量时段直接跑。

我导出的文件大概2.8GB,拿到分析机上打开。分析工具用的是MAT(Memory Analyzer,Eclipse团队维护的开源工具,大家习惯简称MAT),也有人用JProfiler或者VisualVM,但在“找内存泄漏嫌疑对象”这个场景下,MAT效率最高。

打开之后主要看两个视图:Leak Suspects(泄漏嫌疑)和 Dominator Tree(支配树)。Leak Suspects会直接列出“某个大对象占据了堆内存的多少比例,它可能是问题源头”。我在嫌疑列表里看到一个 HashMap 实例,占据老年代接近45%的空间,持有对象数量达到百万级,支配树一眼扫过去全是订单快照对象。

到这里,问题的框架已经很清晰:一个无界增长的本地缓存Map,把订单数据全部放在JVM堆内,没有容量上限,也没有过期清理机制。

2.4 找到Root Cause

继续在MAT里看那个HashMap的引用链,找到持有它的具体类。顺着引用路径,定位到某同事之前写的一个“订单维度缓存服务”,代码里用了一个 static Map<String, OrderSnapshot> 来缓存近期订单明细,设计初衷是减少数据库查询压力。问题在于,这个Map只往里面put,从来不清理,也没有淘汰策略,key直接用了订单号。

订单号是什么概念?全平台每天的订单量几十万,一周就是几百万。这个缓存被当成“近期订单缓存”使用,但代码里没有定义“近期”的范围,于是退化成永久缓存。老年代被这些订单快照对象逐步占满,达到CMS触发阈值后开始并发回收,但由于存活对象太多,回收效果不理想,部分区域又被并发标记阶段新晋升的对象填满,最终触发CMS的“并发模式失败”,退化成Serial Old的Full GC,停顿时间飙升到2秒。

这还没结束。线程栈里看到的那个订单导出任务,也会把一批订单快照塞进同一个缓存Map,进一步加速内存膨胀。两个问题叠加在一起,服务表现自然越来越差。

根因清楚了:第一,代码层有缓存设计缺陷;第二,JVM参数层在堆内存设置和GC触发策略上也存在不合理的地方。两方面都要处理,只改代码或者只调参数都不完整。

3. 调优方案设计:内存结构与收集器配置

3.1 堆大小与动态扩容问题

先看当时的启动参数,简直是一场参数事故。堆设置是“-Xms512m -Xmx2g”,JVM在运行中会从512MB一路扩到2GB,然后GC回收,又缩回去,再慢慢涨回来。JVM在做堆扩展和收缩时,往往会触发带压缩的全堆整理,也就是STW。这种动态扩容方式在低并发时看不出来,一旦流量波动,GC行为就很不稳定。

第一个调整是把 -Xms 和 -Xmx 设成相同值,彻底关掉动态扩容。生产环境没有理由让JVM在运行过程中反复调整堆大小,因为你给容器的内存配额是固定的,JVM一会儿占用512MB一会儿占2GB,对资源规划没有任何好处。

但这里有个很容易踩的坑:不能把 -Xmx 直接设成容器配额的上限。JVM进程除了堆,还有方法区、线程栈、堆外内存、JIT编译器代码缓存等额外开销。如果堆设成4GB,整个进程很容易把容器内存吃满,被操作系统杀掉。当时实例的容器配额是4GB,我把堆定为 -Xms3g -Xmx3g,加上堆外开销,进程总占用控制在3.5GB左右,给系统缓冲区留出余量。用容器部署的服务,一定要记住:-Xmx 不是进程实际内存上限,它只限制堆部分。

3.2 新生代与老年代配比

堆大小只是基础,内部结构必须细调。我分析了业务对象的生命周期:订单快照这类对象,从缓存里读取后只做短暂处理,大多数很快变成垃圾,应该在新生代就被回收掉。但原服务里这些短生命周期对象进入老年代的比例偏高,一个重要原因是新生代太小。

原参数用的默认 NewRatio,新生代大约只占堆的1/3,对高并发场景来说偏保守。我改成用 -Xmn 显式指定新生代大小,并配合 SurvivorRatio 控制Eden和Survivor的比例。

从3GB堆里划出1GB给新生代,幸存区各约128MB。这样做的逻辑是:对象创建高峰期的短生命周期对象,尽量在Eden区完成分配和回收,避免过早晋升到老年代。晋升数量减少,老年代的填充速度就慢,Full GC触发的频率自然下降。

不过调节新生代大小不能拍脑袋。新生代太大,老年代就会被压缩,万一真有长生命周期的大对象,老年代反而容易出问题。我在压测环境里做了两组对比,一组是 -Xmn768m,一组是 -Xmn1g,观察两小时内的Young GC频率和晋升对象总量,最后才确定1GB更合适。这种对比实验比“我感觉”可靠得多。

3.3 CMS参数与并发模式

JDK 8环境下,当时用的收集器是CMS,启动参数为 -XX:+UseConcMarkSweepGC。CMS最大的特点是在回收老年代时大部分阶段并发执行,不阻塞业务线程,但它的弱点是“并发模式失败(Concurrent Mode Failure)”。

CMS不能等到老年代几乎满了才开始回收,因为并发标记需要时间。如果标记期间又有大量对象晋升,把老年代剩余空间耗尽,CMS会直接放弃本轮回收,退化成Serial Old的Full GC,STW时间极其夸张。所以CMS必须设置启动阈值,提前触发。

我加了两个参数:

-XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly

第一个参数表示老年代使用率达到75%时启动一次CMS回收;第二个参数非常关键,加上它之后JVM才会严格遵循75%这个阈值来决定启动时机,否则JVM会基于运行时的统计自己推算阈值,把你的设置当成参考值。

这套组合是业界的成熟配置,目的是在老年代还没拥堵到阻塞业务时就完成一次并发回收。副作用是CMS启动会更频繁,但并发回收阶段不打断业务,总体利大于弊。

我还打开了 -XX:+CMSParallelRemarkEnabled,让remark阶段用多线程执行,减少最终标记的停顿;同时加了 -XX:+ScavengeBeforeRemark,在remark之前先做一次Young GC,清掉Eden区中引用老年代的死对象,降低remark阶段的扫描负担。

需要提醒一下:CMS在JDK 9之后被标记为废弃,JDK 14已经被移除。如果应用跑在JDK 17上,不要追求CMS,直接用G1或者ZGC更合理。这次服务用的是JDK 8,CMS是符合当时环境的正常选择。

3.4 日志与监控参数

这部分是我后来反复强调的重点。所有线上Java服务,必须打开GC日志。哪怕服务再小,一旦出问题,GC日志能让你少走太多弯路。

-Xloggc:/data/logs/gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime

几个参数的含义花点时间说明:

  • %t 会让日志文件名称带上启动时间戳,每次发版生成独立文件,排查问题时容易对号入座。
  • -XX:+PrintGCDateStamps 打印的是日期时间,能够直接对应监控时间轴。
  • -XX:+PrintGCApplicationStoppedTime 会输出应用实际停顿时间,这个数据对评估用户体验帮助很大。

另外加上:

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/

一旦真的OOM,自动把堆转储写到指定目录,保留最关键的事故现场。这个参数在真实故障中价值极大,因为进程OOM后还在,但如果没有自动转储,就只能靠监控曲线去猜内存被什么占住了,排查复杂度成倍增加。

4. 上线与验证:用数据说话

4.1 分批次上线的策略

参数调整不能一把梭,全部实例同时发布的风险太大。我先把代码修复(缓存加容量上限、订单导出逻辑改造)合入主干,在压测环境完整跑了一遍回归,确认没有功能问题。

线上采取灰度策略:第一批只发一台边缘实例,让部分测试流量打上去,观察15分钟,重点看GC次数、Full GC次数、老年代使用率曲线。如果一切平稳,第二批再发三台,第三批才全量覆盖。

实际执行过程中,前两批都很平稳,第三批全量之后有一台实例的老年代使用率在掉到70%后再次缓慢爬升,趋势比另外几台陡。查下来发现那台机器上有一个定时任务在灰度批次中没有被触发,全量后才开始执行,而该任务处理的数据量特别大,属于已知的数据倾斜问题。当时的对策是修了下任务分片逻辑,顺便把“定时任务实例均衡”也排进了发布计划。

灰度发布需要盯哪些指标?总结成清单:Full GC次数、GC总耗时、老年代使用率趋势、P99响应时间、异常日志量。任何一个异常,立即暂停发布,不要抱着“等它自动恢复”的侥幸心理。

4.2 调优前后数据对比

上线稳定跑了48小时后,我拉了一组对比数据:

指标调优前调优后
老年代使用率持续85%左右徘徊稳定在40%~55%波动
Full GC次数约每小时40次全天不超过5次
Full GC平均耗时约1.8秒小于600ms
Young GC次数每小时约180次每小时约120次
P99接口RT500ms左右120ms左右
平均CPU使用率15%~25%8%~12%

Full GC次数从每小时40次降到全天不到5次,变化非常显著。这是两个因素叠加的结果:缓存无界增长被修复,老年代不再持续被订单快照填满;新生代调整合理之后,对象晋升数量减少,老年代的新增垃圾也少了。P99响应时间从500ms回到120ms,对业务方来说是可以直接感知到的改善。

有个细节值得记录:调优后Young GC次数也同步下降。以前Young GC频繁,一部分原因是老年代空间不足导致晋升失败、部分对象在Eden区反复复制,老年代修好之后Young GC反而更顺畅了。JVM的内存问题经常是一环扣一环,只盯单一指标容易误判。

4.3 监控与指标设计

这次调优让我把监控体系也完善了一遍。之前的监控面板只有堆内存使用率这一个JVM指标,远不够用。现在的核心服务会盯五类指标:

第一类是GC统计类:Young GC次数与耗时、Full GC次数与耗时、老年代使用率。第二类是内存分代类:Eden区使用率、Survivor区使用率、非堆内存使用率。第三类是线程类:活跃线程数、阻塞线程数、死锁检测。第四类是RT类:平均RT、P99、P999以及慢请求比例。第五类是系统层:CPU、内存、磁盘IO、网络IO。

监控不是指标越多越好,而是每个指标都能对应到一个故障场景。比如线程数突然涨了,说明可能有任务堆积;P99突然涨了而平均RT没变,说明有一小撮请求在某处排队等待。

报警规则上,不建议任何指标超过阈值就报警。比如Full GC单次超过1秒才报,而不是每次Full GC都报。因为偶尔一次Full GC不影响业务,就不需要人工介入,否则团队很快会对报警产生疲劳。

5. 复盘与避坑清单

5.1 问题复盘:哪些环节值得重来一遍

回头审视这次调优,有几个环节做得还算到位,也有几个地方效率偏低。

做得好的地方:没有一上来就加大堆内存。加内存看似缓解症状,实际上掩盖了根因,缓存无界增长的问题不会消失,只是把爆发时间推迟了。另外,用MAT的支配树快速锁定大对象,而不是在代码里逐行排查,方向上值得肯定。面对几十万甚至百万级别的对象,肉眼不可能看清谁占了内存,必须依靠分析工具的支配关系来缩小范围。

效率偏低的地方:GC日志没有提前打开,导致早期判断只能依赖监控面板的汇总数据。很多分区级别的细节看不到,比如CMS取消标记的次数、并发阶段的具体耗时,如果日志提前开着,问题定位至少能提前半天。

还有一件事贯穿始终:自动化发布脚本中的JVM参数没有纳入版本管理。参数散落在运维平台的“高级配置”里,排查时需要翻后台,非常费劲。这次之后,我把所有JVM启动参数放进了配置仓库,和代码一起走审批和上线流程。

5.2 常见错误操作速查表

错误操作为什么危险正确姿势
生产环境直接jmap dump大堆转储导致长时间STW,核心链路可能挂掉摘流量后操作,或用jmap dump:live并在业务低峰执行
盲目调大-Xmx堆内存占满容器配额,触发操作系统OOM Kill结合堆外开销评估,Xmx建议不超过容器内存的75%~80%
同一个版本改多个参数出问题后不知道哪个参数是元凶一次只改一个维度,用A/B对比验证
Full GC频发就直接换G1收集器适配特定场景,不解决根因就是花式拖延先定位根因,再根据堆大小、停顿要求和机器规格选择收集器
完全依赖本地复现线上数据规模和并发模型本地很难模拟优先用jstack、jmap、GC日志在线上取证
不开GC日志事后只能盲猜,分析工具也救不了你所有服务默认打开GC日志,并定时清理归档

5.3 几个可以沉淀的经验原则

根据我踩过的坑,整理几条JVM调优的原则,供你在实际项目中参考:

调优必须从根因出发。GC只是现象,不是问题本身。先问“为什么垃圾变多了”,再问“怎么让GC更高效”。如果垃圾本来就多,任何GC参数优化都只是给漏水的大坝打补丁。

工具可以用,但不能完全依赖工具。MAT分析堆转储非常高效,但它不会告诉你“这个缓存是业务刚需还是设计缺陷”,最终判断还是靠你自身对代码和业务的理解。

每个参数调整都要有据可查。调整前后的值、调整原因、观察结果,最好都记录下来。一套参数跑了两个季度之后,团队里往往没人记得当初每个设置的含义,文档和提交记录都是救命稻草。

先解决代码问题,再谈JVM参数。这次如果只调参数不修缓存,服务最多是“Full GC次数变少但内存还在缓慢上涨”,最终还是会走到OOM那一步。代码层的缺陷不除,参数优化只是拖延,不是解决。


写到这里,说说个人对JVM调优的感触。很多开发者在刚接触这个话题时,容易把它当成一场“参数玄学”,总觉得江湖上有一套完美的启动参数,抄过来就能压住一切问题。我自己体会到的真相是:所谓经验参数,90%都是普适值,比如-Xms等于-Xmx、CMS阈值设到75这类基础配置;剩下10%必须为具体业务去定制和验证。更重要的,是调优之前把问题定位清楚,调优过程中敢于用灰度验证,调优之后把指标、决策和教训完整沉淀下来。遇到GC异常先别慌,按顺序取日志、抓线程、导堆、看分析报告,顺着线索一层层查下去,问题最终都会水落石出。

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

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

立即咨询