JVM垃圾回收调优实战:从GC日志到性能瓶颈突破

发布时间:2026/10/8 20:40:28
JVM垃圾回收调优实战:从GC日志到性能瓶颈突破 线上服务一到晚高峰就卡顿我第一反应不是去调 GC 参数而是先捧着 GC 日志看半天。说实话JVM 调优这块被好多人当成了玄学网上搜一圈全是八股文什么新生代、老年代、CMS、G1背得滚瓜烂熟真到了现场面对一堆日志和监控数据照样无从下手。这篇内容就是来解决这个问题的从 JVM 垃圾回收的底层原理讲起把对象是怎么分配、怎么晋升、怎么回收的完整链路拆清楚再把 Serial、Parallel、CMS、G1、ZGC 这些主流收集器的机制和适用场景做横向对比最后落到实操——怎么定目标、怎么收数据、怎么调参数、怎么验证效果以及我踩过的一些典型坑。如果你正在排查线上 Full GC 频繁、接口变慢、CPU 飙高这类问题或者准备面试时被问JVM 调优你做过什么又或者只是想搞明白堆内存参数到底该怎么设这篇文章都适合你。内容不会去堆概念所有结论都有日志、命令和案例作为依据可以直接照着走一遍。1. 先别急着调参数垃圾回收到底在回收什么1.1 GC 调优的本质是找到分配与回收的失衡点很多人调 JVM 参数有个习惯感觉卡了就加内存觉得 Full GC 多就换收集器结果往往是越调越乱。垃圾回收调优这件事表面上是在调参数本质上是在解决一个核心矛盾对象分配的速度和 GC 回收的速度不匹配。举一个实际场景。某个订单系统高峰期每秒要创建几万个临时对象Eden 区很快被打满Minor GC 频繁触发。如果 Survivor 区太小存活对象来不及在 Minor GC 之间复制就会提前晋升到老年代老年代一涨Full GC 也跟着频繁起来。这时候你要解决的不是换一个更快的收集器而是搞清楚对象是怎么快速堆积的、为什么晋升得这么快然后从参数和代码两个层面去疏导。这也是我写这篇内容的基本立场调优不是堆参数是先定位再调整最后验证。下面的内容会按照这个逻辑展开。1.2 堆内存不是一个大桶新生代、老年代各自承担什么职责JVM 堆内存虽然是逻辑上连续的空间但实际被分成了好几个区域每个区域职责完全不同。以最经典的分代模型来说新生代Young Generation又细分为 Eden 区和两块 Survivor 区S0、S1。绝大多数新对象出生在 EdenMinor GC 后存活对象会被复制到 Survivor 区。老年代Old Generation存放长期存活的对象以及大对象或晋升上来的对象。老年代触发的 Major GC / Full GC 通常伴随较长的停顿。元空间MetaspaceJDK8 之后把类的元信息移到了本地内存不在堆内但元空间耗尽同样会引发频繁 GC 甚至 OOM后面案例部分会专门讲。对象的一生可以概括成一条流水线出生在 Eden - 经历一次 Minor GC 后进入 S0 - 下一次 GC 后从 S0 复制到 S1 - 反复交换 - 存活次数达到阈值后进入老年代。其中有个细节很多人不清楚CMS 收集器下 MaxTenuringThreshold 的有效值大概率不是 15而是 6因为 CMS 默认会开启基于 Survivor 占用率的动态年龄判定如果你还在把 15 当作标准答案可能已经脱节了。1.3 GC Roots 与可达性分析怎么判断一个对象该不该回收判断对象是否存活的算法叫可达性分析Reachability Analysis而不是简单地数引用计数。它的思路是从一组称为GC Roots的根节点出发沿着引用链遍历对象图凡是不可达的对象就是可回收对象。GC Roots 包含这几类虚拟机栈中引用的对象、本地方法栈中 JNI 引用的对象、方法区中类静态属性引用的对象、运行时常量池中引用的对象以及被 synchronized 持有的对象。可以把它理解成整个程序的起点只要从这些起点还能摸到的对象JVM 就认为它活着。这里有个关键点必须知道枚举根节点和遍历对象图都需要 Stop-The-WorldSTW。也就是说 GC 发生时工作线程必须停下来JVM 才能拿到一张不会变化的快照来做可达性分析。这也是为什么 GC 停顿对延迟敏感的在线服务影响那么大。后面讲的 CMS、G1、ZGC 这些收集器所有设计上的进化本质上都是在和 STW 搏斗。2. 认识主流垃圾回收器从 Serial 到 ZGC 的进化逻辑2.1 一张表看清七款收集器的能力边界我整理了一张对比表按 JDK 演进顺序来看你会发现收藏器设计思路的转变非常清晰收集器所属JDK版本并行/并发回收算法核心目标典型适用场景Serial早期单线程标记-复制新生代/ 标记-整理老年代简单可靠单核环境、客户端模式、内存极小Parallel ScavengeJDK8默认多线程并行标记-复制 / 标记-整理吞吐量优先批处理、离线计算、对停顿不敏感的服务ParNewJDK8常用多线程并行标记-复制配合CMS与CMS搭配使用追求低延迟的JDK8服务CMSJDK9弃用、JDK14移除并发标记、并发清理标记-清除低停顿JDK8时代的延迟敏感在线服务G1JDK9默认并行并发分区标记-复制可预测停顿多核大内存、延迟和吞吐兼顾的JDK9服务ZGCJDK15转正并发染色指针读屏障亚毫秒级停顿超大堆、极高延迟要求、JDK15服务ShenandoahJDK16转正并发多阶段并发低停顿与ZGC类似依赖特定平台注意一个容易混淆的点JDK8 默认的收集器组合是 Parallel Scavenge Parallel Old而不是 CMS。你经常听别人说我 JDK8 用的 CMS其实是手动通过 -XX:UseConcMarkSweepGC 指定的。JDK9 开始 G1 成为默认JDK15 之后 ZGC 转为正式特性但仍需显式开启JDK21 的默认依然是 G1。认清这个版本关系能帮你少在选型上踩坑。2.2 CMS 和 G1 的核心机制为什么它们能降低停顿CMSConcurrent Mark Sweep的流程是四步走初始标记STW- 并发标记无 STW- 重新标记STW- 并发清理无 STW。它把最耗时的标记和清理阶段丢到后台线程并发执行以此缩短 STW 时间。但 CMS 有两个硬伤一是采用标记-清除算法回收后会产生大量内存碎片二是并发执行期间无法处理应用线程新产生的对象这些浮动垃圾只能留到下一次 GC如果老年代在并发清理前就被占满JVM 会退化到 Serial Old 做 Full GC停顿瞬间回到秒级这就是著名的 Concurrent Mode Failure。G1 的设计思路完全不同它把堆分割成大小相等的Region新生代、老年代不再要求物理连续。G1 通过一个停顿预测模型来维护回收集每次回收优先选择回收收益最大也就是垃圾占比最高的 Region也就是名字里 Garbage First 的含义。存活对象在 Region 之间复制解决了 CMS 碎片化的问题。G1 还有一个专门放超大对象的Humongous 区大于 Region 一半的对象直接进 Humongous但 Humongous 对象频繁分配很容易引发 G1 的 Full GC这点后面案例会展开。2.3 选型思路吞吐优先还是延迟优先选收集器本质上是在吞吐量、停顿时间和内存占用三个维度之间做权衡不存在完美方案。我的选型经验很简单JDK8 且追求极致延迟用 ParNew CMS配合 -XX:UseCMSInitiatingOccupancyOnly 固定触发阈值尽量避免浮动垃圾拖垮老年代。JDK9 且堆在 4G 以上直接 G1把 MaxGCPauseMillis 设置为 100ms~200ms让 G1 自己去做停顿预算。JDK15 且堆在几十 GB 甚至上百 GB延迟又极高ZGC 或 Shenandoah 是更靠得住的选择停顿与堆大小基本无关。离线批处理、吞吐优先Parallel Scavenge Parallel Old 就是正确答案别盲目追求低停顿否则并发标记的开销反而拖低吞吐。一个小结论如果你的服务堆内存只有 1G~2G老老实实用默认收集器比费劲迁移到 G1 更靠谱Region 机制带来的额外元数据开销在小堆上是净成本。先看内存再看版本最后才谈野心。3. 接管 GC 证据调优前的数据收集体检3.1 可量化的调优目标怎么定没有目标的调优都是瞎忙。我在项目里定的目标从来不是让系统不卡而是下面这种可验证的指标Full GC 频率线上核心服务要求 Full GC 一天不超过 1 次最好一周都不触发。GC 停顿时间单次 Minor GC 小于 50ms单次 Full GC 小于 200ms或者用 G1 时 -XX:MaxGCPauseMillis 设 150ms实测 95% 停顿不超过该值。吞吐量GC 占用时间占应用总运行时间的比例离线任务通常要求低于 5%在线服务可以参考但优先级低于停顿。元空间Metaspace 每周增长幅度控制在稳定区间不能无限上涨。你看到这些目标之后会发现让 GC 越少越好这种说法根本没法落地。合理的做法是根据业务容忍度定好上限和底线这样后续每次调参都有对比基准线。3.2 打开 GC 日志从日志里能读到什么想摸清 JVM 的脾性第一件事就是打开 GC 日志。JDK8 和 JDK9 的参数形式不一样我分别写一下JDK8-Xloggc:/opt/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStampsJDK9-Xlog:gc*:file/opt/logs/gc.log:time,uptime,level,tags打开日志后重点读这几个字段GC 发生的时间点、类型Minor GC / Full GC、回收前后各区域容量变化、总耗时、以及用户态 CPU 时间 vs 核心态 CPU 时间的比值。比如一条 Minor GC 日志里如果sys 时间明显大于 user 时间说明 STW 期间的引用更新时间主要集中在内核态可能需要检查容器 CPU 限额或者并发线程数。日志本身不会告诉你哪里有问题但它能告诉你问题在哪一刻爆发、以什么形式爆发、与你配置的哪一个参数相关。所以调优时我习惯把 GC 日志保留至少半个月作为复盘依据。3.3 线上快速诊断工具组合jps / jstat / jmap / jcmd光有日志还不够需要配合 JDK 自带命令做实时观测。我常用的组合是# 1. 列出 Java 进程 jps -l # 2. 看堆各区和 GC 总体情况每隔 1 秒刷新 jstat -gcutil pid 1000 # 3. 打印堆当前配置和已用空间 jmap -heap pid # 4. 查看类加载情况 jstat -class pidjstat -gcutil 的输出里E、S0、S1、O、M 分别代表 Eden、Survivor0、Survivor1、老年代、元空间的占用百分比YGC、FGC 是 Minor GC 和 Full GC 累计次数YGCT、FGCT 是累计耗时。这套数据只要连续采集几分钟就能大致判断出Eden 是不是撑不到预期时间就满了、Survivor 有没有被打爆、老年代是不是在匀速增长。这里有个排查思路供参考如果老年代占用率稳步上升优先用 jmap -histo:live 看一眼堆里什么对象最多如果元空间占用率一直爬升优先检查反射、动态代理、CGLib 的类加载来源。数据永远是第一证据猜测永远排第二。# 把堆 dump 下来离线分析谨慎使用会 STW jmap -dump:formatb,file/opt/logs/heap.hprof pid导出堆快照会触发一次较长的 Stop-The-World线上高峰时段慎用。更稳妥的是先配置好自动 dump启动参数加上 -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/opt/logs/真要是 OOM 了好歹有现场证据。4. 落地调优从参数调整到收益验证4.1 核心调优参数逐个拆解每个参数解决什么问题调参不能盲调我按优先级把常用参数整理出来每个参数背后都有明确的目的-Xms 与 -Xmx初始堆和最大堆生产环境建议设成相同值避免 JVM 动态扩缩容时触发额外的系统调用和内存分配。-Xmn新生代大小。新生代太小容易频发 Minor GC太大又会压缩老年代空间我一般先设堆整体大小的 1/4 到 1/3再按晋升速率微调。-XX:SurvivorRatioEden 和 Survivor 的占比默认 8 表示 Eden:S0:S1 8:1:1。如果发现对象晋升年龄过早可以把比例调为 6 或 5给存活对象留出周转空间。-XX:MaxTenuringThreshold存活年龄阈值。JDK8 默认 15但 CMS 场景下动态晋升可能远不到这个值若想稳定可以配合 -XX:TargetSurvivorRatio默认 50%使用。-XX:PretenureSizeThreshold大对象直接进老年代的阈值仅对 Serial / ParNew 生效。G1 下的对应机制是 Humongous 区不看这个参数。-XX:CMSInitiatingOccupancyFractionCMS 老年代触发并发 GC 的阈值搭配 -XX:UseCMSInitiatingOccupancyOnly 固定后常设为 70~75给它和浮动垃圾留一点缓冲。-XX:MaxGCPauseMillis对 G1 来说是停顿预测目标对 Parallel Scavenge 来说是吞吐调优辅助参数。-XX:InitiatingHeapOccupancyPercentIHOPG1 触发并发标记的堆占用百分比默认约 45%如果你发现并发标记太频繁可以适当上调。每个参数都不是越大越好一个参数的改动往往会影响到另一个参数的效果所以调参必须配套验证。4.2 一个完整的 G1 调优实战演示用一个 8G 堆的 Spring Cloud 服务举例线上 4 核 8G 容器JDK17默认 G1服务高峰期接口 P99 抖动明显。我按流程走一遍第一步收集基线数据。开启 GC 日志后观察一天发现Mixed GC 每隔十几分钟触发一次单次停顿波动很大部分停顿超过 500ms。第二步定位原因。用 jstat 看发现 Humongous 区对象很多进一步用 jmap -histo:live 确认有一批 10MB 以上的 byte[] 频繁分配。这些大数组走的是 Humongous 分配路径会占用连续 Region且需要提前触发并发标记周期。第三步制定调整方案。业务侧先压缩大数组一次性查询拆成批量分页这是根本解法。JVM 侧的辅助调整是将 -XX:G1HeapRegionSize 默认自动计算改为固定 16MB减少大对象跨 Region 的情况把 -XX:MaxGCPauseMillis 从默认调成 100ms让 G1 更激进地控制停顿同时把 -XX:InitiatingHeapOccupancyPercent 从默认 45% 调到 55%避免并发周期启动过早。第四步验证。调整后连续压测两小时对比日志数据Mixed GC 次数从每十几分钟一次降到每半小时一次P99 停顿从 500ms 降到 120ms吞吐量没有明显回退。这套基于证据 - 定位来源 - 参数辅助 - 压测验证的循环比直接抄一堆参数组合科学得多。关键点是 Big 对象/Humongous 对象这类问题调参只能缓解代码层面减少大对象分配才是根治。4.3 调优闭环确认收益、灰度放量、固化监控调优不是一次性动作做完要按闭环走完才算数。我的经验是三步灰度放量。不要一把把所有节点都改掉挑一个低峰流量节点先跑 24 小时如果 GC 指标确实变好再逐步扩展到全集群。回滚预案。每个参数改动前记清楚改动内容线上出问题能 5 分钟回滚而不是手忙脚乱翻历史记录。固化监控。把关键指标接到监控平台上例如 Full GC 次数、老年代占用趋势、元空间增长率、单次 GC 最大停顿这些设置告警阈值。很多团队的问题是调完参数后没人持续观察结果三个月后内存又涨上来了事后再复盘时根本不知道是从哪次版本迭代引入的。固化的价值就在于把调优变成长期可观测的过程而不是一次性救火。5. 典型性能瓶颈案例拆解这些坑我替大家踩过5.1 案例一Minor GC 20 秒一次老年代却被快速塞满现象服务运行稳定但 old 区在一天内从 20% 涨到 90%Full GC 开启频繁。GC 日志显示每次 Minor GC 后 S0/S1 占用率拉满大量对象直接晋升老年代。初步判断是新生代容量设置过小对象在 Eden 撑不满一个完整的 Minor GC 周期就被迫晋升。解决办法分两步先动态调整 -Xmn 从原来的 1G 扩到 3G同时把 -XX:SurvivorRatio 从 8 降到 6扩大存放存活对象的周转空间再配合 -XX:MaxTenuringThreshold10 和 -XX:TargetSurvivorRatio90防止对象过早晋升。调整后 Minor GC 频率从每 20 秒一次降到每 1 分钟一次老年代增速明显放缓。晋升速率是最容易被忽视的核心指标单纯看 GC 次数看似正常实际隐患藏在对象晋升路径上。5.2 案例二G1 大量 Humongous 分区Mixed GC 频繁超时现象大对象频繁让 G1 分配 Humongous Region导致 Mixed GC 停顿超过 300ms而且回收后堆占用率很快又涨回来。用 jmap -histo:live 确认有一批大 buffer 占用空间超高。那次的教训是不能在业务代码里为了省事把大 PDF 文件整个读进内存再解析正确做法是流式解析或者按页处理。JVM 参数方面我做了三件事把 -XX:G1HeapRegionSize 固定为 16MB减少大对象跨 Region把 -XX:MaxGCPauseMillis 下调到 100ms 让 G1 更激进地平稳停顿同时降低了 -XX:InitiatingHeapOccupancyPercent 的触发频率避免并发周期无限循环。最终停顿恢复到 100ms 以内。5.3 案例三CMS 并发模式失败导致的回光返照现象JDK8 上用 ParNew CMS 做低延迟服务某次上线后 GC 日志里出现 concurrent mode failure紧接着是一长串 Full GC停顿甚至达到秒级。原因很清楚CMS 并发清理期间应用线程还在产生新对象老年代可用空间不足以容纳这些浮动垃圾JVM 只能切换到 Serial Old 兜底。那次我做了三处改动-XX:CMSInitiatingOccupancyFraction 从默认动态改成 72%配合 -XX:UseCMSInitiatingOccupancyOnly 固定触发时机给老年代预留足够的余量同时排查了业务里有没有短时间大量分配对象的代码段用缓存方案削减分配峰值。需要说明的是CMS 在 JDK14 已经被移除还在用 JDK8 的老项目可以参照这个思路新项目直接用 G1 更省心。5.4 案例四Metaspace 持续增长类加载泄漏现象某服务上线两周后 Metaspace 涨到 1GGC 频率明显升高最后直接抛出 OutOfMemoryError: Metaspace。排查方法是用 jstat -class 观察加载类数量发现每分钟增加上千个类这绝对不正常。定位到根因是业务代码里用了动态代理生成了大量代理类且旧代理类没有被及时回收。解决手段包括加大 -XX:MaxMetaspaceSize 做兜底在代码里复用代理类实例避免频繁生成同时给监控加了一条Metaspace 每周增长率的告警规则超过 20% 就触发检查。这类问题其实是代码层面有泄漏GC 参数只能兜住一时必需要修复源头。6. GC 调优的常见误区与避坑清单6.1 误区一无脑加大堆内存就能解决一切堆内存在调优参数里最容易让人上头因为加 -Xmx 是最低成本的改动。但堆并不是越大越好堆越大单次 Full GC 扫描和移动的对象就越多停顿随之膨胀而且大堆会对 CPU 缓存命中率造成压力影响业务线程的运行效率。我见过一个极端案例有人把 -Xmx 从 4G 调到 16G结果 Full GC 一次要好几秒服务直接雪崩。正确的做法是先分析对象分配速率和存活对象大小再反推堆容量。假设服务高峰期存活对象总量 2G新生代周转频率合适堆设 8G 是合理的如果存活对象只有几百 MB堆设 4G 就够用加更多只是自我安慰。6.2 误区二把 STW 时间调到最低就算成功低停顿为导向没错但很多人忽略了并发收集器的后台开销。CMS、G1、ZGC 的并发阶段虽然不 STW但会用掉一定比例的 CPU 资源如果机器本身就很忙业务线程的响应反而会变差。吞吐量和延迟是跷跷板ZGC 可以把停顿压到亚毫秒级但它多阶段并发带来的内存屏障开销在部分场景会拖低吞吐。选型时先给业务做个定性在线交易之类延迟敏感系统低停顿优先对吞吐损失可以容忍离线统计、批量导入之类的任务干脆用 Parallel别跟风换 ZGC。你要调优的是业务的瓶颈指标不是某个理论最优值。6.3 误区三只调 GC 参数不查业务对象分配源头这是最常见的误区也是很多人调参调到头来发现没用的原因。每次 Full GC 频繁本质上是短时间内有大量对象需要回收这个大量通常来自业务代码循环里反复 new 集合、统计报表一次性把全量数据装进内存、线程池里堆积了超大任务队列……这些问题的解法不是把老年代调大而是改代码。我给自己定了一条规矩看到 GC 频繁先查分配速率再看 GC 参数。分配速率的直观指标是 jstat 里 Eden 区的增长速度和 GC 后存活对象的规模。如果这两项都正常再考虑收集器选型和参数调整如果不正常先把业务代码优化提上日程。6.4 一份可以直接保存的避坑清单上线前把 -Xms 和 -Xmx 设为相同值避免运行时扩容。GC 日志保留在独立磁盘上避免日志 I/O 与业务 I/O 抢资源。不要同时混合使用不同收集器家族的参数例如 Parallel 和 CMS 的参数混在一起容易产生非预期行为。改了参数后至少观察 24 小时最忌讳上午调完下午回滚。每次 OOM 都要检查是否配置了 -XX:HeapDumpOnOutOfMemoryError没有的话先补上。容器环境一定要确认 JVM 能正确识别 CPU 核数和内存限额否则默认线程数可能失控。关注 -XX:MaxRAM 等参数与容器内存限制的匹配防止 JVM 比容器先 OOM。这条清单每条都是从真实事故里提炼出来的哪怕现在用不上也建议先收藏等你的服务真遇到问题再翻出来对照一遍。我在实际排查过不少 GC 问题之后最深的感受是JVM 调优考验的不是记忆力而是定位问题的思路。你不需要背下所有参数但一定要知道 GC 日志里每个数字代表什么、每个工具输出怎么解释、每一个参数改动会影响哪条链路。把收集证据 - 定位来源 - 最小改动 - 灰度验证这套闭环跑顺了线上性能瓶颈多数都能迎刃而解。最后再分享一个小习惯我每调完一个系统都会把调整前后的 GC 日志和指标对比截图存档时间久了这就是团队里最珍贵的 JVM 调优知识库。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询