☰
JVM YGC与FGC排查调优实战:从GC日志到根因分析
2026/9/30 6:32:38 网站建设 项目流程

线上服务跑得好好的,某天监控突然报警:Full GC 次数在半小时内涨了 40 次,单次耗时 800ms,接口 P99 从 80ms 抖到 1.2s。这时候你打开日志,满屏的[Full GC (Ergonomics)]和[GC (Allocation Failure)],很多人第一反应是"堆不够了,加内存",结果-Xmx从 4G 提到 8G,FGC 是少了点,过了两天又回来了。JVM 里 YGC 和 FGC 的分析,实际上是一套"现象—证据—根因—验证"的排查链路,你把它当成一门手艺来练,比死记参数有用得多。这篇文章聊的就是这套手艺:YGC(Young GC,新生代回收,也叫 Minor GC)和 FGC(Full GC,全堆回收)分别在什么条件下被触发,它们的日志该逐字段怎么读,jps、jstat、jmap、jstack、Arthas 这些工具各自在什么环节出手,以及那些把 FGC 拱起来的典型根因怎么一层层剥开。不管你是刚接触 JVM 内存模型的开发,还是天天盯着线上 GC 曲线发愁的运维和后端,都能从里面拎出能直接落地的东西。

1. 先把 YGC 和 FGC 这件事说透:两种回收到底差在哪

1.1 从 JVM 内存模型和分代设计说起

理解两种 GC,得先接受一个前提:JVM 把堆按对象的"存活年龄"切成不同的区域,这个设计本身就是为回收效率服务的。绝大多数对象生命周期极短——方法里 new 出来的临时对象、请求上下文、DTO 转换的中间结果,用完就死。少数对象会长期存活,比如缓存、连接池、单例、配置对象。如果把这两类对象混在一个大池子里回收,每次扫描都得遍历所有存活对象,成本极高。分代的设计逻辑是:把新对象都放新生代,用复制算法快速清掉;把熬过几轮回收的对象挪到老年代,用标记清除或标记整理来处理。

具体到 JVM 内存模型,堆的划分是 Eden、两个 Survivor(S0/S1)加老年代。Eden 占新生代的大头,Survivor 各占一个小头,默认比例是 8:1:1。新对象优先在 Eden 分配,Eden 满了触发一次 YGC,把 Eden 加上正在使用的 Survivor 里的存活对象复制到另一块空闲 Survivor(这就是"复制算法"的 to-space)。复制完之后,存活对象的年龄加一,年龄到阈值就晋升到老年代。老年代装满了、装不下了,就轮到 FGC 出场。除了堆,还有一块绕不开的区域叫 Metaspace(元空间),存类元信息,它不属于堆,但它的膨胀同样会触发 FGC,这点后面会单独讲。

注意:方法区在 JDK 8 之后从永久代(PermGen)改成了元空间,挪到了本地内存。所以"Java 8 之后没有 PermGen OOM,只有 Metaspace OOM"这句话是对的,但 Metaspace 撑爆一样会引发 Full GC。

1.2 YGC 与 FGC 的触发条件对比

两者最本质的区别不是"回收范围大小",而是触发条件、停顿代价和可预测性完全不同。YGC 是高频、低停顿、可预期的;FGC 是低频(理想情况下)、高停顿、破坏性强的。把触发条件列清楚,排查时才知道该往哪个方向找。

维度YGC(Young GC / Minor GC)FGC(Full GC)
回收范围Eden + 一个 Survivor整个堆(新生代 + 老年代),部分实现含元空间
主要触发条件Eden 区分配不下新对象(Allocation Failure)老年代空间不足、元空间不足、显式 System.gc()、晋升失败、CMS Concurrent Mode Failure、G1 的 Humongous 分配失败
停顿时间通常几毫秒到几十毫秒几百毫秒到数秒,取决于堆大小和存活对象量
频率高,访问量大的服务每秒几次都正常低,健康服务可能几小时甚至几天一次
采用的算法复制算法(Copying)标记-清除(CMS)、标记-整理(Parallel Old / Serial Old)、G1 的混合回收
对业务的影响一般可接受,除非频率异常影响大,是接口抖动、超时的主要嫌疑
调优关注点频率、单次耗时、晋升速率频率、单次耗时、回收后老年代占用是否下降

