Skip to content

面试官追问:设计一个 100ms 以内 GC 停顿的系统

提出问题

"如果系统要求 GC 停顿必须控制在 100ms 以内,你怎么设计 JVM 参数和架构?"——这是 JVM 面试里区分候选人层级的一道压轴题。初级答法是把 -XX:MaxGCPauseMillis=100 一贴了事;但面试官真正想看的是:你是否理解停顿的物理来源,是否知道每种收集器的停顿下限,以及当参数调优触及天花板时,能否从架构层面破局。

生产上真会遇到这个约束:交易撮合、实时风控、广告竞价(RTB 要求 100ms 内出价)、游戏后端帧同步——这些场景一次 STW 超过 100ms,就意味着丢单、超时、掉线。而 Java 应用堆一大,Full GC 动辄几秒,这道题就成了架构师必须回答的现实问题。

停顿的物理来源:STW 到底在等什么

GC 停顿不是"系统忙"那么简单,它由三部分构成:

  1. 根扫描(Root Scanning):STW 遍历所有线程栈、JNI 引用、Class 元数据,找到存活起点。线程越多、栈越深,开销越大。500 个线程的 Java 应用,根扫描一次就要 10-30ms,已经把 100ms 预算吃掉 1/3 了。
  2. 对象图遍历(Mark/Copy):从根出发,沿引用链标记或复制存活对象。堆内存活对象越多,耗时越长。G1 的 Mixed GC 里,如果存活率 50% 的 Region 有 100 个,复制 50GB 对象即使并发也扛不住。
  3. 引用处理(Reference Processing):处理 SoftReference / WeakReference / PhantomReference / finalize() 队列。如果应用大量使用 WeakReference 做缓存,这个阶段可能卡 10ms+。

还有一个隐藏的停顿来源:元空间(Metaspace)的类卸载(Class Unloading)。当应用频繁做动态类加载(如 CGLIB 代理、Groovy 脚本热加载),元空间触发 GC 时,需要扫描所有类加载器的引用链。一个用了 2000+ CGLIB 代理的 Spring 应用,Class Unloading 阶段可能吃掉 30-50ms。低延迟场景下必须控制动态代理生成数量,或者用 -XX:MetaspaceSize 预分配元空间。

关键结论:ZGC 和 Shenandoah 通过并发根扫描 + 并发引用处理,把 1 和 3 从 STW 清单里移除了,所以它们的停顿不随堆大小增长。G1 的 Young GC 还要 STW 做根扫描。

收集器选型对比表

特性Parallel GCG1ZGCShenandoah
机制标记-整理,全 STW分代 Region,并发标记+STW 复制染色指针+读屏障,几乎全并发写屏障+转发指针,几乎全并发
典型停顿(16GB 堆)200ms-5s50-200ms<10ms<10ms
与堆大小的关系线性增长亚线性增长基本无关基本无关
最小 STW几十 ms约 50ms<1ms<1ms
JDK 版本全版本JDK 9+JDK 11 实验 / JDK 17+ 生产JDK 11 实验 / JDK 17+ 生产
适合场景离线批处理中型堆(4-16GB),中等延迟要求大堆低延迟,50GB+与 ZGC 对标,Red Hat 系发行版常用
并发根扫描
并发引用处理

一个真实案例:某电商广告平台,32GB 堆跑 G1,MaxGCPauseMillis=80,线上 P99 GC 停顿 120ms 打不住。核心原因是促销期存活对象暴涨,G1 的 Mixed GC 复制阶段 STW 压不下来。切到 ZGC 后,即使在 48GB 堆上,GC 停顿稳定在 2-5ms,P99 抖动从 5% 降到 0.5%。

另一个踩坑案例:某金融交易系统,G1 的 G1HeapWastePercent 设成默认 5%,促销期 Old Gen 持续膨胀,Mixed GC 频发但仍无法回收,最终触发 Full GC(Serial Old 全量 STW,耗时 8.3s),导致 3000+ 交易超时。事后把 G1HeapWastePercent 降到 2%,G1MixedGCLiveThresholdPercent 从 85% 降到 65%,Mixed GC 提前触发,Full GC 再没出现过。

堆大小:拆小比调大更有效

ZGC/G1 的停顿会随存活对象规模上升。50GB 以内 ZGC 停顿通常 1-5ms,G1 在 100-200ms 之间。最实用的低延迟方案不是无止境调 GC,而是把大堆拆小

一台 256GB 物理机,与其跑一个 256GB 单实例(GC 线程扫描海量对象,停顿难控且单点故障影响全部流量),不如部署 4 个 64GB 的 JVM 实例。总吞吐不变,但单实例停顿可压到 10ms 内,且单实例崩溃只影响 1/4 流量。

bash
# 反例:单个超大堆,GC 停顿难控 + 单点风险
-Xmx256g

