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,这个状态持续了数月。

GC 耗时曲线:从 20ms 基线到 300ms+ 尖刺

异常信号

某天监控面板上 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。

参数调整 vs YGC 耗时:所有尝试都无效

一行被忽视的日志

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]  ← 异常

GC 日志 Times 行:正常 vs 异常

这行里藏了一个被 99% 的人忽略的信号。当你能读懂它的时候,就不是调参数的事了。

【路标】追到 OS:缺页中断是元凶

real 和 user 之间差了 4 倍

前面那行日志里有三个字段:usersysreal

多数人只看 GC 暂停时间(real),但重要的不是绝对值,是三个字段的比例关系。real 是墙钟时间,user 是 CPU 执行时间——正常情况下 YoungGC 的 real ≈ user + sys(所有时间都花在 CPU 上)。但异常行里 real=0.32user+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 的根本诱因。

THP 参数配置对比

方案对比

方案 生效范围 GC 尖刺 THP 收益 JDK 要求
A:禁用 THP OS 全局 消除 全部
B:defer + JVM OS + JVM 消除 保留 JDK 17+
C:memory reservation 容器层 辅助 依赖 A/B 全部

改后效果

方案 B 应用后,YGC 耗时从 300ms 锯齿恢复到了 20ms 基线。同一台机器的监控面板上,耗时曲线从尖刺丛生变回平直线。

改后 YGC 恢复到 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_scannedcompact_isolated 持续变化 → THP compact 正在发生 - THP defrag = always根因确认

下次你的 GC 飙高时,顺手看一眼 Times 行的 real/user。如果比值大于 2,停手别调参数,向上看。

下篇我们聊"JVM 参数不是越多越好"——一个服务 50+ 个 JVM 参数,真正起作用的不到 10 个。