这张表最需要记住的一点是最后两行。很多人只看 FGC 的"次数"和"耗时",却忽略了"回收后老年代占用有没有降下来"。这个指标才是判断内存泄漏的黄金标准:如果每次 FGC 之后老年代占用从 3.8G 降到 3.5G,过一会儿又涨回 3.8G,反复如此,说明老年代里有大量回收不掉的活对象,加内存只能拖延、不能解决。

1.3 为什么 FGC 才是真正要盯的那个

YGC 频繁本身不一定是病。一个每秒处理上万请求的服务,Eden 小一点,YGC 每秒跑几次很正常,只要单次停顿在 20ms 以内、存活对象不多,业务基本无感。真正让人头疼的是 FGC:它扫描范围大,停顿时间长,而且会"Stop The World"(暂停所有业务线程)。一次 1 秒的 FGC,意味着这 1 秒内所有请求都在排队。

再往深一层看,FGC 频繁往往是果、不是因。它背后可能是对象晋升过快(新生代太小或者 Survivor 太小,短命对象熬两下就进了老年代)、可能是内存泄漏(活对象只增不减)、可能是元空间撑爆(动态生成类太多)、也可能是有人代码里偷偷调了System.gc()。你只盯着 FGC 数字去调参数,就像发烧了只吃退烧药不查炎症,治标不治本。所以我个人的排查顺序永远是:先看 FGC 的触发原因是什么(日志里的括号关键字),再看老年代回收前后的占用变化,最后才去动参数。

2. 读懂 GC 日志:分析 YGC 和 FGC 的第一手证据

2.1 先把日志参数打开,别等出事才想起来

我见过的团队里,十个有八个一开始没开 GC 日志,出问题时两眼一抹黑,只能靠监控图表猜。GC 日志的开关必须在服务上线前就配好,它对性能的影响微乎其微(日志量可控的情况下),但排查价值极高。JDK 8 和 JDK 9+ 的参数写法不一样,这点坑过不少人。

JDK 8 的经典写法:

-Xloggc:/data/logs/gc-%t.log \ -XX:+PrintGCDetails \ -XX:+PrintGCDateStamps \ -XX:+PrintGCTimeStamps \ -XX:+PrintHeapAtGC \ -XX:+UseGCLogFileRotation \ -XX:NumberOfGCLogFiles=10 \ -XX:GCLogFileSize=100M

JDK 9 及以后的统一日志框架写法(JDK 11、17、21 都用这套):

-Xlog:gc*,gc+heap=info,gc+age=trace:file=/data/logs/gc-%t.log:time,uptime,level,tags:filecount=10,filesize=100M

-XX:+PrintHeapAtGC这个参数很多人忽略,它会在每次 GC 前后打印完整的堆区占用,分析晋升和泄漏时特别有用。gc+age=trace(JDK 9+)会打印对象年龄分布,配合动态年龄判定分析非常直观。日志滚动那三个参数是防止磁盘被写爆的,生产环境务必加上——我就见过因为没滚动、GC 日志写了几百 G 把磁盘打满、导致整个节点挂掉的案例。

2.2 一条 YGC 日志逐字段拆解

拿 JDK 8 默认的 Parallel 收集器举例,日志长这样:

2024-05-21T10:12:33.456+0800: 12.345: [GC (Allocation Failure) [PSYoungGen: 655360K->54321K(762880K)] 720000K->131000K(1565184K), 0.0321234 secs] [Times: user=0.12 sys=0.01, real=0.03 secs]

逐段读:2024-05-21T10:12:33.456+0800是墙钟时间,12.345是从 JVM 启动到现在的秒数。GC表示这是 Minor GC(Full GC 会写成Full GC)。(Allocation Failure)是触发原因——Eden 分配不下新对象了,这是最常见的 YGC 原因。

[PSYoungGen: 655360K->54321K(762880K)]是新生代的回收情况:回收前用了 655360K(约 640M),回收后剩 54321K(约 53M),括号里的 762880K 是新生代总容量。这里的差值 655360 - 54321 ≈ 600M 就是被清掉的垃圾,剩下的 53M 是被复制到 Survivor 的存活对象。720000K->131000K(1565184K)是整个堆的占用变化,从 720M 降到 131M,堆总容量约 1.5G。注意堆总占用下降的幅度(720-131=589M)和新生代下降幅度接近,说明这次回收对老年代的帮助不大,老年代原本占用就很稳定。