# 正解:多实例拆分 + 每实例 ZGC
# instance-1..4 各 -Xmx64g -XX:+UseZGC,前面挂负载均衡

注意:堆拆分不是万能药。如果你的应用有大量跨实例共享状态(如分布式缓存客户端的本地堆内缓存),拆小后每个实例各自缓存一份,总内存开销反而更大。这种情况下应该优先考虑堆外缓存。

实操建议:堆拆分的粒度取决于单实例的分区逻辑。例如广告竞价系统按广告主 ID 做一致性哈希分片,每台机器跑 4 个实例,每个实例处理 1/4 的广告主。这样单个实例 Full GC 仅影响 1/4 广告主的竞价,不触发全局降级。

真实调优参数模板

以下是我在 32GB 堆、4C8T 广告服务上验证过的参数组合:

ZGC 生产模板(JDK 17+)

bash
# 32GB 堆 / 4C8T 广告竞价服务
java -XX:+UseZGC \
     -Xms16g -Xmx16g \
     -XX:ConcGCThreads=2 \          # 4C8T 上默认为 1,调 2 让 GC 更积极但别抢业务 CPU
     -XX:+AlwaysPreTouch \           # 启动时预分配物理内存,避免运行时缺页中断
     -XX:-UseBiasedLocking \         # JDK 8 迁移注意,JDK 15+ 已默认关闭
     -XX:+UnlockExperimentalVMOptions \
     -Xlog:gc*:gc.log:time,level,tags:filecount=5,filesize=20m \
     -jar app.jar

坑点ConcGCThreads 的默认值是 CPU 核数的 1/8(向上取整)。在 8 核机器上默认 1 个 GC 线程,大促时可能来不及回收;调到 2 或 3 可以加速,但注意不要超过核数的 1/4,否则 GC 线程抢占业务线程 CPU,业务 P99 反而升高。

G1 极限压榨模板(JDK 11+,当无法升级 JDK 17 时用)

bash
# 32GB 堆 / 4C8T 交易系统,JDK 11
java -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=80 \       # 设 80ms 而不是 100ms,留余量
     -XX:G1HeapWastePercent=2 \      # 从默认 5% 降到 2%,加快 Mixed GC 触发
     -XX:G1MixedGCLiveThresholdPercent=65 \ # 存活率 >65% Region 不回收,避免白复制
     -XX:G1MixedGCCountTarget=8 \    # 一次 Mixed GC 分成 8 轮,减少单轮停顿
     -XX:G1OldCSetRegionLiveThresholdPercent=5 \ # 旧生代 Region 存活比例 <5% 才加入 CSet
     -XX:+ParallelRefProcEnabled \   # 并行处理引用,WeakReference 多时有用
     -Xms16g -Xmx16g \
     -Xlog:gc*:gc.log:time,level,tags:filecount=5,filesize=20m \
     -jar app.jar

坑点MaxGCPauseMillis软目标,不是硬限制。G1 内部基于历史数据做停顿预测,如果最近 10 次实际停顿都超过 80ms,G1 不会报错,只会尝试缩小 Young 区来降低下次停顿,结果可能导致吞吐量骤降。所以 G1 调参必须配合 -Xlog:gc* 日志监控实际停顿,不能设完就跑。

坑点 2:JDK 8u40 之前 G1 有重大 bug(JDK-8048179),在并发标记阶段如果 Full GC 触发,会死循环导致 CPU 100%。如果必须用 JDK 8,至少升到 8u191,且建议开启 -XX:+UseStringDeduplication 减少冗余字符串。

业务架构配合:从源头减少 GC 压力

参数调优是术,减少对象分配才是道。低延迟系统的架构手段:

1. 对象池化

复用短生命周期对象,降低 Young GC 频率。Netty 的 Recycler 典型实现:

java
// Netty 池化直接内存:缓冲区不占堆,绕开 GC
ByteBuf buf = PooledByteBufAllocator.DEFAULT.directBuffer(1024);
try {
    buf.writeBytes(payload);
    channel.writeAndFlush(buf);
} finally {
    buf.release(); // 引用计数手动管理,而非等 GC
}

对比测试:一个每秒 10 万请求的网关,从 UnpooledByteBufAllocator 切到 PooledByteBufAllocator 后,Young GC 频率从每秒 2 次降到每 10 秒 1 次,GC 总开销从 15% CPU 降到 2%。

2. 直接内存

Netty、gRPC 的 IO 缓冲用 ByteBuffer.allocateDirect 分配在堆外,不进入 GC 视野。但注意:直接内存受 -XX:MaxDirectMemorySize 控制,默认等于堆大小。如果堆外泄漏,不会触发 GC 日志告警,只会静默 OOM。

排查命令:直接内存泄漏可以通过 NMT(Native Memory Tracking)诊断:

