JVM 内存结构与 GC 调优:从 900ms 到 180ms

堆调到 8G 停顿反而变长?从分代布局讲清四类 GC 症状的判别方式,再用一次真实调优过程说明参数该怎么推。

线上一个服务开始频繁 Full GC,平均每 40 秒一次,每次停顿 800ms,接口 P99 从 120ms 涨到 900ms。运维的第一反应是把堆调大——从 4G 调到 8G,停顿反而变成 1.6 秒,因为它把更多的垃圾攒到一次回收。

这是 GC 调优里最反直觉的一点:堆越大,单次 Full GC 的停顿越长。调优的目标从来不是「让堆够大」,而是「让垃圾在年轻代就死掉」。

这篇文章从 JVM 内存结构的实际布局讲起,然后给出四类症状对应的参数组合,最后是一个完整的分代调优过程。

一、先把内存结构画清楚

JVM 的内存不是一块,是好几块:

┌─────────────────────────────────────────────────────────┐
│  堆(-Xmx / -Xms)                                       │
│  ┌──────────────┬──────────────┬──────────────────────┐  │
│  │  Eden        │ Survivor S0  │ Survivor S1          │  │  ← 年轻代 -Xmn
│  │  (8/10)      │ (1/10)       │ (1/10)               │  │
│  └──────────────┴──────────────┴──────────────────────┘  │
│  ┌──────────────────────────────────────────────────┐    │
│  │  老年代(Old Gen)                                │    │
│  └──────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────┘
┌──────────────┐ ┌────────────┐ ┌──────────────┐ ┌────────┐
│ Metaspace    │ │ 线程栈     │ │ Code Cache   │ │ 直接   │
│ (方法区)     │ │ (每线程1M) │ │ (JIT 编译)   │ │ 内存   │
└──────────────┘ └────────────┘ └──────────────┘ └────────┘
                ↑ 这些都不在堆里,但算进容器内存

三个关键事实:

  1. Eden : S0 : S1 默认 8:1:1,年轻代总共可用的是 Eden + 一个 Survivor = 9/10。所谓「Survivor 空间不足」就是指这 1/10 装不下存活对象。
  2. Metaspace 在堆外,默认无上限(卸载不掉的类会一直涨)。
  3. -Xmx 只限制堆,容器 limit 还包括后面四块。这正是上一篇 OOMKilled 的主要成因。

二、先搞清楚你是哪种「慢」

GC 导致的慢分四类,对应的处理完全不同。不要先改参数,先分类。

症状 观测数据 根因 方向
A. 周期性长停顿 Full GC 频繁,老年代回收后仍占 60%+ 内存泄漏或晋升过快 查泄漏 / 调分代
B. 高频短停顿 Young GC 每 1~2 秒一次,单次 20ms 年轻代太小 加年轻代
C. 单次超长停顿(>2s) Full GC 单次 3s+ 堆太大 / 用错收集器 换 G1/ZGC
D. 长时间卡顿但 GC 日志正常 GC 耗时占比 <5% 不是 GC 问题 查锁、IO、DB

判断 D 类特别重要。 很多「以为是 GC 问题」的排查最后发现是下游超时。先用 GC 日志算出 STW 总时长占比:

# 从 GC 日志统计总停顿时间
grep -oP 'Pause Young.*?\K\d+(?=ms)' gc.log | awk '{s+=$1} END {print "总停顿:", s/1000, "秒"}'
# 再除以运行时长,占比 <5% 就别在 GC 上花时间了

三、必须开的诊断参数

调优前先能看见。这四个参数应该进所有 Java 服务的默认配置:

-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/var/log/app/gc-%t.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=20M

JDK 9+ 用统一的 -Xlog:

-Xlog:gc*,gc+heap=info,gc+age=trace:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=20M

gc+age=trace 非常有用,它会打印对象晋升年龄分布:

Desired survivor size 43646976 bytes, new threshold 15 (max 15)
- age   1:   18485872 bytes,   18485872 total
- age   2:    3443992 bytes,    3443992 total
...
- age  15:     128832 bytes,     128832 total

如果 age 1 的对象存活量就有 18 MB,而 Survivor 只有 43 MB,说明大量对象在第一次 GC 后还活着——它们会被反复搬运,最终晋升到老年代。这是「晋升过快」的典型证据。

四、四类症状的处置方式

A 类:内存泄漏 / 晋升过快