0.0321234 secs是本次 GC 的实际耗时,约 32ms。最后的[Times: user=0.12 sys=0.01, real=0.03 secs]是 CPU 时间维度的统计:real是墙钟耗时 30ms,user是所有 GC 线程消耗的用户态 CPU 时间总和 120ms。user / real ≈ 4,说明大约有 4 个 GC 线程在并行工作,这是判断并行度的经验公式——user / real大概等于实际参与工作的 GC 线程数。

2.3 一条 FGC 日志逐字段拆解

Parallel 收集器的 Full GC 日志:

2024-05-21T10:20:01.123+0800: 460.012: [Full GC (Ergonomics) [PSYoungGen: 54321K->0K(762880K)] [ParOldGen: 400000K->380000K(802816K)] 454321K->380000K(1565184K), [Metaspace: 34567K->34567K(1056768K)], 0.3123456 secs] [Times: user=1.23 sys=0.02, real=0.31 secs]

关键看括号里的触发原因(Ergonomics)——这是 JVM 自适应策略判断该做 Full GC 了,属于"常规触发"。如果是别的关键字,指向的根因就完全不同:

  • (System.gc()):有人显式调用了System.gc(),或者用了Runtime.getRuntime().gc(),也可能是某些框架(比如直接内存回收触发的System.gc())或 JNI 代码干的。这种 FGC 是"自找的"。
  • (Metadata GC Threshold):元空间达到阈值触发的。
  • (Allocation Failure):老年代分配不下对象,最常见但最难缠的一种。
  • (Promotion Failed):新生代对象要晋升到老年代,但老年代没空间了,这是典型的新生代/老年代配比失调。
  • (Concurrent Mode Failure):CMS 收集器特有,并发回收还没做完,老年代就被填满了,被迫退化成单线程的 Serial Old 做 Full GC,停顿极长。

日志里的[PSYoungGen: 54321K->0K(...)]说明 Full GC 把新生代整个清空(存活对象都挪走了),[ParOldGen: 400000K->380000K(...)]是老年代从 390M 降到 371M,只回收了 20M。这 20M / 390M ≈ 5% 的回收率就是危险信号:绝大多数老年代对象都活着,说明它们正在被长期持有。[Metaspace: 34567K->34567K(...)]显示元空间回收前后没变化,排除元空间问题。

2.4 日志里的危险信号清单

看日志要有"条件反射"——某些特征一出现,基本就能锁定方向。我整理了一份速查清单,出现频率从高到低排:

日志特征可能的根因下一步动作
FGC 后老年代占用几乎不降(回收率 < 10%)内存泄漏、大缓存无上限、ThreadLocal 未清理jmap 导出堆快照,比对两次快照的对象增长
(System.gc())反复出现代码/框架显式调用加-XX:+DisableExplicitGC,同时定位调用来源
(Metadata GC Threshold)频繁动态代理、反射、CGLIB、脚本引擎大量生成类看 Metaspace 曲线,设-XX:MaxMetaspaceSize并查生成源
(Promotion Failed)新生代偏大或 Survivor 偏小,对象过早晋升调整新生代/Survivor 比例,或换 G1
(Concurrent Mode Failure)CMS 回收速度跟不上分配速度降低 CMS 触发阈值,或换 G1/ZGC
YGC 频率极高但每次回收很少Eden 太小适当增大新生代
单次 FGC real 时间 > 1s堆过大、存活对象过多降堆、拆分服务、换低停顿收集器

经验:把 FGC 触发原因做成监控指标,比只监控"FGC 次数"有用得多。次数只能告诉你"有问题",触发原因才能告诉你"问题在哪"。

3. 常用 JVM 调优工具实战:jps、jstat、jmap、jstack、Arthas

3.1 jps + jstat:先看趋势,别急着 dump

排查第一原则:先用低成本工具看趋势,别上来就 dump 堆。jmap -dump会触发 STW,大堆能卡好几秒,生产环境贸然执行可能直接引发雪崩。第一步永远是jps -l找到目标进程 PID:

jps -l # 12345 com.example.OrderApplication # 12400 sun.tools.jps.Jps

然后用jstat看 GC 趋势,这是所有工具里最轻量的:

jstat -gcutil 12345 1000 10

这条命令每 1000ms 打印一次,共 10 次。输出字段含义如下:

