GC 停顿飙了 15 倍:元凶不在 JVM 在缺页中断
场景:一个 G1GC 服务的 YoungGC 耗时从 20ms 突升到 300ms+,调了所有 GC 参数都无效。根因不在 GC 日志里,在缺页中断。 路径:GC 停顿飙升 → real >> user 关键线索 → 缺页中断排查 → THP 方案 → 验证命令链
YoungGC 耗时一直稳定在 15-25ms——某天突然飙到 300ms+。调大了年轻代、调了 MaxGCPauseMillis、调整了并发线程数——全都不管用。
不是 GC 参数的问题。根本原因是 缺页中断(major page fault)——GC 线程在等 OS 把内存页从磁盘读回来。
上篇讲了 CMS 的 promotion failed 是碎片化和并发周期跟不上导致的,这篇我们来看另一个维度的 GC 异常——YoungGC 耗时飙升了 15 倍,根因却在 OS 层。
【画像】均衡型 Web 服务的异常征状
应用画像
一个均衡型 Web 服务,8GB 堆 + G1GC(JDK 17),跑在 8C16G 容器里。YoungGC 基线稳定在 15-25ms,这个状态持续了数月。

异常信号
某天监控面板上 YGC 耗时曲线从平滑的 20ms 基线突然变成 100-350ms 的锯齿状尖刺。异常的关键特征:
- 频率不变:YGC 触发间隔仍然是 4-5 秒(分配速率未变)
- 耗时暴增:单次 YGC 从 20ms 变成 100-350ms,波动剧烈
- CPU 正常:用户态 CPU 没有显著升高,排除"GC 线程忙"的可能
- IO 正常:磁盘 IO 处于基线水平,没有日志刷盘或 swap 活动
这三个特征组合在一起,传递了一个反直觉的信号——问题可能不在 GC 本身。
【盲区】大多数人的第一反应:调参数
典型操作——全部无效
大多数人的思路是按"YGC 耗时高 = 年轻代回收慢"来排查,典型操作为:
# 调大年轻代——减少单次 GC 的存活对象量
-XX:NewSize=6g -XX:MaxNewSize=6g
# 降低目标停顿时间——让 G1 更激进地选择回收量
-XX:MaxGCPauseMillis=100
# 增加并行线程——加速标记和复制
-XX:ParallelGCThreads=8
全部无效。调大年轻代后基线降到 18ms 但尖刺仍在;调 MaxGCPauseMillis 后 GC 频率反而升高了(目标变严后 G1 更频繁地触发 YGC);加线程数毫无变化——因为瓶颈根本不在 CPU。

一行被忽视的日志
GC 日志中有一行平时没人看的信息:
[Times: user=0.08 sys=0.02 real=0.32 secs]
对比一下正常行:
[Times: user=0.18 sys=0.01 real=0.19 secs] ← 正常
[Times: user=0.08 sys=0.02 real=0.32 secs] ← 异常

这行里藏了一个被 99% 的人忽略的信号。当你能读懂它的时候,就不是调参数的事了。
【路标】追到 OS:缺页中断是元凶
real 和 user 之间差了 4 倍
前面那行日志里有三个字段:user、sys、real。
多数人只看 GC 暂停时间(real),但重要的不是绝对值,是三个字段的比例关系。real 是墙钟时间,user 是 CPU 执行时间——正常情况下 YoungGC 的 real ≈ user + sys(所有时间都花在 CPU 上)。但异常行里 real=0.32 而 user+sys=0.10,差了 3 倍多。
这意味着 GC 线程花了大量时间不在 CPU 上运行——它们在等待。被谁?被 OS 挂起。而 OS 挂起用户态线程最常见的原因之一,就是缺页中断(major page fault):线程访问的内存页不在物理内存中,OS 需要从磁盘换入,线程进入 D 状态。
| 模式 | 含义 | 排查方向 |
|---|---|---|
real ≈ user |
正常,CPU 密集型 | GC 参数调优 |
real >> user |
线程被 OS 挂起 | OS 层排查 |
user >> real |
多核并行 | 正常(YGC 并行) |
当 real / user ≥ 2 时,就别再看 GC 参数了——问题在 OS。
GC 日志不是一个性能计数器——它是一份 OS + JVM 联合体检报告。
排查链路:GC 日志 → OS 层计数
顺着这个信号,排查路径从 JVM 层切换到了 OS 层:
[Times: real=0.32 user=0.08] → real/user > 2
→ 线程挂起原因排查
→ /proc/vmstat 查看缺页计数
→ sar -B 查看缺页频率
→ 确认:major page fault 在 YGC 期间飙升

