
凌晨两点多监控群突然炸了。优惠券系统的告警面板上Full GC次数在5分钟内拉出12次尖峰服务RT从80ms直接飙到3秒紧接着就是一连串超时和重试。我打开GC日志看到连续三次超过1秒的Pause Full第一反应不是去改-Xmx而是先问一句这批对象到底是从哪来的。不管你是刚接触JVM调优还是已经处理过几轮生产事故GC优化都会碰到类似的场景。很多人一听说卡顿就上去加堆内存、换G1结果堆从4G加到8GFull GC确实少了但单次停顿反而更长——因为根本没搞清楚GC到底在优化什么。这篇内容我不会只罗列参数而是把我们平时处理GC问题时的决策顺序、排查链路和一些不太会写进文档里的坑一起讲清楚。适合正在做Java后端服务、被GC告警追着跑或者准备系统性学习JVM调优的工程师参考。1. 先弄明白GC优化的边界不是所有卡顿都该让GC背锅1.1 垃圾回收的本质停顿从哪里来Java的堆内存管理是自动的对象不再被引用之后由垃圾回收器负责回收。听起来很完美但几乎所有垃圾回收器在做回收时都必须保证对象视图一致否则一边业务线程还在改引用另一边回收器在移动对象两边就乱套了。所以JVM要选择一个安全点把所有业务线程都停下来也就是Stop-The-World简称STW然后才能做可达性分析和对象整理。这一段暂停时间就是我们感受到的卡顿。GC优化本质上就是和STW做斗争。但要注意STW不可完全消除只能压缩和分摊。串行回收器会把整个堆扫一遍停顿自然长并行回收器用多线程加速但对单次GC来说还是全停顿G1通过分区和增量回收来控制停顿上限ZGC则通过染色指针和读屏障把大部分工作挪到业务线程并发执行。每一代GC的演进本质上都在做同一件事把STW的时间缩短、把大停顿拆成小停顿。但这里有个容易误解的地方STW越短不代表GC性能越好。为了缩短STW回收器通常要牺牲吞吐量比如更频繁地做并发标记、更细粒度地维护记忆集。如果业务对延迟不敏感比如批处理任务、离线分析那么Parallel GC的高吞吐反而更合适。调优前先问业务要什么再选GC这个顺序不能反。1.2 分配速率和存活对象GC压力的真正来源很多人调GC只盯着堆大小这是个误区。堆大小只是容器真正决定GC压力的两个变量是对象分配速率和存活对象总量。分配速率是指单位时间内新对象占用的内存大小。分配速率越高Eden区越快被填满Minor GC就越频繁。存活对象总量是指每次GC后仍然存活的对象大小它决定了晋升到老年代的量也直接决定Major GC或Full GC成本。一个秒杀系统在高峰期每秒分配几百MB对象如果这些对象大部分是短命的请求上下文Minor GC虽然多但成本可控怕的是高分配速率叠加高存活对象比如线程池任务里每个请求都往一个全局缓存里塞大对象那老年代就会被快速填满Full GC迟早爆掉。我常用一个比喻GC调优就像管办公室的垃圾桶。如果所有人每分钟都扔十个小纸团垃圾桶一分钟就满了保洁员不得不反复来倒如果垃圾里还混着大量不能扔的合同原件那保洁员每次都得停下来分类耗时更久。调GC参数相当于换个更大的垃圾桶、找一个更勤快的保洁员、优化清扫路线但如果你不阻止有人往垃圾桶里扔不能扔的东西所有优化都是白搭。所以每次GC问题排查我第一个看的不是GC参数而是对象分配和存活曲线。jstat的S0/S1/E/O变化、GC日志里的前后堆占用差值、甚至jmap -histo里Top对象列表都比盲目调参更能说明问题。GC参数能解决的是回收效率问题代码层面的对象滥用只能靠代码修复。2. 收集GC数据没有日志的调优就是瞎调2.1 正确配置GC日志JDK8和JDK11以上写法都别搞错线上环境没有GC日志出了问题只能靠猜这是大忌。不同JDK版本的GC日志参数差异很大很多老项目还停在JDK8新服务已经跑在JDK17上写法不能混用。JDK8及以前常用的参数组合是-XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC \ -Xloggc:/data/logs/gc.logJDK9开始引入了统一日志框架-Xlog原来的PrintGCDetails已经废弃。JDK11及以上建议这样配-Xlog:gc*:file/data/logs/gc.log:time,uptime,level,tags:filecount10,filesize50m这里gc*代表输出GC相关所有tag的日志filecount和filesize是日志轮转配置避免GC日志把磁盘写满。我见过因为GC日志没配置轮转几个月后把磁盘占满导致服务挂掉的案例属于典型的排查故障的配置反而制造了故障。另外建议配合这两个参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof一旦发生OOM会自动留下堆快照省去事后想dump但进程已经没了的尴尬。容器环境里最好再让脚本把cgroup的内存限制也记录下来否则分析RSS超限问题时缺数据。2.2 读懂一条GC日志字段背后的含义看GC日志很多人只关注停顿多少毫秒其实里面的信息量远不止这些。拿G1的日志举例[2025-02-18T02:15:30.1230800] GC(12) Pause Young (Normal) (G1 Evacuation Pause) [Eden: 1024.0M(1024.0M)-0.0B(1024.0M) Survivors: 64.0M-64.0M Heap: 2500.0M(8192.0M)-1600.0M(8192.0M)] [Times: user0.25 sys0.03, real0.04 secs]这条日志说明这是一次年轻代回收Eden区从占用1024M变成0说明所有Eden对象都被处理了Survivor区回收前后都是64M说明有一部分对象存活并停留在Survivor没有被晋升整个堆从2500M降到1600M说明回收掉了900M垃圾。最后一行real0.04 secs才是业务线程真正的停顿时间也就是40ms。看日志时我还会特别关注三件事第一晋升对象量。如果某次Young GC后老年代占用明显上涨说明短命对象正在进入老年代这是老年代提前打满的前兆。第二停顿分布。单次停顿100ms不可怕可怕的是每5分钟来一次100ms还是每天凌晨来一次2秒的Full GC。前者影响可用性后者可能是定时任务或缓存刷新的问题。第三老年代的回收进度。G1日志中的Pause Full (G1 Compaction Pause)出现就说明回收器已经兜不住了这不是正常回收而是降级成了类似Serial Old的全堆整理停顿通常以秒计。配合工具看会更直观。我常用GCViewer加载日志文件能看到各区域占用曲线和停顿分布gceasy.io则适合快速生成报告但生产数据上传前记得脱敏。2.3 建立基线调优前先收集一周数据没有基线的GC调优是自嗨。改一个参数前至少要记录以下指标一周以上Full GC频率、平均/最大STW时间、老年代占用曲线、晋升速率、CPU使用率。为什么要跑满一个业务周期因为很多服务的GC特征跟流量和定时任务强相关。工作日白天和凌晨的GC表现可能完全不同只说最近GC有点多但不知道高峰期多少、低峰期多少根本没法判断改动是否有用。我习惯把GC日志采集做成标准动作新服务上线第一天就必须有GC日志并且在监控面板上把GC耗时和Full GC次数作为基础指标显示。这样出问题时至少能回看一周的数据确认规律而不是在告警发生时手忙脚乱。3. Full GC频繁的线上排查一次完整链路复盘3.1 从告警到heap dump的完整定位过程前面提到的优惠券系统事故就是一次非常典型的Full GC排查。当时的处理过程我拆成几个步骤这个套路后来也反复在用。第一步看监控确认是全局性问题还是单实例问题。如果是某个节点的老年代使用率持续上涨而其他节点正常优先怀疑这个实例的负载不均衡或本地缓存异常而不是整个应用的GC配置有问题。第二步用jstat连续采样jstat -gcutil pid 1000 10重点观察O老年代使用率、FGCFull GC次数、FGCTFull GC累计耗时。当时采样结果很明确老年代使用率从60%一路涨到95%每次Young GC后都有几十MB对象留在老年代说明晋升速率异常。第三步导出堆快照。这一步要谨慎生产环境推荐用jcmd执行它不需要像jmap -dump:live那样先触发Full GC这个操作在堆已经快满的时候可能直接造成雪崩jcmd pid GC.heap_dump /data/logs/heapdump.hprof第四步用MAT打开dump看Dominator Tree。当时排在最前面的是一个自定义的UserSessionCache占了整个堆的70%多。继续追到GC Roots发现是定时任务每次跑批时会把一批用户对象塞进这个缓存Map而Map里的数据根本没有过期和淘汰策略只进不出。这不是GC参数能解决的纯粹是代码问题。处理方案也不复杂给缓存加上TTL和容量上限跑批任务结束后主动清理。上线后观察两天Full GC从原来5分钟一次降到了几乎整天没有RT尖刺消失连之前的-Xmx都不用动。这个案例我每次讲都要强调堆dump的定位价值远大于调参。如果你跳过dump直接改-Xmx可能只是把爆掉的时间往后拖而不是消灭根因。3.2 高频根因先对照这张再决定要不要动参数做了几年JVM故障排查我把Full GC的高频根因归成下面几类排查时可以对照不要一上来就钻进参数调整里。内存泄漏局部对象被全局容器长期持有比如缓存Map、线程池任务队列、静态集合。排查靠heap dump和GC Roots追踪。大对象直接进入老年代比如超过阈值的大数组、大集合。G1会用Humongous区域存放超大对象直接占老年代空间频繁分配大对象会加快老年代增长。代码层看是否有大列表加载、大报文解析。显式调用System.gc()RMI、NIO、某些框架会在内部触发Full GC比如RMI默认每分钟左右做一次Full GCsun.rmi.dgc.client.gcInterval。如果代码或框架里能关闭就关闭不能关闭就调大间隔或加-XX:DisableExplicitGC但要评估框架是否依赖显式GC。元空间不足动态生成类过多比如大量反射、CGLIB代理、热部署触发Metadata GC Threshold甚至Full GC。排查后一般要调大-XX:MaxMetaspaceSize但更好的做法是控制动态类生成量。堆设置过小Eden和Survivor太小导致对象频繁晋升老年代过早打满。这个才轮到调堆参数。分配担保失败老年代剩余空间不足无法容纳Young GC晋升上来的对象触犯HandlePromotionFailure逻辑JVM会改成Full GC。这种情况光加老年代空间没用要看晋升速率和分配速率。容器内存限制JVM不知道自己在容器里给了很大-Xmx但容器内其他内存把内存上限吃满进程被OOMKilled重启后继续循环。每种根因的处理手段不同先把方向判断对再动手改参数至少能少走一半弯路。3.3 堆外内存GC日志正常但RSS持续上涨的坑还有一种情况特别迷惑人GC日志很健康老年代稳定在50%左右Minor GC频率正常Full GC几乎没有但运维那边提示容器内存使用率持续走高最后进程被OOMKilled。这是堆外内存的问题。Java进程占用的总内存不只是Java堆还包括元空间、线程栈、DirectByteBuffer、JIT代码缓存、NIO的映射文件、本地库分配的内存。Netty、RocketMQ客户端、gRPC这些框架常用堆外内存做缓冲区如果分配后释放不及时RSS就一路涨。排查工具我常用两个。一是pmap看进程地址空间的匿名内存分布能快速确认堆外是否异常二是JVM的NMTNative Memory Tracking-XX:NativeMemoryTrackingsummary然后执行jcmd pid VM.native_memory summary就能看到各部分内存占用。需要注意NMT有少量性能开销不要长期开在生产环境通常是怀疑堆外内存问题时才临时开。另外一个经验遇到GC日志正常但进程持续上涨优先怀疑Direct Memory的-XX:MaxDirectMemorySize没设置默认和堆大小一致Netty分配堆外内存后如果没有主动释放很容易把容器内存顶爆。4. G1与ZGC现代回收器的选型和关键参数4.1 G1为什么能hold住大堆低延迟场景JDK9之后G1是默认回收器很多团队是从Parallel GC或CMS直接迁过来的。G1的革命性在于把堆分成一个个大小相等的Region默认约1M到32M由-XX:G1HeapRegionSize控制回收时不再全堆扫描而是优先回收垃圾最多的Region这就是Garbage First名字的来源。它还维护了RSetRemembered Set记录跨Region引用配合并发标记可以实现可预测的停顿目标-XX:MaxGCPauseMillis默认200ms。G1的回收过程分几步Young GC只回收年轻代Region速度快当老年代使用率达到-XX:InitiatingHeapOccupancyPercentIHOP默认45%后会启动并发标记周期标记完成后进入Mixed GC此时会同时回收一部分老年代Region和年轻代Region。这套机制让老年代回收不再需要一个全堆STW的Full GC主要停顿被控制在Mixed GC上。实际调G1时我建议优先看这几个参数-XX:MaxGCPauseMillis停顿目标不是设得越小越好。设得太小比如50msG1会频繁做并发标记和Mixed GC回收跟不上分配反而可能触发Full GC。我一般从默认200ms开始压测后看实测量再微调。-XX:G1NewSizePercent和-XX:G1MaxNewSizePercent控制年轻代大小在堆中的比例默认分别是5%和60%。如果Young GC太频繁可以适当调大G1MaxNewSizePercent让年轻代容纳更多短命对象。-XX:InitiatingHeapOccupancyPercent老年代达到45%就启动并发标记。如果老年代增长快、Mixed GC跟不上可以适当下调比如35%让回收提前介入但代价是并发标记更频繁CPU开销上升。G1最怕的是Humongous对象。超过Region大小50%的对象会被放到连续的Region里这种对象在回收时处理成本高且容易产生碎片。代码层能避免就避免不能避免就要评估G1是否适合当前负载。4.2 ZGC低停顿的背后是更高的资源消耗ZGC是追求极致低延迟的产物目标是把STW控制在10ms以内。它用染色指针Colored Pointers把对象状态编码在指针里通过读屏障让业务线程在访问对象时协助GC完成部分工作实现了并发标记、并发转移、并发重映射。整个过程几乎不阻塞业务线程。但ZGC不是银弹。第一它从JDK11才作为实验特性引入JDK15才转正如果你的线上还停留在JDK8升级JDK本身就是个大工程。第二ZGC的CPU和内存开销比G1高因为它要做更细粒度的状态维护吞吐量通常比G1略低。第三在堆小于8G、对延迟没那么敏感的场景ZGC的收益并不明显反而浪费资源。我个人对ZGC的建议是大堆几十G以上 低延迟 分配速率稳定的服务才值得尝试小堆高并发场景先用G1把基础参数调好收益更快。不要因为听上去厉害就无脑切换。4.3 回收器选型决策表把几个主流回收器的定位整理成一张表方便快速决策回收器核心特点适用场景停顿表现生产落地建议Serial单线程回收实现简单客户端程序、极小堆秒级停顿服务端极少使用Parallel吞吐优先多线程并行批处理、离线任务停顿随堆增大变长JDK8默认适合非实时场景CMS并发标记清除低延迟经典老版本低时延服务停顿较短但碎片明显JDK14已移除不建议新项目G1分区化可预测停顿在线服务、大堆通用默认目标200ms当前最通用选择ZGC染色指针读屏障超大堆、极低延迟通常小于10msJDK15生产可用成本较高选型没有标准答案核心是匹配业务延迟要求、堆大小和团队维护成本。我见过很多团队把CMS迁移到G1后原来CMS的Full GC问题变成G1的Mixed GC失败原因多半是对G1参数和运行机制不熟悉一上来就套默认值跑。迁移GC前先跑一个完整的压测周期。5. 实战调参最容易踩的坑5.1 堆大小设置和容器内存的博弈堆大小是整个GC调优的基础但很多人在这上面就翻车了。在容器环境里-Xmx不能直接等于容器内存。JVM进程除了堆之外还要占用元空间、线程栈、JIT代码缓存、GC数据结构、DirectByteBuffer等堆外内存。如果容器是4G你把-Xmx设成3.5G看着好像还剩500M但实际元空间加上线程栈和Netty的堆外内存很容易就把4G打满然后进程被OOMKilled。我常用的经验值是2C4G容器堆给到-Xmx3g -Xms3g元空间给512MJIT代码缓存给256M其余留给线程栈和堆外。4C8G容器堆给6G比较稳。但这不是公式要看服务实际吃多少堆外内存最好用NMT或压测来验证。另外两个原则第一-Xms和-Xmx设成一致。很多老配置只设置了-XmxJVM启动时堆很小随着流量增长一点点扩容扩容过程本身需要STWGC优化时容易多出许多莫名其妙的停顿。两个一样大JVM启动时就划好整块堆避免动态扩容。第二预留内存要超出你看到的堆使用峰值因为GC过程中的临时分配、晋升失败、并发标记都可能导致内存使用短暂超过-Xmx的保守估算。线上容器看到堆使用率到90%不是立刻改大-Xmx的唯一依据还要看Full GC是否同时变多。5.2 年轻代结构参数最值得动的杠杆如果确认不是代码问题也不是堆大小问题那下一个最值得调整的就是年轻代结构。年轻代里的Eden区、两个Survivor区以及晋升年龄阈值直接决定对象在年轻代停留多久。默认情况下-XX:NewRatio2表示老年代和新生代的比例是2比1也就是新生代占堆的1/3-XX:SurvivorRatio8表示Eden区和单个Survivor区的比例是8比1两块Survivor各占新生代的1/10。-XX:MaxTenuringThreshold15表示对象经过15次Young GC还活着就晋升老年代。问题往往出现在Survivor空间不足。当Survivor放不下存活对象时即使对象年龄很小JVM也会把它提前晋升到老年代。GC日志里会看到Desired survivor size和new threshold。如果你观察到Young GC后老年代占用不断上涨先调-XX:SurvivorRatio比如从8改成4同时把-XX:TargetSurvivorRatio设到90让Survivor尽量容纳更多存活对象减少晋升量。也有人喜欢把MaxTenuringThreshold直接调低觉得对象少复制几次更省事。但过早晋升会让老年代更快打满增加Major GC频率。这个参数要结合晋升对象的大小和GC频率来看不能随手改。一个土办法连续记录一周GC日志统计每次Young GC的平均晋升字节数和老年代回收频率再决定是扩大Survivor还是调整晋升阈值。5.3 安全点问题GC日志显示没问题但RT飙高还有一种情况很考验排查能力GC日志里STW时间只有几十毫秒甚至GC次数也很少但业务RT偶尔飙到几百毫秒甚至秒级。这时候不要怀疑GC参数要去查安全点Safepoint问题。JVM做GC前需要所有业务线程都到达一个安全点。如果某个线程长时间运行在循环里长时间不到达安全点JVM就得等它这段时间也会表现为应用停顿但GC日志里看不出开销。可以用下面的参数打印安全点统计-XX:PrintSafepointStatistics -XX:PrintSafepointStatisticsCount1日志里会列出每次安全点触发的原因、等待时间和总耗时。常见导致安全点频繁或长时间等待的原因包括进入了超长循环且JIT没插入安全点检查、偏向锁的批量重偏向/撤销会触发安全点JDK8比较常见JDK15以后默认关闭偏向锁、线程数过多导致每次同步成本变高。如果你发现GC停顿正常但安全点等待时间异常处理方向不是调GC而是要查代码里的热点循环、是否还在用偏向锁、线程池规模是否过大。这类问题很容易被误判成GC问题白白浪费时间去调参数。6. 我的GC调优纪律6.1 一次只改一个参数并且跑足完整业务周期线上调GC最忌讳一次性改五六个参数然后说感觉好多了。你根本不知道是哪个参数起了作用也不知道有没有参数是负优化。我给自己定的规矩是一次只改一个参数改完至少跑24到48小时覆盖一个完整业务周期用之前建立的基线和监控指标对比。如果业务有明显的周周期性特征比如周末流量和平时不一样还要再跑一周。这样虽然慢但每次调优都有结论积少成多对当前服务的GC特性会越来越清楚。6.2 代码能解决的问题不要让JVM参数掩盖每次准备改参数之前先过一遍代码是否有缓存无淘汰、是否有大对象频繁分配、是否有显式System.gc、是否有线程池任务堆积。这些问题的正确解法是改代码而不是通过加堆、禁用GC之类的方式去掩盖。堆加得再大泄漏的对象池最终还是会填满GC配置再激进分配速率高到一定程度照样会触发Full GC。参数是最后一道护栏不是第一道防线。6.3 监控报表的最终衡量维度是RT和可用性GC频率、STW时长、堆使用率这些都是过程指标不是最终目标。最终目标只有一个业务RT稳定、可用性达标。只要GC没有导致超时和错误率上升哪怕它每天固定来几次Minor GC也不值得为此大动干戈去调优。反过来如果服务RT波动严重即便GC日志看起来很干净也要继续往下查包括安全点、堆外内存、甚至操作系统层面。我个人这几年最大的体感是GC优化更像是一个排查问题、理解对象行为的过程而不是参数表演。大部分线上老年代打满的案子最后都收在了代码上真正要动回收器参数的往往是容器内存、堆大小、年轻代比例这些基础项。先把日志打开、把基线条清再谈优化。这样即使哪天凌晨两点又被告警吵醒你也能从容地顺着链路把根因捞出来而不是对着-Xmx盲目折腾。