字段含义
S0 / S1两个 Survivor 区的使用百分比
EEden 区使用百分比
O老年代使用百分比
MMetaspace 使用百分比
CCS压缩类空间使用百分比
YGC累计 Young GC 次数
YGCT累计 Young GC 总耗时(秒)
FGC累计 Full GC 次数
FGCT累计 Full GC 总耗时(秒)
GCT所有 GC 总耗时

重点看O这一列。持续观察 10 次,如果O从 40% 一路涨到 95% 然后 FGC 降到 40%,又接着涨,说明老年代在持续被填充——要么是正常的高晋升速率(业务量大),要么是内存泄漏。区分方法:把观察窗口拉长到几十分钟,泄漏的话 FGC 后O的"谷底值"会越来越高。

再用jstat -gc看绝对数值(KB 为单位),配合-gcnew和-gcold看细分:

jstat -gc 12345 1000 5 jstat -gcnew 12345 1000 5 jstat -gcold 12345 1000 5

-gcnew会多出TT(Tenuring Threshold,当前晋升年龄阈值)和MTT(最大阈值)两列,TT一直偏低说明 JVM 在动态降低晋升年龄,暗示 Survivor 空间可能不够用。

3.2 jmap:堆快照与直方图,两种用法

jmap有两个完全不同量级的用法,要分清。

第一种,jmap -histo,看对象直方图,代价较低(但:live会触发一次 Full GC):

jmap -histo:live 12345 | head -30

