做Java开发这些年,我每一次认真研究垃圾回收调优,几乎都是被线上Full GC和飙升的接口延迟逼出来的。上周又遇到一次典型的JVM性能瓶颈:一个订单服务从P99 80ms一路涨到2.3秒,老年代曲线像坐了火箭,Full GC从半小时一次变成两分钟一次。这次我没有急着加机器,而是打开GC日志,按照一套固定流程定位问题、调整参数、验证效果,最后让服务恢复平稳。这篇文章就是那套流程的完整复盘:从JVM内存模型和垃圾回收底层原理讲起,再到调优参数设计和真实线上排查案例,适合正在被高延迟、Full GC折磨的Java工程师,也适合面试时被问到"JVM调优你做过什么"却不知道如何组织答案的同学。
1. 先找准病根:GC调优前必须吃透的JVM内存模型
1.1 对象在堆里如何流动:新生代、老年代与元空间的分工
很多人调JVM参数之前,连一次GC日志都没看过,上来就改-Xmx、改-XX:MaxGCPauseMillis,结果调完比不调还糟糕。原因很简单:你连对象在堆里是怎么流动的都不清楚,参数改得再花哨也只是碰运气。
JVM的堆内存主要分两大块:新生代和老年代。新生代里又分为一个Eden区和两个Survivor区(S0和S1),默认比例是8:1:1。绝大多数new出来的对象会先落在Eden区,如果Eden区放不下,大对象可能直接在老年代分配。Eden区快满时触发Young GC,活下来的对象进入Survivor区,并且年龄加1。每经历一次Young GC,存活对象的年龄加1,当年龄达到阈值(默认15,但JVM会根据实际情况动态调整)或者Survivor区装不下时,对象就晋升到老年代。大对象,比如那些很大的数组,为了避免在Survivor区反复复制,也会直接进入老年代。元空间不在堆内,主要存储类元信息和方法数据,默认受本地内存限制,如果这块区域耗尽,会报Metadata space溢出的错,那属于另一类问题。
为什么要把堆分成这么细?因为有研究数据表明,绝大部分对象的存活时间非常短,可能创建出来没几个毫秒就变成垃圾。用两个Survivor区配合复制算法,可以高效地把这些"短命对象"清理干净,只保留少数"长命对象"进入老年代。老年代里的对象存活率高,如果也用复制算法,复制成本太高,所以老年代普遍采用标记-整理或者并发标记的方式处理。
还有一个常见误区:JRE和JVM不是一回事。JRE是Java运行环境,包含JVM、核心类库和启动命令;JVM是JRE内部负责执行字节码、管理内存的部分。垃圾回收只是JVM内存管理的一个环节,但恰恰是最影响运行表现的部分。
1.2 标记、清除、复制与整理:三种基础算法如何配合
GC要做的事情,本质上是回答两个问题:哪些对象还活着?哪些对象已经可以被回收?判断存活的基础,是从GC Roots出发做可达性分析。GC Roots包括线程栈上的局部变量、静态字段引用、JNI引用等,凡是能从这些根出发到达的对象,都算"活的"。
三种基础算法各有取舍:
标记-清除:先标记可达对象,再清除不可达对象。实现简单,但会产生内存碎片。碎片一旦变多,分配大对象时触发老年代GC的概率就会升高,所以我们很少在Java堆里完全靠这种算法打天下。
标记-复制:把存活对象复制到另一块区域,然后整体清空原区域。新生代经常用这种方式,效率高、没有碎片,代价是浪费一部分空间(Survivor区本来就是留一块用来复制的)。
标记-整理:把存活对象向一端移动并整理出连续空间,老年代常用。没有碎片问题,但移动对象本身有开销,而且同样需要STW。
不管哪种算法,GC的关键瓶颈都在于Stop The World。为了准确记录对象引用关系,GC时必须暂停应用程序线程,这是物理层面的约束,不是哪个回收器能完全绕开的。所谓调优,不是消灭STW,而是把STW的频率和单次时长控制在业务能接受的范围里。
理解到这层,再看网上那些参数贴子就会清醒很多:堆开多大、Survivor比例怎么设、晋升阈值改不改,本质上都是在调整"短命对象的清理成本"和"长命对象的晋升速度"之间的平衡。
1.3 用数据说话:jstat、jmap和GC日志的使用门槛
调优的第一个动作是收集数据,不是改参数。JDK自带的工具完全足够完成这一步。先找到Java进程PID,用jstat看实时GC情况:
jstat -gcutil 12345 1000输出里S0、S1、E、O分别是Survivor区、Eden区和老年代的使用百分比;YGC和YGCT是Young GC次数与总耗时,FGC和FGCT是Full GC次数与总耗时。如果O列持续在90%以上、怎么都降不下来,基本可以断定老年代压力异常,Full GC很快会来。
想看堆整体配置,用jmap -heap 12345,能看到新生代、老年代当前容量和参数。想看正在运行的JVM到底生效了哪些参数,用jinfo -flags 12345。
GC日志建议从一开始就打开。JDK 9以后统一采用-Xlog参数,例如:
java -Xlog:gc*:file=/opt/app/logs/gc.log:time,uptime,level:filecount=5,filesize=20m -jar app.jar这段的意思是:记录所有gc相关日志,输出到指定文件,最多保留5个文件、每个20MB。上线时就把这行写进启动脚本,等出问题的时候才不至于两眼一抹黑。我见过不少团队,线上服务GC日志没开,等Full GC把服务打挂之后,才发现拿不到任何现场信息,只能重启了事。Java垃圾回收调优的第一步永远是建立可观测性。没有数据,后面所有操作都是瞎猜。
2. 垃圾回收器怎么选:主流回收器的设计逻辑与场景边界
2.1 主流回收器各有各的脾气:从Serial到ZGC
不同JDK版本默认回收器不一样。很多人还在用JDK 8,默认是Parallel;JDK 9以后默认切换成G1。如果不了解默认行为,直接套网上那些"XX参数调优"很可能南辕北辙。
Serial是最朴素的单线程回收器,适合客户端程序、单核小内存场景,现代后端基本不碰。
Parallel(-XX:+UseParallelGC)是JDK 8默认的吞吐量优先回收器,Young GC用多线程复制,Old GC用多线程标记-整理。它的设计目标是最大化吞吐量,不太在乎单次停顿时间,适合后台批处理、离线计算这类对延迟不敏感的任务。我自己跑离线报表任务时,用Parallel反而比G1更稳。
CMS(-XX:+UseConcMarkSweepGC)在JDK 9被标记废弃、JDK 14被移除。它的初衷是减少老年代GC的停顿,用并发标记代替大部分串行标记,但引入了浮动垃圾和严重的内存碎片问题,最终被G1取代。如果你维护的旧系统还在用CMS,建议尽早规划迁移到G1或ZGC。
G1是JDK 9之后的默认回收器,核心是把堆划分成很多小块Region,根据每个Region里垃圾的比例动态决定优先回收哪些Region。它提供了可预测的停顿时间模型:通过-XX:MaxGCPauseMillis给出目标停顿,G1在每次回收时挑选回收收益最大的Region集合。我用G1处理过不少4G-16G堆的服务,整体调节空间比Parallel大很多,也是目前后端最主流的选项。
ZGC是面向超大堆低延迟场景的回收器,目标是让STW控制在10毫秒以内,而且停顿时间基本不随堆大小增长。它使用染色指针和读屏障实现并发转移,代价是需要额外的CPU开销。如果机器核数富裕,堆又有十几个G,体验会很好。
不同回收器的适用场景差异很大,我用下面这张表做个总结:
| 回收器 | 设计目标 | 适用场景 | 明显缺点 |
|---|---|---|---|
| Serial | 简单单线程 | 客户端、小堆 | 单次停顿久 |
| Parallel | 吞吐量优先 | 批处理、离线计算 | 单次停顿不可控 |
| CMS | 减少老年代停顿 | 已淘汰,旧系统残留 | 碎片、浮动垃圾 |
| G1 | 可控停顿+兼顾吞吐 | JDK 9+默认,通用后端服务 | 参数和调优逻辑复杂 |
| ZGC | 超低延迟 | 大堆在线服务、低延迟要求 | CPU占用偏高 |
2.2 先定目标再选回收器:吞吐量和低延迟怎么权衡
这里的"权衡"不是绝对二选一,但确实需要按业务权重取舍。吞吐量的意思是,百分之多少的CPU时间花在处理业务上,计算公式大致是:有效运行时间 /(有效运行时间 + GC时间)。低延迟看的是每次GC造成的停顿有没有让请求变慢、超时。
如果是凌晨跑批处理、日志清洗、离线报表,优先级显然是吞吐量,Parallel往往比G1更合适。如果是用户能直接感知的接口服务,目标是P99稳定,那G1或者ZGC是更合理的选择。
选完回收器,对应的参数体系也不一样。Parallel系列调优主要看堆大小和代际比例;G1调优重心是停顿目标、Region大小和新生代占比范围;ZGC的调优空间相对小,重点是把堆初始值和最大值设一致、避免动态扩容,然后交给它自动管理。
2.3 换回收器前,先确认GC到底是不是瓶颈
有一类项目,GC看起来频繁,但实际上罪魁祸首是代码里的锁竞争或者数据库慢查询。如果不做判断,盲目把Parallel换成G1甚至ZGC,业务延迟该高还是高,只会留下一个"调过JVM也没用"的错误印象。
我的判断方法很朴素:打开GC日志,算两笔账。第一步,看GC总耗时占比。假设服务运行了30分钟,Young GC累计耗时3秒,Full GC耗时1秒,GC总占比约0.2%,那GC绝对不可能是主要瓶颈。第二步,看GC发生的时机。如果每次P99飙高的时间点都伴随YGC,那要关注单次停顿和分配速率;如果和YGC没有明显对应,更值得查的是线程池、锁或下游依赖。
只有当GC总时间占比明显偏高(比如超过5%),或者单次停顿已经超过业务容忍线,才值得进入调优环节。否则,调优还没开始,方向就已经错了。
3. 调优参数如何落地:一套能直接抄作业的流程与验证方法
3.1 第一步:把基线数据留下来,启动参数怎么打
我见过最普遍的问题是没有基线。启动脚本里没有GC日志,监控面板上也没有堆指标,等压测完或者线上故障发生时,只能凭记忆说"好像是GC的问题"。
正确做法:上线或者压测前,先在启动脚本里加上完整的观测参数:
java -Xms4g -Xmx4g -XX:+UseG1GC \ -XX:MaxGCPauseMillis=100 \ -Xlog:gc*:file=/opt/app/logs/gc.log:time,uptime,level:filecount=5,filesize=20m \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/opt/app/logs/ \ -jar order-service.jar这里有两个非常关键的生产参数:-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath。一旦OOM,JVM会自动把当时的堆快照落盘,后面分析内存泄漏全靠它。没有这两行,OOM时你连现场都找不到。
跑一段时间压测或观察线上后,记录三个基础数据:Young GC频率和单次停顿、Full GC频率和单次停顿、老年代占用曲线。这就是调优前的基线,后面任何一次参数变更,都要拿它来对比。
3.2 第二步:按观察拆分两类场景,针对性调参
场景A:接口服务,堆4G,QPS高,YGC每隔几秒一次,每次20ms左右,Full GC偶尔出现但单次停顿1秒以上。分析下来,要么是对象分配速率太高,要么是新生代空间偏小。可以先微调新生代占比,给G1的年轻代更多动态空间:
-XX:G1NewSizePercent=10 -XX:G1MaxNewSizePercent=60注意,G1默认新生代范围是5%到60%,大部分情况不需要手动指定。如果观察发现新生代总是被压得很小才触发YGC,可以试着调高G1MaxNewSizePercent,让G1在每次分配时有更多年轻代可用。但不要一上来就改,先判断是不是对象分配速率过高。
如果YGC后大量对象快速晋升老年代,表现为老年代占用曲线被反复"冲高",那要关注晋升阈值。G1的年龄阈值默认是动态调整的,手动指定-XX:MaxTenuringThreshold意义有限。真正有效的是从代码层面检查:是不是有频繁出现的临时大集合、字符串拆分拼接、或者那些生命周期比预期长很多的"短命对象"。
场景B:缓存型服务,堆20G,老年代常年高位,Full GC一次3秒。分析下来,堆太大导致GC时STW时间过长,可能是缓存设计不合理,或者对象生命周期确实很长。这种场景我不会尝试把G1调成"永远不Full GC",而是直接评估换ZGC:
java -Xms20g -Xmx20g -XX:+UseZGC \ -Xlog:gc*:file=/opt/app/logs/gc.log:time,uptime,level:filecount=5,filesize=20m \ -jar cache-service.jarZGC的优点是,哪怕堆涨到几十G,它依然能把STW控制在很低的范围。代价是GC线程会占用一些CPU,如果机器只有4核,ZGC未必合适。
每个项目差异很大,参数只能作为起点。我的原则是:一次只改一个变量。改多个参数遇到问题,你根本没有办法定位是哪一步导致了恶化。
3.3 第三步:验证效果,别用平均值骗自己
调完之后不能只看"Full GC好像少了",要看业务指标和GC指标是否同步改善。JDK 11+自带JFR(Java Flight Recorder),启动时加一行:
-XX:StartFlightRecording=filename=/tmp/app.jfr,duration=60s或者线上动态启用:jcmd 12345 JFR.start name=test filename=/tmp/app.jfr duration=60s,跑完用JFR看GC暂停时间趋势。
我更推荐直接在压测里做一组对照组:调优前压测10分钟,记录P99、YGC次数、FGC次数、GC总耗时;调优后同样流量压10分钟,再对比。假设压测前GC总耗时占运行时间8%,P99 200ms;调优后GC总耗时降到3%,P99回到120ms,这才是有效的调优。
如果GC指标改善了,但业务P99反而变差,说明你优化的方向和业务瓶颈不完全一致,要继续查代码逻辑、锁、数据库。
4. 线上Full GC高发案例复盘:从告警到根因的完整链路
4.1 现象与初判:GC日志里藏着最直接的线索
案例背景:一个订单查询服务,8C16G,堆设了6G,用了G1,平时P99在80ms左右。某天上午10点运维告警:接口P99涨到2.3秒,Full GC频繁,服务接近不可用。
我先做的不是看代码,而是看GC日志。日志里有大段这样的内容:
[Full GC (Allocation Failure) 5445M->5340M(6144M), 1.45s]关键信息是:Full GC之后,堆从5445M只降到5340M,一次Full GC总共才释放了100M。老年代几乎纹丝不动,这是典型的内存占用性上涨,而不是临时流量冲击。如果是流量冲击,Full GC后老年代应该明显下降,因为短命对象已经被清理了。
接着看jstat:
jstat -gcutil 12345 1000O列在97%左右,FGC从一次变成每两分钟一次,FGCT仍在增长。同时在监控里确认QPS没有暴增、最近没有上新版本。到这里,我的初步判断已经偏向:存在某种对象被长期持有,导致老年代只升不降。下一步就是找到谁在持有这些对象。
4.2 堆转储与MAT分析:把疑似内存泄漏钉死到具体代码
对一个生产服务做堆转储要谨慎,因为dump瞬间可能影响线上。我的建议是:优先在压测环境复现,或者选择低峰期操作,并且使用jcmd代替jmap:
jcmd 12345 GC.heap_dump /tmp/heap.hprof拿到hprof文件后,用Eclipse MAT打开。先看Leak Suspects报告,MAT会根据支配树找出占用堆最大的对象网络。在这个案例里,报告指向一个HashMap,占了整个堆的82%。
顺着Dominator Tree往下钻,找到一个叫OrderCache.instance的静态Map,里面存了近千万条订单实体对象。这个Map本意是减少数据库查询,但写入时没有容量限制,也没有过期策略。订单数据每多一单就往里放一条,老年代被这些对象逐渐塞满,最终触发频繁Full GC。
这里分享一个MAT实用技巧:如果Leak Suspects不够直白,可以使用OQL(Object Query Language)直接查询:
SELECT * FROM INSTANCEOF com.example.Order然后用MAT的"List objects -> with incoming references"一层层往上看,是哪个类引用它。这种逆向追踪方式,在排查生产环境堆时非常管用。
4.3 修复方案与上线后验证:缓存也要有寿命
根因是缓存设计问题,跟JVM参数其实没有直接关系。修复动作有两个层面:代码层面,把无界Map换成Caffeine本地缓存,限制最大条数和过期时间;JVM层面保留G1和原有的6G堆,不再做额外调参。
Caffeine示例:
Cache<String, Order> orderCache = Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofMinutes(30)) .build();上线后跑了一轮压测:Full GC消失,老年代稳定在2G以下,YGC照常发生但单次停顿在20ms左右,接口P99回到85ms。之后连续监控一周,FGC次数为0,堆占用曲线呈现健康的锯齿状(涨上去又降下来),问题才算真正关闭。
这个案子给我的教训是:很多被误判为"JVM参数问题"的线上故障,根因都在应用代码。调参只是止血,找到并修复对象持有的源头才是根治。
5. 我踩过的调优误区和四条经验建议
5.1 误区一:堆越大越好、参数越激进越好
堆从4G调到8G,看起来YGC频率降低了,但单次停顿可能从500ms变成1.2秒,接口超时反而更严重。因为单次STW会直接卡住请求线程,堆越大,每次GC要处理的对象越多,停顿时间通常也更长。合理堆大小要考虑对象分配速率、存活对象总量和GC停顿目标。与其盲目扩堆,不如先清理无用对象。
还有一个常见操作:在代码里调用System.gc(),希望主动触发一次GC来释放内存。这通常适得其反。System.gc()只是建议JVM执行Full GC,不保证立即执行,而且显式Full GC往往造成不必要的停顿。RMI、NIO等框架可能隐式调用,这也是为什么有些团队会加-XX:+DisableExplicitGC来屏蔽显式调用。加之前要先确认不影响业务逻辑,尤其要确认第三方库是否依赖System.gc()来腾内存。
5.2 误区二:把"没有GC"当目标,忽略长尾延迟
有人在压测后开心地告诉我:"我把FGC调成了0!"但YGC每秒钟都在发生,单次停顿虽然小,GC总耗时却很高。目标应该是让GC停顿不影响用户体验,不是追求0次GC。只要程序一直在new对象,GC就不可能消失,也没必要消失。
还有一类错误是只盯平均值。平均停顿100ms,看起来很优秀,但P99.9可能已经到5秒。长尾延迟对用户体验的杀伤力,远大于平均值。调优前后,我建议都打开百分位指标,专门看P99和P99.9,而不是只看平均耗时。
5.3 我的四步调优心法:先量化、再定位、后调参、终验证
这四个词的顺序不能反。量化——GC日志必须开,jstat定期记录,最好接上监控大盘;定位——根据老年代曲线、FGC日志和堆转储,回答"是内存泄漏、晋升过快还是堆太小";调参——一次只动一个参数,改完记录变更;验证——用压测数据和业务指标确认收益,不行就回滚。
日常开发中,我给团队的建议更简单:缓存一定要设上限;大循环和批量任务里不要创建大量临时对象;能用基本类型就别装箱;日志框架尽量用异步,减少对象拼接。这些代码习惯,本身就是在给GC减负。
最后说一句面试相关的观察。被问到"JVM调优你做过什么",最容易得高分的回答不是报参数名,而是按"现象-分析-定位-调参-验证"讲一个真实案例。哪怕你只是调大过新生代,也比干巴巴背一百个-XX参数强,因为面试官想看的就是你有没有完整的问题排查闭环。
我在实际项目里调完GC参数之后,一定会再做一件事:把当时的GC日志、参数变更和压测结果归档,写一段"为什么这么调"的说明。下次再遇到类似问题,或者线上出了新变化,这份记录能帮全组少走很多冤枉路。JVM调优没有银弹,但有了数据和思路,性能瓶颈基本都能被一层层剥开。