先说个真事。前两年我接手过一个线上服务高峰期接口RT从50ms一路飙到1200ms监控面板上Full GC像心跳一样每两分钟一次整个团队一度怀疑是数据库慢查询结果排查半天慢SQL没问题、缓存也没穿透最后翻GC日志才发现一次Full GC的STWStop The World在极端情况下超过了800ms。那之后我把这台机器上几乎所有能调的JVM参数都调了一遍踩了一堆坑也总结出了一套自己用着很顺手的GC优化流程。这篇文章就是把那套流程写出来聊聊GC优化到底在优化什么、什么时候才值得去调、怎么从日志里读出问题以及最常见的那些“调了等于白调”的坑。先说明白这篇内容主要面向用Java做后端服务的人尤其是被Full GC、STW卡顿和内存问题折磨过的开发者。你要是刚开始接触JVM看不懂参数名会很正常我会尽量把每个参数背后的逻辑讲清楚。文章不会去罗列所有GC回收器的参数大全那没意义我尽量把“判断问题 - 定位根因 - 调整参数 - 验证效果”这条完整链路讲透。1. GC优化到底在优化什么先搞清楚目标和边界1.1 GC优化的本质是平衡不是消灭GC很多初学者一提到GC优化第一反应就是把垃圾回收这件事“干掉”最好服务跑一天完全没有任何GC。这个想法我从一开始就不认同。GC本身是JVM内存管理的核心机制是自动化的“管家”它帮你把没用的对象回收掉释放堆内存。完全没有GC意味着堆无限大也就意味着物理内存无限大这不现实。GC优化的本质其实是两件事一是让每一次GC的暂停时间可接受也就是STW尽量短二是让GC发生的频率可接受别动不动就Full GC导致整个应用频繁“卡死”。换句话说我们做GC优化是在吞吐量和延迟之间找平衡点。举个例子如果你把新生代调得特别大Minor GC的频率会降低但每次晋升到老年代的对象变多老年代GC压力就大Full GC可能更频繁反而得不偿失。我自己在项目中有一个固定的判断公式虽然不是绝对但作为参考很有价值GC的吞吐量 应用运行时间 / (应用运行时间 GC处理时间)。比如你的服务跑了100分钟GC累计花了1分钟那吞吐量就是99%。对于绝大多数后台服务99%以上基本算健康低于95%就需要认真对待了。1.2 怎么判断你的服务需不需要动GC这是我在团队里反复强调的一件事不要因为“听说GC要优化”就去调参。GC调优是有成本、有风险的乱调参数只会引入更多不确定性。我建议先看三个关键指标Full GC频率如果老年代GC一天几次基本正常如果一小时几次甚至几十分钟一次那问题就比较大。单次STW时长G1的目标下单次GC暂停通常在几十到一百毫秒以内。如果频繁出现超过200ms的暂停用户侧基本能感知到卡顿。GC导致的CPU开销GC线程在疯狂跑但业务线程的CPU占比却很低说明大部分资源都耗在了回收上这也是典型的GC不健康信号。我曾经接手过一个内部系统指标表面上看Full GC一天就三四次看起来不频繁但每次Full GC稳定停顿600ms以上高峰期又恰好有大量读写请求结果上游的Feign调用频繁超时。这种就属于“频率不夸张、单次停顿很致命”的场景。所以判断GC有没有问题一定要结合业务场景不能只看单一指标。1.3 内存分配行为比GC参数本身更值得关注做GC优化久了你会发现一个规律大多数GC问题根源根本不是GC参数而是应用代码的内存分配行为。比如循环里不断创建大对象、缓存没有设置过期策略、批量查询把全表数据load进内存、ThreadLocal用完不清理导致对象无法回收……这些问题你靠调JVM参数基本是治标不治本。我个人的排查顺序是先看对象分配再看回收器参数最后才调堆大小。对象分配速率Allocation Rate是一个特别容易被忽略的指标。举个例子一个服务每秒分配500MB对象堆只有2GB那么就算你把回收器调出花来它也只能不停地GC来腾出空间。这种时候先优化代码把不必要创建的中间对象消掉效果立竿见影。2. 回收器选型与核心参数解读2.1 JVM垃圾回收器怎么选G1、ZGC、Parallel的适用场景很多人问我现在到底该用哪个回收器。我的经验是分情况看回收器核心特点适用场景Serial / ParallelSTW时间相对较长但吞吐量高实现简单客户端应用、批量任务、对延迟不敏感的本地环境CMS并发标记清除降低停顿但碎片化严重JDK9后逐渐废弃遗留系统、无法升级JDK的老项目G1分region管理堆可预测停顿时间兼顾吞吐与延迟JDK11服务端默认选择绝大多数中大型应用直接使用ZGC / Shenandoah超低停顿停顿可控制在10ms以内超大堆、超低延迟场景需要较新JDK支持我现在的建议是如果没有特别原因服务端应用直接上G1JDK 17以上甚至可以直接考虑ZGC做压测对比。G1的优势不仅仅是停顿时间可预测还在于它可以设定一个明确的停顿目标比如 -XX:MaxGCPauseMillis200JVM会尽量在这个目标范围内安排垃圾回收对于业务方来说体验会稳定很多。注意不要觉得ZGC一定优于G1。ZGC在低延迟上确实碾压但在CPU开销和吞吐量上未必占优。如果你的服务对吞吐量要求极高且堆内存不大4GB以内Parallel反而可能是更合适的选择。2.2 G1核心参数不是背数字是理解意图G1的参数网上能搜到一大堆但真正值得花时间理解的没几个。我挑重点说-XX:MaxGCPauseMillisG1最核心的目标参数默认200毫秒。它不是硬性限制而是软目标JVM会尽量去达成。如果你服务的RT要求很高可以试着压到100甚至50但代价是GC会更频繁吞吐量会下降。这是一个典型的“拿吞吐换延迟”的杠杆。-XX:G1HeapRegionSizeRegion大小默认由JVM根据堆大小自动计算范围1MB到32MB。Region大小会影响大对象的分配方式如果应用里有很多大对象Region太小会导致大对象直接进入老年代晋升过快。-XX:InitiatingHeapOccupancyPercentIHOP老年代占堆的比例达到这个值时G1会启动并发标记周期默认45%。太高可能导致Full GC过多太低会导致并发标记太频繁CPU开销增大。-XX:MaxGCPauseMillis和-XX:G1NewSizePercent配合使用如果年轻代太小Minor GC频繁太大又容易让年轻代GC停顿超标。一般需要在压测中反复实测。这里我要强调一句GC参数调整时一定要一次只动一个变量。这个原则我反复跟团队说。因为GC的行为就像一个动态系统参数之间相互关联你同时改三个参数出了问题根本分不清是哪个导致的。2.3 堆内存设置的“最小必要”原则很多人的堆内存设置是拍脑袋来的比如“机器64GB内存堆给32GB吧”。实际上堆越大GC的停顿时间通常也越长因为要处理的可达性分析范围更大。所以我更倾向于“最小必要堆”的思路压测时不断压低堆内存找到业务能跑稳的最低值再留20%-30%的余量作为生产配置。我见过一个典型的例子一台16GB内存的机器服务运行只需要4GB堆但有人直接配了-Xms12g -Xmx12g。结果Full GC每次停顿接近2秒原因很简单——标记和清理大量堆区域需要更长的时间而且老年代很大存活对象少但JVM扫描的工作量没少。这种就是在无意识中给自己增加了GC负担。3. 一次线上Full GC案例的完整拆解3.1 现象与初步排查RT飙升背后的真实原因我刚才提到的高峰期RT从50ms飙到1200ms的问题具体现象是这样的某天业务方反馈订单查询接口大范围超时看监控发现GC次数瞬间攀升老年代使用量到了98%之后一直下不来。我当时做的第一件事不是去看代码而是先看GC日志和堆内存使用情况。具体做法是先看监控确认是G1还是别的回收器确认堆大小配置检查老年代占用曲线如果一直高水位说明对象无法被及时回收用jstat -gcutil pid 1000连续打印GC统计看Young GC和Full GC的频率与耗时。jstat的输出里我特别关注FGCFull GC次数和FGCTFull GC累计时间这两个字段。如果FGC增长很快FGCT也在快速膨胀基本说明老年代回收出了大问题。3.2 jstat与jmap定位内存分配异常当时用jstat看到的数据大致是Young GC频率不算高每次大概10ms但Full GC每2-3分钟一次每次700ms以上老年代使用率在Full GC后只从98%降到92%左右。这个问题很典型老年代回收效率极低说明老年代里存在大量无法回收的对象或者是对象分配速率太快导致每轮GC还没清完就又塞满了。接着我用jmap -histo:live pid | head -50查看存活对象分布发现有一个自定义的ReportCache类占了大头。再看代码这个缓存是个静态Mapkey是用户IDvalue是一个大对象存了一整天的报表数据而且没有过期清理机制。到了高峰期用户一多这个Map就撑爆了老年代。到这里就清楚了这其实是一个典型的内存泄漏/对象长期存活问题而不是单纯的GC参数问题。我们做的第一步不是调参而是让业务同学给这个缓存加上TTL过期策略同时将大对象拆分成更小的维度缓存。3.3 参数调整过程从-Xmx到G1细化修复缓存问题之后GC情况好了很多但高峰期Full GC依然偶尔出现一次。这时候我才开始动参数。原来的启动参数是-Xms4g -Xmx4g -XX:UseG1GC没别的了。我按顺序做了下面这些调整把停顿目标显式化-XX:MaxGCPauseMillis100先把停顿目标压到100ms让JVM更积极地控制单次GC时长调整IHOP因为业务的特点是突发流量对象在高峰期突然暴增我把-XX:InitiatingHeapOccupancyPercent从默认的45降到35让并发标记更早启动避免老年代直接爆掉触发Full GC限制并发GC线程-XX:ConcGCThreads2避免GC线程和业务线程抢CPU太凶。在这一步我只同时动了这三个参数然后在预发环境压测了几天观察GC日志和RT曲线。调整后Full GC基本消失G1更多地通过并发标记和Mixed GC完成回收高峰期RT也稳定在80ms以内。3.4 升级ZGC的探索与性价比分析后来这个服务进一步压延迟我们又用JDK 17试了ZGC直接设-XX:UseZGC没有配其他额外参数。实测下来停顿时间确实从几十毫秒降到了5ms左右但CPU开销上升了15%。对这个服务来说延迟指标更敏感这点CPU成本可以接受所以最终线上切到了ZGC。但我得提醒一句ZGC不是免费的午餐。如果你的服务CPU资源本来就紧张或者堆内存小、GC频率低那么ZGC带来的收益会非常有限反而白白消耗CPU。我建议上ZGC之前先做A/B压测对比G1和ZGC的RT、TPS和CPU开销再用数据说话不要盲目追新。4. GC日志分析工具、指标与正确读法4.1 打开GC日志的正确姿势GC日志是分析GC行为的第一手资料不打开日志做GC优化就像盲人摸象。好消息是从JDK 9开始GC日志已经统一用-Xlog来管理用法比老版本清晰很多。我一般会用下面这组参数-Xlog:gc*:filegc.log:time,uptime,level,tags:filecount10,filesize20m解释一下gc*表示输出所有GC相关日志filegc.log表示写入文件避免刷爆控制台time,uptime,level,tags决定每条日志带上时间戳、运行秒数、级别和标签filecount10,filesize20m是日志轮转配置防止单个日志文件过大。生产环境我强烈建议加上日志轮转不然gc.log能撑爆磁盘。4.2 GC日志核心字段每行日志都能读出什么看G1日志时我重点关注这几类信息GC pause (G1 Evacuation Pause)年轻代转移暂停。日志里会显示Pause Young (Normal) (G1 Evacuation Pause)后面跟着XX ms这个值就是STW时间。GC pause (G1 Humongous Allocation)巨对象分配暂停。如果频繁出现这种日志说明应用在频繁创建超大对象Region无法容纳需要特别注意。Concurrent Cycle并发标记周期。包含Concurrent Mark、Concurrent Root Region Scan等子阶段这些是并发执行的不阻塞业务线程但会占用CPU。Full GC (Allocation Failure)这是最需要警惕的。一旦出现说明G1的并发收集已经跟不上分配速度只能回退到Full GCSTW时间会非常夸张。举个例子一条典型日志[2025-04-01T10:00:00.0000800][24.567s] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 1024M-512M(2048M) 80.123ms这表示在第24.567秒发生了一次Young GC堆内存从1024MB降到512MB总堆大小2048MBSTW时间为80.123ms。如果这种日志频繁出现且耗时稳定说明Minor GC本身没问题如果偶尔出现耗时200ms以上的就需要关注并发标记和回收线程的状态了。4.3 从日志指标到调参决策一个简单的速查思路我自己总结过一组从GC日志到调参决策的映射关系简单列一张表日志现象可能原因优先尝试的调整Young GC频繁且堆未满年轻代太小适当调大年轻代比例或G1NewSizePercentYoung GC后老年代增长过快对象晋升过早检查大对象分配调整RegionSize或MaxTenuringThresholdMixed GC频繁但老年代持续高水位IHOP设置过小或存活对象太多调高IHOP同时排查长期存活对象Full GC频繁且耗时极长老年代爆满或内存泄漏先排查泄漏再考虑堆内存扩容STW稳定但RT依旧高问题可能不在GC而是锁、IO或网络回到业务线程用Arthas等排查线程状态这张表不一定覆盖所有场景但能解决我日常遇到的大多数问题。4.4 好用不贵的GC分析工具说实话生产环境的GC日志动辄几百兆光靠人眼盯不现实。我常用的工具就三个GCeasy在线GC日志分析工具直接把gc.log拖进去几秒出报告。Heap大小变化、STW时间分布、GC原因占比、推荐参数一条龙全给你列出来。排查问题时效率极高。jstat轻量级命令行工具适合快速看一下当前JVM的GC状态和类加载情况。Arthas阿里开源诊断工具虽然不是专门的GC工具但dashboard命令可以实时看堆内存、GC次数和线程状态在线排查特别顺手。这些工具搭配使用基本能覆盖从“日志分析”到“在线诊断”的完整链路。如果你用的云厂商有Java监控上报也可以直接把GC Time接入监控大屏做趋势分析提前发现隐患。5. 那些让你白忙一场的GC陷阱与避坑经验5.1 显式System.gc()全公司最熟悉的陌生人不知道多少人在代码里写过System.gc()想着“手动触发一次回收应该挺有用吧”。但实际上System.gc()只是“建议”JVM执行Full GCG1下它会直接触发Full GC带来一次很可观的STW停顿。更麻烦的是有些框架和中间件会把System.gc()和堆外内存回收绑在一起你一调用可能连堆外内存管理逻辑都跟着遭殃。我经手过的最离谱的一次事故就是某个定时任务里为了“释放内存”而定期System.gc()结果每到整点接口集体超时。排查了大半天才找到这个“罪魁祸首”。我的建议很简单如果不是明确知道自己在干什么别调System.gc()真需要主动触发Full GC的场景99%的普通业务都不会有。5.2 堆外内存、元空间与DirectByteBuffer的隐形成本GC日志里看不到堆外内存的使用情况导致很多人忽略了堆外内存对GC的间接影响。DirectByteBuffer、Netty用的堆外内存、JNI分配的内存这些不归堆管但会受GC回收的影响。当堆外内存不足时Cleaner机制会触发Full GC来尝试回收堆外资源这就出现了一个诡异现象老年代明明没满却反复Full GC。另外元空间Metaspace的容量不足也会触发Full GC。常见的触发场景是热部署或者动态生成大量代理类元空间蹭蹭往上涨一旦达到-XX:MaxMetaspaceSize上限就会触发Full GC去卸载类。遇到这类问题首先要区分是堆内还是堆外因素不然你在堆参数上白忙半天。5.3 GC线程数配置的隐藏影响-XX:ParallelGCThreads和-XX:ConcGCThreads这两个参数直接决定了GC阶段占用多少CPU。默认情况下ParallelGCThreads会取CPU核心数的5/8左右。如果你不想让GC线程和核心业务线程抢资源可以适当降低尤其在高并发的Web服务上给业务线程留足CPU比什么都重要。但这里有个反直觉的地方如果你把GC线程数压得过低GC耗时反而会变长STW照旧甚至更糟。所以这是一个“在压测中找最优点”的参数不是线性调优的。我在一个16核的机器上服务对延迟敏感最终把ParallelGCThreads调成6ConcGCThreads调成2停顿时间从平均80ms降到60ms左右但CPU也始终稳定在一个可控范围内。5.4 内存泄漏的锅GC参数不背我最想强调的一点是GC优化永远排在内存量分析和代码优化之后。如果你有一个静态集合不断往里塞数据或者ThreadLocal没清理那么就算你堆内存翻三倍问题迟早还会复现只是时间往后拖了。遇到老年代持续高水位、Full GC越来越频繁的情况先用jmap把heap dump拉下来用MAT或VisualVM分析谁占着内存。如果heap dump里某个业务对象占了70%以上内存那就先改代码再谈调参。内存泄漏比GC停顿可怕得多因为它会慢慢吃光你的资源直到整个应用不可用。5.5 常见问题速查表问题现象排查思路对应处理方向Full GC频率突然升高先看GC日志确认原因再jmap看对象占用优先排查代码缓存、大对象、连接池老年代居高不下heap dump分析占用Top对象定位长期存活对象修复后观察Young GC慢且频繁G1日志看耗时、观察Region数量调整年轻代比例或RegionSize元空间触发Full GC检查动态代理类加载量调大MaxMetaspaceSize或优化类加载高并发现rt大幅波动Arthas看线程状态区分GC与锁竞争确认是否真是GC停顿导致再对症下药低CPU但GC耗时高看GC线程数和并发标记配置调整ParallelGCThreads、ConcGCThreads写在最后的一点体会做了这几年性能优化我最深的感受是GC优化最难的其实不是参数而是诊断思路。你得先搞清楚服务到底是分配过快、存活对象过多、还是回收器配置不合理把问题分类定清楚再去动参数。每一个参数背后都有代价MaxGCPauseMillis压得太低吞吐量可能受影响堆开得太大停顿时间可能反而变长换ZGC能换来低延迟却要付出更高的CPU开销。在那次订单查询服务的故障处理里我们最终通过三步走解决了问题先修业务代码里的缓存问题再显式设置G1停顿目标最后在充分压测的基础上测试了ZGC。整个过程没有一步是“玄学调参”完全是顺着数据一层层剥开问题的。所以如果你想给团队留下点长期有效的东西比起贴一段调好的JVM参数不如把GC日志分析的习惯、内存分析的流程、一次只动一个参数的原则传递下去。参数会过时JDK会更新但这一套分析思路到哪个版本都不会变。