输出是每个类的实例数和占用字节数降序排列。这一步能快速发现"哪个类的对象特别多"——如果[C(char 数组)或者某个业务 DTO 类排第一,基本就能定位到方向。

第二种,jmap -dump,导出完整堆快照,用于 MAT、JProfiler 等工具做深度分析:

jmap -dump:live,format=b,file=/data/dump/heap-$(date +%s).hprof 12345

这里的坑:加live会先触发一次 Full GC,只为 dump 存活对象;不加live会 dump 所有对象(包括垃圾),文件更大但不停顿。生产环境如果堆很大,建议不加live、且优先用HeapDumpOnOutOfMemoryError提前拿到现场,而不是事后手动 dump。

# 推荐:OOM 时自动 dump,先保住现场 -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/dump/ \ -XX:+HeapDumpBeforeFullGC # 在 Full GC 前 dump,能看到"最脏"的状态

3.3 jstack:FGC 频繁时到底要不要看线程栈

很多人一遇到 FGC 就jstack抓线程栈,其实直接关联不大。线程栈主要看的是"线程在干什么、有没有死锁、有没有大量线程 BLOCKED",它不直接反映堆的情况。但有两种场景jstack非常关键:

一是怀疑System.gc()时,可以在代码里加-XX:+ExplicitGCInvokesConcurrent让显式 GC 走并发而不是 STW,但更彻底的做法是找到调用点。jstack配合 Arthas 的stack命令能定位到是谁调用的。

二是 FGC 期间大量线程卡住,jstack能看到线程全在等待,确认是 GC 停顿导致的连锁反应。命令:

jstack 12345 > /tmp/thread-dump-$(date +%s).txt

如果连续抓三次,发现某个线程一直停在同一处,且不是 GC 线程(GC task thread),那它可能是真正的性能瓶颈。

3.4 Arthas:线上排查 FGC 的一把好手

线上环境往往不允许随意 dump、不允许重启复现,Arthas 的价值就体现出来了。几个常用命令:

# 1. dashboard:一屏看全,GC、线程、内存都在这 dashboard # 2. heapdump:等价于 jmap -dump,但更顺手 heapdump /data/dump/heap.hprof # 3. 查看某个类加载了多少实例,定位内存泄漏 vmtool --action getInstances --className com.example.OrderCache --limit 20 -x 2 # 4. 看某个方法的调用链路,定位谁触发了 System.gc() stack java.lang.System gc

dashboard命令的界面里会实时显示堆各区的使用率、GC 次数和耗时,比 jstat 更直观。vmtool --action getInstances是我最常用的——直接列出某个对象的实例,如果OrderCache这种缓存类实例数异常多,基本就是缓存没做容量控制。

stack java.lang.System gc能追踪每一次System.gc()的调用栈,几秒钟就能揪出那个偷偷调 GC 的框架或者 SDK。

3.5 工具选型对照表

不同场景该用哪个工具,一张表说清:

场景首选工具备注
看整体 GC 趋势jstat -gcutil 或 Arthas dashboard轻量,生产首选
看对象类型分布jmap -histo定位"哪类对象多"
深度分析泄漏根因jmap -dump + MAT有 STW 代价,慎用
定位 System.gc() 调用源Arthas stack直击罪魁祸首
看线程与死锁jstack / Arthas thread与 GC 分析配合
可视化实时监控JConsole / VisualVM本地或可开 JMX 的场景
线上实时诊断Arthas免重启、功能全

JConsole 和 VisualVM 更适合本地开发或者有 JMX 开放权限的内网环境,它们能画出漂亮的堆曲线,但生产上要么连不上,要么担心安全风险。生产环境的实战,Arthas + jstat 基本能覆盖 90% 的场景。

4. FGC 频繁的六大典型根因与排查路径

4.1 内存泄漏:最经典也最需要耐心

内存泄漏的表现是:FGC 后老年代占用降不下去,且谷底值逐次抬高。GC 日志上看是"回收率极低",jstat 上看是O列的最低点越来越高。常见的泄漏点有几个:静态 Map 缓存没有过期或容量上限、ThreadLocal 用完了忘记remove()(线程池复用线程时泄漏尤其明显)、监听器注册了没反注册、ClassLoader泄漏(热部署场景)。

排查路径:用jmap -dump导两份快照(间隔 10-30 分钟),在 MAT 里做"Histogram 对比"或"Dominator Tree",看哪些对象增长最多,再顺着引用链找到谁在持有它们。不要只 dump 一次,单份快照看不出"增长",只有两份对比才能锁定泄漏对象。

4.2 元空间撑爆:动态生成类的锅

(Metadata GC Threshold)触发的 FGC,根源往往是动态生成了大量类。反射、动态代理(JDK Proxy / CGLIB)、Spring AOP、Groovy/JS 脚本引擎、JSON 库的某些用法都会生成新类,这些类如果被 ClassLoader 持有着,Metaspace 就一直涨。JDK 8 之后 Metaspace 默认不限上限(受限于本地内存),涨起来能慢慢把物理内存吃干。

处理方式:加上-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m,让它到阈值就触发回收。但设上限只是止损,真正的解法是找到"谁在不停生成类"。Arthas 的classloader -t能看类加载器树,sc -d 类名能看类被哪个加载器加载,顺着查就能找到源头。

4.3 晋升过快:新生代与 Survivor 的配比问题

(Promotion Failed)或者频繁的普通 FGC,很多时候是对象"太早"进老年代。原因是 Survivor 太小,对象在几个 Survivor 之间复制不下,就触发"分配担保"直接进老年代;或者动态年龄判定把它提前晋升了。默认的-XX:MaxTenuringThreshold=15只是上限,JVM 会用动态年龄判定:Survivor 里某个年龄的所有对象大小总和超过 Survivor 空间的一半,这个年龄及以上的对象就直接晋升。

解决办法是给 Survivor 留足空间。默认-XX:SurvivorRatio=8意味着 Eden:Survivor = 8:1,每个 Survivor 只有新生代的 1/10。如果你观察到对象实际存活量不小,可以把比例调到 4(Eden:Survivor = 4:1),让 Survivor 更大,给短命对象更多"死掉"的机会。这一块后面第 5 章会给出计算过程。

4.4 显式 System.gc():最容易忽略的一刀

日志里出现(System.gc())就要警觉,因为这是"人为"触发的全堆回收。触发源五花八门:自己团队写的代码、第三方 SDK、NIO 直接内存回收(DirectByteBuffer的 Cleaner 机制在某些 GC 下会触发)、RMI 的分布式 GC、MBeanMemoryMXBean.gc()。定位方法就是上面说的 Arthasstack java.lang.System gc。

止血方案有两个层次:临时加-XX:+DisableExplicitGC直接忽略显式 GC 请求(但要注意,如果程序依赖它回收直接内存,可能引入新的内存问题);根治是找到调用点改掉。-XX:+ExplicitGCInvokesConcurrent是个折中:把显式 GC 变成并发 GC,不停顿,适合不想动代码又想缓解的场景。

4.5 堆外内存与直接内存:看不见的那部分

有种 FGC 让人摸不着头脑:堆里明明很空,却频繁 FGC。这时候要看直接内存和堆外内存。DirectByteBuffer分配的内存不在堆里,统计工具(如 jmap)看不到,但它占物理内存。当直接内存用尽,Cleaner触发回收时会调用System.gc(),间接引发 FGC。

排查工具推荐NMT(Native Memory Tracking):启动加-XX:NativeMemoryTracking=detail,运行jcmd <pid> VM.native_memory detail,能看各区块的本地内存占用。也可以监控/proc/<pid>/smaps里的实际物理内存。上限用-XX:MaxDirectMemorySize控制,别让它无限膨胀。

4.6 参数设置不合理:那些被抄来抄去的坑配置

很多团队直接抄网上的"万能参数",结果适得其反。常见的有:堆设得过大(FGC 单次耗时随堆线性增长,32G 堆的 FGC 能到 5 秒以上);-Xmn设得太大(新生代过大,YGC 反而变慢,且触发老年代分配担保的频率不一定降低);-Xss设得过大(每个线程 1M 栈,几千线程就是几个 G 的本地内存);没配MaxMetaspaceSize;堆和物理内存不匹配导致被 OS 换页。参数没有"标准答案",只有"匹配你的业务特征"。第 5 章会给一套从业务特征反推参数的模板。

5. 一份可复制的 YGC/FGC 调优参数模板与落地流程

5.1 从业务特征反推堆大小

调参第一步不是打开参数列表,而是问清楚三个问题:实例的物理内存多大、服务是"吞吐优先"还是"延迟敏感"、平均对象存活量大概多少。给一个通用的推算逻辑:如果容器(或裸机)给了 8G 物理内存,操作系统、JVM 自身(元空间、线程栈、代码缓存、直接内存)加起来至少要留 2-3G,堆最多给 4-5G。

-Xms4g -Xmx4g # 堆固定,避免动态扩缩容带来的额外 GC -Xss512k # 线程栈,视并发线程数调整 -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:MaxDirectMemorySize=512m -XX:ReservedCodeCacheSize=256m

-Xms和-Xmx设成一样是硬性建议,目的是让 JVM 启动时就申请好内存、避免运行期扩容(扩容本身会触发 GC)。总的预留空间算清楚了,堆内参数才有意义。

5.2 新生代与 Survivor 比例的取舍(附计算过程)

假设堆固定 4G,用 Parallel 或 CMS(老年代),新生代给多少合适?新生代比例一般用-XX:NewRatio或-Xmn控制。NewRatio=2表示老年代:新生代 = 2:1,即新生代占堆的 1/3;NewRatio=1表示 1:1,新生代占一半。延迟敏感的服务可以把新生代给大一些(占堆的 1/3 到 1/2),让绝大多数短命对象在新生代就被清掉,减少晋升。

假设我们取新生代 1.5G(NewRatio=2约等于堆的 1/3),SurvivorRatio=8:Eden = 1.5G × 8/10 = 1.2G,每个 Survivor = 150M。如果实测发现 YGC 后存活对象就有 200M,那 150M 的 Survivor 装不下,对象会通过分配担保直接进老年代——这就是典型的"新生代够大但 Survivor 太小"。改成SurvivorRatio=4:Eden = 1.5G × 4/6 = 1.0G,每个 Survivor = 250M,存活对象能装下了,晋升压力立刻缓解。

提示:SurvivorRatio调小(4 甚至 2),Eden 会变小、YGC 会更频繁,但晋升会减少。这是一对权衡,需要结合 YGC/FGC 的实际数据反复验证,别拍脑袋定。

5.3 收集器选型:别在参数上硬刚,换个收集器可能就通了

如果你的调优目标是把 FGC 停顿压到 200ms 以内,靠调 Parallel 的参数很难做到,因为 Parallel Old 本身就是"单次全堆整理",堆越大停顿越长。这时候优先级应该是"换收集器"而不是"调参数"。一张选型参考:

收集器组合适用场景特点
Serial + Serial Old客户端、小内存(< 100M)简单,单线程,停顿可控
Parallel Scavenge + Parallel Old后台批处理、吞吐优先吞吐高,FGC 停顿随堆增大
ParNew + CMS延迟敏感的 Web 服务(旧版本常用)并发回收,但碎片、Concurrent Mode Failure 问题多
G1中大堆(4G-32G)、延迟敏感可预测停顿,需设MaxGCPauseMillis
ZGC / Shenandoah大堆、极低延迟停顿与堆大小近乎无关,JVM 版本要求高

G1 的关键参数是-XX:MaxGCPauseMillis=200(期望停顿目标,不是硬保证)和-XX:G1HeapRegionSize(区域大小,通常让 JVM 自己算)。用 G1 时记住:它通过 Region 增量回收来避免一次性全堆扫描,所以"FGC 次数少"但不代表"没有停顿",要关注[GC pause]日志里的实际停顿。

5.4 调优闭环:一次改一个参数,用数据验证

调参最大的忌讳是一次改五六个参数,然后不知道哪个起了作用(或者哪个把系统搞崩了)。正确做法是闭环迭代:

  1. 用 jstat + GC 日志记录基线:YGC 频率、单次耗时、FGC 频率、老年代谷底值。
  2. 提出一个假设,比如"Survivor 太小导致提前晋升"。
  3. 只改一个相关参数,比如把SurvivorRatio从 8 改成 4。
  4. 观察至少 30 分钟到几小时的数据,和基线对比。
  5. 有效就保留,无效就回滚,换下一个假设。

配合压测环境验证会更快,但要注意压测流量和线上真实流量在对象分配特征上可能差异很大,别拿压测结论直接上生产。上线新参数时最好灰度一个节点,观察一天再全量。

6. 常见问题速查表与踩过的坑

6.1 高频问题速查表

现象大概率原因处理方向
FGC 频繁但老年代占用很低System.gc() 或元空间触发查日志触发原因,禁用显式 GC
FGC 频繁且老年代一直高位内存泄漏或缓存无界dump 比对,加缓存上限
YGC 每秒几十次,但停顿很短Eden 偏小适当增大新生代,观察是否划算
YGC 单次耗时超过 100ms存活对象多、复制成本高检查是否对象大量存活,考虑调 Survivor
Promotion Failed老年代没空间,晋升失败增大老年代或换 G1
Concurrent Mode FailureCMS 回收跟不上分配降低 CMS 触发阈值或换收集器
OOM: Metaspace动态类无限生成设 MaxMetaspaceSize,定位生成源
OOM: Direct buffer memory直接内存耗尽查 Netty/NIO 分配,设 MaxDirectMemorySize

6.2 我踩过的那些坑

第一个坑:盲目加堆。一个服务 FGC 频繁,第一反应是-Xmx从 4G 提到 8G。结果 FGC 次数确实少了,但单次耗时从 400ms 涨到 900ms,接口抖动更严重了。原因是堆越大,Full GC 要扫描和整理的对象越多,停顿越长。后来把根因找到——是缓存 Map 没设上限——改代码后堆降回 4G,FGC 一天一次。

第二个坑:jmap -dump:live在生产高峰执行。这个命令会先触发一次 Full GC,我当时没意识到,导完 dump 服务直接超时报警。后来换了策略:无关 dump 就不加live,或者用-XX:+HeapDumpOnOutOfMemoryError提前配置好、OOM 时自动落盘,避免手动干预。

第三个坑:忘了看 Survivor 的实际使用。jstat 里S0和S1只有一个是 100% 用的,另一个是 0,这是复制算法的正常现象(一块在用、一块空闲)。有人看到 Survivor 100% 就以为出问题了,其实要看的是存活对象有没有超过 Survivor 容量。用jstat -gcnew看TT阈值变化更准。

第四个坑:只看GCT不看分布。GCT(GC 总耗时)是个累计值,单看它没意义。要看YGCT/YGC得出的 YGC 平均耗时,和FGCT/FGC得出的 FGC 平均耗时,再看单位时间内的 GC 耗时占比(GC 开销占比超过 5%-10% 就该优化了)。

第五个坑:换收集器不看版本。G1 在 JDK 8 里虽然能用,但早期版本问题不少;ZGC 需要 JDK 11+,分代 ZGC 更要 JDK 21。换收集器前先确认版本支持,别在 JDK 8 上折腾 ZGC。

我自己最深的体会是:YGC 和 FGC 的分析,参数知识只占三成,剩下七成是"用数据说话"的习惯——把基线打出来,把每次改动的前后对比记下来,把日志里的触发原因当线索去追。养成这个习惯之后,绝大多数所谓的"疑难 GC 问题",基本上一两个小时就能定位到方向。GC 日志别嫌它啰嗦,它其实一直在把答案念给你听,只是看你有没有耐心逐字读完。

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

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

立即咨询