bash
# 启动时开启
-XX:NativeMemoryTracking=detail
# 运行时对比
jcmd <pid> VM.native_memory baseline
jcmd <pid> VM.native_memory detail.diff

3. 堆外缓存

大量热数据用 RocksDB / MapDB / OHC 存堆外,避免堆内 OOM 和 GC 抖动。一个典型场景:用户画像缓存,每秒 500 万次查询,如果全部堆内缓存,Old Gen 撑到 50GB,G1 的 Full GC 直接 10s+ 切 ZGC 都救不了。改用堆外 OHC 后,堆内只存 2GB 热数据,ZGC 停顿稳定在 2ms。

OHC 配置示例

java
// OHC (Off-Heap Cache) 配置示例
OHCache<String, UserProfile> cache = OHCacheBuilder.<String, UserProfile>newBuilder()
    .keySerializer(new StringSerializer())
    .valueSerializer(new UserProfileSerializer())
    .capacity(2 * 1024 * 1024 * 1024L)   // 2GB 堆外容量
    .segmentCount(16)                      // 16 个槽位减少锁竞争
    .hashMode(HCache.HashMode.SINGLE_WRITER) // 单写者模式,避免 CAS 开销
    .timeouts(true, TimeoutPolicy.LRU_EXTENDED) // LRU 淘汰
    .build();

注意:OHC 不支持遍历和按前缀查询,只适合 key-value 精确匹配。需要范围查询的场景用 RocksDB。

4. 关键路径去 Java 化

对延迟极敏感的核心链路(交易撮合引擎、实时风控规则引擎)用 C++/Rust 实现,Java 只做调度与聚合。字节跳动在广告竞价引擎上就是这么做的——Java 管理请求路由和结果聚合,C++ 做竞价计算,GC 完全不触及竞价路径。

时序图:一次 ZGC 并发周期的完整流程

请求线程          |---♦---♦---♦---♦---♦---♦---♦---♦---♦---♦---|
ZGC 并发标记线程    |   |=====并发标记=====|   |=====并发搬迁=====|
ZGC STW 暂停       |  |<1ms|                 |<1ms|             |
                   |   ↑                    ↑                   |
                   | 初始标记              最终标记              |
                   | (STW: 根扫描)        (STW: 引用处理)       |
                   |   ↑                    ↑                   |
                   | 并发标记阶段,标记整个    标记完成后,并发    |
                   | 存活对象图,不 STW       搬迁存活对象到新页  |

核心差别:ZGC 的两次 STW 都是微秒级(<1ms),只做根扫描和引用处理的同步,真正耗时的标记和复制都在并发线程里做。G1 的 Young GC 复制阶段是 STW 的,所以最小停顿 50ms 封底。

并发线程与停顿预测公式

架构师还要能聊清楚细节参数背后的机制:

  • ZGC 并发线程数 -XX:ConcGCThreads(默认约 1/8 CPU 核数):高并发系统上要调低,否则 GC 线程抢占业务线程 CPU,反而拉高 P99。实测:8 核机器上 ConcGCThreads=3 时,业务 P99 延迟从 20ms 升到 35ms,降回 2 后恢复。
  • G1 停顿预测-XX:G1HeapWastePercent(默认 5%)和 -XX:G1MixedGCLiveThresholdPercent(默认 85%)共同决定 Mixed GC 的触发时机和 Region 选择——存活率高于阈值的 Region 不回收,避免高成本复制。
  • G1 的 Young GC 停顿公式PauseTime = RootScan + (YoungRegionCount × CopyCostPerRegion) + RefProc。其中 CopyCostPerRegion 约 0.5-1ms/Region(取决于存活率)。所以如果想压到 50ms,Young Region 数不能超过 40-50 个(以 1MB/Region 计,约 40-50MB Young 区)。
  • 无状态化:Service Mesh 模式下每个请求处理完即释放全部对象,让绝大多数对象死在 Young 区,Old GC 几乎不触发。

故障排查三步法:当线上 GC 停顿超标时

第一步:确认是真的停顿还是假报警

bash
# 1. 看 GC 日志实际停顿时间
grep 'Pause Young' gc.log | awk '{print $NF}' | sort -n | tail -20
# 2. 看系统级抖动(可能不是 GC 导致的)
# 检查是否被容器 cgroup 限流、是否被超线程邻居抢 CPU
# 对比 GC 日志时间戳和业务日志超时时间戳是否吻合

第二步:定位来源

bash
# 1. GC 日志详细分析
jstat -gcutil <pid> 1000 10  # 每 1s 看一次 Eden/Old 占比
# 输出示例:
#  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT
#  0.00 100.00  85.43  42.17  95.12  89.34   2402   120.34    1    8.290
#  ↑ FGC=1 且 FGCT=8.29s 说明触发了一次 Full GC,耗时 8.3 秒

