线上服务跑得好好的,某天监控突然报警: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=100MJDK 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 区的使用百分比 |
| E | Eden 区使用百分比 |
| O | 老年代使用百分比 |
| M | Metaspace 使用百分比 |
| 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 gcdashboard命令的界面里会实时显示堆各区的使用率、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 调优闭环:一次改一个参数,用数据验证
调参最大的忌讳是一次改五六个参数,然后不知道哪个起了作用(或者哪个把系统搞崩了)。正确做法是闭环迭代:
- 用 jstat + GC 日志记录基线:YGC 频率、单次耗时、FGC 频率、老年代谷底值。
- 提出一个假设,比如"Survivor 太小导致提前晋升"。
- 只改一个相关参数,比如把
SurvivorRatio从 8 改成 4。 - 观察至少 30 分钟到几小时的数据,和基线对比。
- 有效就保留,无效就回滚,换下一个假设。
配合压测环境验证会更快,但要注意压测流量和线上真实流量在对象分配特征上可能差异很大,别拿压测结论直接上生产。上线新参数时最好灰度一个节点,观察一天再全量。
6. 常见问题速查表与踩过的坑
6.1 高频问题速查表
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| FGC 频繁但老年代占用很低 | System.gc() 或元空间触发 | 查日志触发原因,禁用显式 GC |
| FGC 频繁且老年代一直高位 | 内存泄漏或缓存无界 | dump 比对,加缓存上限 |
| YGC 每秒几十次,但停顿很短 | Eden 偏小 | 适当增大新生代,观察是否划算 |
| YGC 单次耗时超过 100ms | 存活对象多、复制成本高 | 检查是否对象大量存活,考虑调 Survivor |
Promotion Failed | 老年代没空间,晋升失败 | 增大老年代或换 G1 |
Concurrent Mode Failure | CMS 回收跟不上分配 | 降低 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 日志别嫌它啰嗦,它其实一直在把答案念给你听,只是看你有没有耐心逐字读完。