证据一:/proc/vmstat
$ grep -E 'pgfault|pgmajfault' /proc/vmstat
pgfault 2847158392
pgmajfault 28415 ← major page faults 累计
pgpgin 142375828
pgpgout 98327423
pgmajfault(major page faults)的累计值达到 28415。正常运行时 pgmajfault 也会缓慢增长(类加载、JIT 编译、mmap 文件都会触发少量缺页),但数万次且持续攀升意味着大量内存访问触发了磁盘换入——这不是正常的内存分配模式。
证据二:sar -B 观察突发
$ sar -B 1
16:32:01 pgpgin/s pgpgout/s fault/s majflt/s %vmeff
16:32:02 8273.00 2351.00 4827.00 23.00 94.27
16:32:03 0.00 0.00 4128.00 0.00 0.00
16:32:04 6138.00 1092.00 5031.00 18.00 91.35
16:32:05 0.00 0.00 3942.00 0.00 0.00
majflt/s 在 16-23 之间波动,且与 pgpgin/s 同步——在内存紧张的瞬间,大量 major page fault 触发了磁盘换页。而这些瞬间恰好对应 GC 停顿。为什么 GC 期间会触发缺页中断?因为 YoungGC 需要遍历和复制存活对象,当这些对象所在的内存页被换出或尚未分配物理页框时,就会触发 major page fault。

根因:透明大页(THP)的连锁反应
深入排查后,根因指向了 Transparent Huge Pages(透明大页) 的 defrag 设置。THP 是 Linux 内核的一种内存管理机制——它自动将 4KB 的小页合并为 2MB 的大页,以减少 TLB miss。
当 /sys/kernel/mm/transparent_hugepage/defrag 设为 always 时,内核在分配大页时会同步执行内存紧缩(direct compaction)——扫描和迁移物理页框来凑出一段连续的 2MB 空间。
这段瓶颈会沿着一条链级联放大:
THP defrag=always → direct compaction 阻塞分配线程 → 内存分配延迟
→ 内存压力累积 → page reclaim 回收已缓存页
→ 被回收的页需要写回swap → 后续访问触发 major page fault
→ GC 线程陷入 D 状态 → YGC 耗时从 20ms 飙到 300ms+
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
$ cat /sys/kernel/mm/transparent_hugepage/defrag
[always] defer defer+madvise madvise never
这里有两个层面:direct compaction(线程直接参与紧缩,被挂起等待)和 major page fault(因紧缩导致的内存压力触发换页,线程进入 D 状态)。GC 日志的 real >> user 是两者的叠加——既有 compaction 导致的线程阻塞,也有 page fault 导致的磁盘 I/O 等待。
G1GC 在 YoungGC 期间会进行大量的对象复制和内存分配,当 THP defrag 设为 always 时,每一次分配都可能触发 direct compaction。而 compaction 引发的内存压力又会触发 page reclaim,让后续 GC 访问到已被换出的页。
为什么只在 GC 期间出现
非 GC 期间的内存分配是渐进式的,每次分配的量少,触发 compaction 的概率低,也不会在短时间内造成内存压力脉冲。但 YoungGC 期间 G1 需要批量分配新的 Eden 和 Survivor 区域——一次 YGC 分配数百 MB 的连续虚拟地址空间,短时间内大量大页分配请求集中爆发,compaction 和 page reclaim 被频繁触发。这正是尖刺只出现在 YGC 期间的底层原因。
【方案】THP 参数调整
方案 A:禁用 THP(最简单的止血方案)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
这个方案最直接、最保守,适用于所有 JDK 版本。代价是失去了大页的 TLB 性能优势,但对于 8C16G 的普通 Web 服务影响可忽略。注意需要 root 权限——容器内通常无法修改 /sys/kernel/mm/,需在宿主机层面配置或容器启动时通过 securityContext.sysctls 注入。
方案 B:defer 模式 + JVM 参数(JDK 17+ 推荐)
# OS 层:允许 MADVISE 级别的 THP,但 defer 紧缩
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo defer > /sys/kernel/mm/transparent_hugepage/defrag
# JVM 层:启用 THP 适配
-XX:+UseTransparentHugePages # JDK 17+
defer 模式下,大页分配不会阻塞线程——内核在后台线程(kcompactd)中执行紧缩,分配线程直接使用 4KB 小页继续运行。JDK 17+ 的 -XX:+UseTransparentHugePages 让 JVM 在分配大页时使用 MADV_HUGEPAGE 建议,内核可以在不影响应用线程的情况下合并大页。
为什么方案 B 更好:既避免了 direct compaction 导致的 GC 尖刺,又保留了 THP 的 TLB 收益。但仅适用于 JDK 17+——JDK 11 及以下版本的 -XX:+UseTransparentHugePages 存在稳定性问题(JDK-8253871)。
方案 C:容器内存 reservation(配套加固)
# Docker / Kubernetes 资源预留
resources:
requests:
memory: "12Gi" # 预留 > JVM 堆 + 堆外
limits:
memory: "16Gi" # 限制不变
确保容器请求的内存大于 JVM 实际使用量。当 memory.requests < memory.limits 时,OS 可能 oversubscribe 内存,增加缺页中断的概率。预留足够的内存可以减少 major page fault 的根本诱因。