先确认是不是真泄漏。方法:连续三次 Full GC 后看老年代占用。

jcmd 1 GC.heap_info

输出:

garbage-first heap   total 4194304K, used 3355443K [0x..., 0x...)
  region size 2048K, 24 young (49152K), 1 survivors (2048K)
 Metaspace       used 128349K, capacity 131072K, ...

连续采样三次(间隔 2 分钟,中间手动 jcmd 1 GC.run),如果老年代 used 只涨不跌,就是泄漏。此时改参数没用,要用 MAT 找引用链:

jcmd 1 GC.heap_dump /tmp/heap.hprof
# 拉到本地用 MAT 打开,看 Dominator Tree 最大的几个对象

如果是晋升过快而非泄漏(老年代上涨但 Full GC 后能回落到 30% 以下),才轮得到调参:

-Xmn4g                        # 加大年轻代,让对象活到自然死亡
-XX:SurvivorRatio=8           # Eden:单个S = 8:1(默认)
-XX:MaxTenuringThreshold=15   # 提高晋升年龄(默认15)
-XX:+UseAdaptiveSizePolicy    # 默认开,会动态调整 Survivor

-XX:MaxTenuringThreshold 调到 15(G1 下最大就是 15)能减少晋升,但也可能造成 Survivor 溢出转为提前晋升(premature promotion)。要看日志里的 to-space exhausted——出现这个说明 Survivor 真装不下了,该加 -Xmn 而不是调阈值。

B 类:年轻代太小

现象是 Young GC 次数极多。假设堆 8G,默认 -Xmn 是堆的 1/3 ≈ 2.7G,如果 QPS 高、每秒产生 500 MB 垃圾,那大约 5 秒就会触发一次 Young GC。

处置:加大 -Xmn 或提高 -XX:NewRatio 反向调整。但注意上界——年轻代太大会让单次 Young GC 停顿变长(复制存活对象更久)。

经验法则:单次 Young GC 停顿控制在 20~50 ms,频率 1 次/5 秒以上。

C 类:单次超长停顿

如果是 Parallel / CMS 收集器,堆超过 8G 就该换 G1:

-XX:+UseG1GC
-XX:MaxGCPauseMillis=200        # 目标停顿,G1 会据此调整年轻代大小
-XX:G1HeapRegionSize=8m         # region 大小,堆 4-16G 建议 8m
-XX:G1NewSizePercent=10
-XX:G1MaxNewSizePercent=60
-XX:G1MixedGCLiveThresholdPercent=85   # 存活超过85%的region不参与混合回收

MaxGCPauseMillis 是软目标,G1 会尽力但可能达不到。设在 200ms,实际可能 150~300ms 波动。设太小(比如 50ms)会导致年轻代被压得很小,Young GC 变得频繁,吞吐反而下降。

JDK 11+ 且堆 < 16G、延迟敏感的服务,可以上 ZGC:

-XX:+UseZGC -XX:+ZGenerational    # JDK 21+ 分代 ZGC

ZGC 的停顿基本在 1ms 以内,代价是吞吐量下降约 10% 和更高的内存占用(需要额外 20% 的堆做并发标记)。适合「延迟优先」的在线服务,不适合离线批处理。

D 类:不是 GC 问题

用 GC 日志证明「GC 不是瓶颈」之后,方向转向:

# 1. 线程 Dump,看是不是锁等待
jstack 1 > /tmp/thread.txt
grep -c 'BLOCKED\|WAITING' /tmp/thread.txt

# 2. 如果有 Arthas,直接看慢方法
arthas-boot > trace com.example.OrderService getOrder '#cost > 200'

经验上,接口 P99 高但 GC 正常的成因排序是:DB 慢查询 > 下游接口超时 > 线程池打满 > 锁竞争 > GC。

五、一个完整的调优过程

以那个「4G 堆每 40 秒 Full GC」的服务为例,真实过程是这样的。

第 1 步:采集基线

jstat -gcutil 1 1000 30
  S0     S1     E      O      M     CCS    YGC     YGCT     FGC    FGCT     GCT
  0.00  98.32  12.41  99.61  92.18  89.04  1241   12.043    38   30.412   42.455
  0.00  98.32  18.22  99.63  92.18  89.04  1242   12.051    38   30.412   42.455