# 2. 如果是 G1,看 GC 日志里的 Region 存活率
grep 'Mixed GC' gc.log | head -5
# 输出示例:如果存活率普遍 >85%,说明 G1MixedGCLiveThresholdPercent 设得太高

第三步:对症下药

症状根因措施
FGC 频繁,Old 区持续增长内存泄漏 / 对象晋升过快jmap -histo:live <pid> 检查泄露对象,或用 MAT 分析 dump
停顿稳定但偏高(80-120ms)GC 参数未收敛优化 ConcGCThreads / G1MixedGCCountTarget
停顿突然飙高(>500ms)容器超卖 / 物理机 OS 抖动检查 dmesg 看 OOM Killer,检查 NUMA 绑定
元空间 Full GC动态代理/脚本热加载过多设置 -XX:MetaspaceSize=256m,控制代理生成数量
直接内存 OOM 但堆正常堆外泄漏jcmd <pid> VM.native_memory detail.diff 定位

典型面试追问链

面试官问完这道题后,通常会顺着追问,提前准备好:

Q:如果 ZGC 在 JDK 11 是实验特性,JDK 8 怎么办? → 只能用 G1 + 堆拆分 + 堆外缓存。JDK 8 的 G1 还不成熟,建议升级 JDK 11+。如果实在不能升,考虑 Azul Zing 的 C4 收集器(商业)。另外,JDK 8u192 之后 G1 的并发标记有显著改进,JDK-8204080 修复了 Full GC 触发条件过松的问题。

Q:ZGC 的染色指针原理是什么? → 用 64 位指针的 42 位做地址,高位 18 位存元数据(Finalizable、Remapped、Marked0、Marked1 等),剩余 4 位备用。不需要对象头标记位,所以读屏障开销极低。注意:染色指针要求 64 位操作系统且不支持 32 位或压缩 OOP 的某些场景。

Q:ZGC 和 Shenandoah 选哪个? → 功能上等价。选型因素:JDK 自带对 ZGC 更友好(Shenandoah 某些版本需要额外参数 -XX:+UnlockExperimentalVMOptions);Red Hat 系(CentOS/RHEL)优先 Shenandoah;AWS Corretto 两者都支持。P99 延迟差异在 1ms 以内,不必纠结。但要注意:Shenandoah 在 JDK 11 的某些版本(11.0.0-11.0.5)有 GC 线程泄漏的 bug,必须升级到 11.0.6+。

Q:如果堆只有 2GB,还用 ZGC 吗? → 不需要。2GB 堆上 G1 就能做到 10-20ms 停顿,ZGC 的额外开销(读屏障/写屏障的 CPU 代价)反而得不偿失。ZGC 的收益在 16GB+ 堆上才明显。小堆场景用 G1 或 Serial GC 反而更优。

Q:你线上怎么监控 GC 停顿告警? → 配三路告警:① GC 日志解析出实际停顿时间,超 80ms 就告警;② jstat -gcutil 实时采集 Old 区增长率,如果连续 5 分钟增长 >2%/min 就告警;③ Prometheus + Micrometer 采集 jvm.gc.pause 指标,用 histogram_quantile 算 P99 停顿。三路告警防止漏报。

总结

面试话术示例——被追问时可以这样分层回答:

  • 选型层:JDK 17+ 直接上 ZGC,停顿 <10ms 且与堆无关;只有老版本才退而用 G1 配 MaxGCPauseMillis
  • 堆规划层:不堆单实例大堆,256GB 机器拆 4×64GB,停顿可控且隔离故障。
  • 架构层:对象池化 + 池化直接内存 + 堆外缓存降低分配速率,核心链路去 Java 化。
  • 调参层ConcGCThreads 按 CPU 核数收敛,避免 GC 抢业务 CPU;-Xlog:gc* 日志必开,实际停顿要以监控为准。
  • 故障排查层:三步法先确认是否真停顿,再定位是 GC 问题还是系统抖动,最后对症下药。

一句话收尾:100ms 停顿不是靠一个参数达标,而是"选对收集器 + 拆小堆 + 减少对象分配"三管齐下的系统工程。 只会背 -XX:MaxGCPauseMillis 的是 P6,能讲清楚为什么 G1 压不到 50ms 以下、什么时候该拆实例的才是 P7/P8。

参考:《深入理解 Java 虚拟机》周志明(第 3 版);ZGC 官方文档 https://wiki.openjdk.org/display/zgc/Main ;Shenandoah GC https://wiki.openjdk.org/display/shenandoah/Main ;Netty PooledByteBufAllocator 源码 io.netty.buffer.PooledByteBufAllocator;《Java 性能权威指南》Scott Oaks;OHC 文档 https://github.com/snazy/ohc ;JDK 8 GC 改进记录 https://bugs.openjdk.org/browse/JDK-8204080

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。