方案对比
| 方案 | 生效范围 | GC 尖刺 | THP 收益 | JDK 要求 |
|---|---|---|---|---|
| A:禁用 THP | OS 全局 | 消除 | 无 | 全部 |
| B:defer + JVM | OS + JVM | 消除 | 保留 | JDK 17+ |
| C:memory reservation | 容器层 | 辅助 | 依赖 A/B | 全部 |
改后效果
方案 B 应用后,YGC 耗时从 300ms 锯齿恢复到了 20ms 基线。同一台机器的监控面板上,耗时曲线从尖刺丛生变回平直线。

现在这个服务的 YGC 耗时重新稳定在 15-25ms,THP 的 TLB 收益也保留了。
【标记】在你的环境中复现排查链
你可以用下面的命令链在自己的环境中检查是否存在相同问题:
# 第一步:找到 GC 日志(-Xloggc 指定的路径)
# 常见位置:/opt/app/logs/gc.log、/var/log/gc.log、应用目录下
# G1GC 格式: [Times: user=0.08 sys=0.02 real=0.32 secs]
grep 'Times:' /path/to/gc.log | \
awk '{real=$NF; user=$(NF-2); gsub(/secs/,"",real); \
if (real/user > 2) print $0, "[ALERT] real/user=" real/user}'
# 第二步:检查 THP 配置
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
# 第三步:查看缺页中断统计(关注 pgmajfault 和 compact_ 系列)
cat /proc/vmstat | grep -E 'pgfault|pgmajfault|compact'
# 第四步:实时观察缺页中断(在 GC 期间运行)
sar -B 1
判断标准:
- real/user > 2 → 怀疑 OS 层问题
- pgmajfault 累计数 > 1000 且持续增长 → 缺页中断可疑
- compact_free_scanned 和 compact_isolated 持续变化 → THP compact 正在发生
- THP defrag = always → 根因确认
下次你的 GC 飙高时,顺手看一眼 Times 行的 real/user。如果比值大于 2,停手别调参数,向上看。
下篇我们聊"JVM 参数不是越多越好"——一个服务 50+ 个 JVM 参数,真正起作用的不到 10 个。