关键读法:

  • O(老年代)99.6% → 老年代已满,这才是 Full GC 的直接原因
  • FGC 38 次,FGCT 30.4 秒 → 平均每次 800ms,符合观察
  • YGC 1241 次,平均 9.7ms → 年轻代回收很快,不是瓶颈
  • S1 98.32% → Survivor 快满了,说明存活对象多

结论:不是年轻代问题,是老年代被塞满了。

第 2 步:确认是泄漏还是晋升过快

jcmd 1 GC.run          # 手动 Full GC
jstat -gcutil 1 1000 3 # 立刻看

如果 O 从 99.6% 掉到 15%,说明堆里没有泄漏,全是垃圾,只是晋升太快。如果只掉到 85%,就是泄漏。

这个案例掉到了 12%,所以是空间配置问题。

第 3 步:定位晋升过快的证据

# 看 GC 日志里的晋升统计
grep 'to-space exhausted\|Promotion' gc.log | tail -20

发现大量 to-space exhausted——Survivor 装不下,对象直接被塞进老年代。这解释了为什么老年代涨得这么快。

第 4 步:调参并复测

# 原来
-Xmx4g -Xms4g -XX:+UseParallelGC

# 调整后
-Xmx4g -Xms4g \
-XX:+UseG1GC \
-Xmn1536m \                       # 年轻代从 1.3G 提到 1.5G
-XX:SurvivorRatio=8 \
-XX:MaxTenuringThreshold=15 \
-XX:MaxGCPauseMillis=200 \
-XX:+ParallelRefProcEnabled \      # 并行处理 Reference,减少 STW
-XX:+AlwaysPreTouch                # 启动时预分配页,避免运行时缺页停顿

-XX:+AlwaysPreTouch 是个容易被忽略的优化:它让 JVM 在启动时把所有堆页真正分配并触碰一遍。启动慢几秒,但运行期不会因为缺页中断出现偶发的长停顿。对延迟敏感的服务值得加。

第 5 步:结果

YGC 从 1241 次/小时 → 420 次/小时(频率降低 66%)
FGC 从 38 次/小时 → 0 次/小时
单次 Young GC 停顿 9.7ms → 38ms
P99 从 900ms → 180ms

代价是单次年轻代停顿变长了(9.7 → 38ms),但总停顿大幅下降。这是典型的用吞吐换停顿次数的取舍。

六、几个常见误判

1. System.gc() 能解决内存问题

System.gc() 触发的是 Full GC。如果对象还被引用着,GC 完也释放不掉,只是白白停顿一次。真正的内存问题只能靠消除引用。

2. 把 -Xms 设小于 -Xmx

-Xms1g -Xmx8g    # 错误示范

运行期堆会动态扩张,每次扩容都可能触发 Full GC(-XX:+UseParallelGC 下叫 Ergonomics 扩容)。生产环境 -Xms 和 -Xmx 设成一样,避免动态调整带来的抖动。

3. 只看 GC 次数,不看停顿总时长

Young GC 100 次 × 5ms = 500ms 总停顿,比 Full GC 1 次 × 800ms 更好。评价指标应该是 GC 时间占比(GCT / uptime),目标是 < 5%,最好 < 1%。

4. 调大 -Xmn 一定能减少 Full GC

如果在 SurivorRatio 不变的情况下加大年轻代,Survivor 也同步变大,确实能减少晋升。但如果对象生命周期本身就很长(比如本地缓存),它们早晚会进老年代,加年轻代只是延后问题。这种情况要限制缓存大小。

七、速查:不同场景的推荐配置

场景 堆 收集器 关键参数
小服务(< 2G) 1~2G G1 MaxRAMPercentage=70
通用 API(4~8G) — G1 MaxGCPauseMillis=200 AlwaysPreTouch
低延迟(P99 < 50ms) 8~32G ZGC +ZGenerational
离线批处理(大堆) 16G+ Parallel 大堆 + 高吞吐,不追延迟
内存受限容器 — G1 MaxRAMPercentage=65 留堆外空间

最后一句:GC 调优的本质是「让对象在年轻代死掉」。所有参数都是为这一个目标服务的。如果一个对象注定要进老年代,那要么它生命周期真的长(那就放缓存里、算好大小),要么它就是泄漏。这两种情况都不是调参数能解决的。

查看「Java 与 JVM」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

JVM 内存结构与 GC 调优:从 900ms 到 180ms - 起航社区