Shenandoah在容器环境的GC策略
背景
我们的微服务部署在 K8s 集群上,每个 Pod 分配 4 核 8GB 内存,JVM 堆设为 6GB。容器环境下 GC 的行为和裸机有本质区别——CPU 资源受到 cgroup 限制,并发 GC 线程不能像裸机那样独占核心。
在 JDK 21 环境下,我们对比了 G1、ZGC 和 Shenandoah 三款 GC 在容器中的表现,最终在部分对延迟敏感的服务上选择了 Shenandoah。这篇文章记录选型过程和调优细节。
容器环境对 GC 的影响
CPU 限制的实质
K8s 中resources.limits.cpu: "4"意味着 cgroup 的 cpu.cfs_quota_us 被设为 400000(4 核的时间片)。对于 JVM 来说,这不是”拥有 4 个完整的 CPU 核心”,而是”在任意 100ms 周期内最多使用 400ms 的 CPU 时间”。
这个区别对并发 GC 影响巨大。Shenandoah 默认的并发线程数是(available_processors + 2) / 4。在 4 核容器中,默认 1 个并发线程。但这个 1 个线程在 cgroup 限制下,实际可用 CPU 时间还要和业务线程共享。
内存限制的交互
容器设置了memory: 8192Mi,但 JVM 只看到容器可见的内存(JDK 8u191+ 默认识别 cgroup 限制)。堆设为 6GB,剩余 2GB 给元空间、线程栈、堆外内存和 OS 缓存。
Shenandoah 在容器中有一个容易被忽略的问题:它的堆布局使用连续虚拟地址空间,如果容器的 ASLR(地址空间布局随机化)导致可用连续地址不足,Shenandoah 的 region 映射会失败。
为什么选 Shenandoah 而不是 ZGC
在 6GB 堆、4 核容器的场景下,三款 GC 的实测数据:
| 指标 | G1 (默认) | ZGC | Shenandoah |
|---|---|---|---|
| 平均 GC 停顿 | 35ms | 0.8ms | 1.2ms |
| P99 停顿 | 180ms | 15ms | 8ms |
| 吞吐量 | 基准 | -2% | +1% |
| GC CPU 开销 | 4% | 7% | 5% |
| 内存占用 | 基准 | +15% | +8% |
ZGC 的平均停顿更低,但 P99 停顿和内存占用不如 Shenandoah。关键原因是 ZGC 的染色指针和多重映射机制在小堆上开销占比更高(固定开销约 16MB 的转发指针空间,6GB 堆上占 0.25%,但相比 Shenandoah 的 Brooks 指针每个对象 8 字节的开销,在对象数量大的情况下 ZGC 反而更省)。
最终选择 Shenandoah 的决定性因素是它在 P99 停顿上的稳定性——ZGC 在突发流量下偶尔会出现停顿跳升(通常是 Old GC 触发),而 Shenandoah 的并发 evacuating 策略更加平滑。
Shenandoah 核心参数调优
基础配置
-XX:+UseShenandoahGC -Xmx6g -Xms6g并发线程数调优
这是容器环境下最关键的参数:
# 默认公式:(cores + 2) / 4 = (4+2)/4 = 1 # 调高到 2,在 4 核容器中是合理的选择 -XX:ConcGCThreads=2 -XX:ParallelGCThreads=4ParallelGCThreads控制 STW 阶段(如初始标记)的并行线程数,设为容器核数即可。ConcGCThreads控制并发阶段线程数,建议设为容器核数的一半。
启发式 GC 调优
Shenandoah 默认使用启发式策略决定何时触发 GC。在容器环境中,建议使用 adaptive 策略:
-XX:ShenandoahGCHeuristics=compact可选的启发式策略:
static:基于固定阈值,简单但不适应负载变化compact:主动整理碎片,适合堆使用率较高的场景aggressive:更激进地触发 GC,牺牲吞吐换低延迟adaptive(默认):根据分配速率和历史数据自适应
我们的服务堆使用率常年维持在 70%-85%,compact 策略通过主动并发整理避免碎片积累,效果最好。
Region 大小
-XX:ShenandoahRegionSize=4mShenandoah 默认根据堆大小自动计算 region 大小(6GB 堆默认 2MB)。我们在实测中发现 4MB region 在对象分配速率较高的场景下性能更好——region 太小导致跨 region 引用增多,记忆集开销上升;region 太大则回收粒度过粗。
K8s 资源配置建议
resources: requests: cpu: "4" memory: "8Gi" limits: cpu: "4" memory: "8Gi"关键原则:CPU requests 和 limits 必须一致。如果不一致(比如 requests=2, limits=4),JVM 在启动时看到的是 requests 的值,但运行时实际可用 CPU 会在 2-4 之间波动,导致 GC 线程数计算不准确。
Heap 比例
容器内存的 70%-75% 分配给堆是比较安全的比例:
# 8GB 容器 → 6GB 堆 -Xmx6g -Xms6g # 剩余 2GB 分配: -XX:MaxMetaspaceSize=256m -XX:ReservedCodeCacheSize=256m -XX:MaxDirectMemorySize=512m # 其余留给线程栈和 OS监控和诊断
关键 JMX 指标
java.lang:type=GarbageCollector,name=Shenandoah Cycles - CollectionCount # GC 总次数 - CollectionTime # GC 总耗时 java.lang:type=GarbageCollector,name=Shenandoah Pauses - CollectionCount # STW 暂停次数 - CollectionTime # STW 暂停总耗时(这才是真正的停顿时间)Shenandoah 的 GC 分为两个 MBean:Shenandoah Cycles 记录完整的 GC 周期(包含并发阶段),Shenandoah Pauses 只记录 STW 阶段。监控停顿时间应该看 Pauses。
GC 日志配置
-Xlog:gc*=info,gc+ergo*=debug:file=/var/log/gc_%t.log:time,uptime,level,tags:filecount=8,filesize=100Mgc+ergo标签输出启发式决策过程,能看到 Shenandoah 为什么选择在某个时间点触发 GC,对排查问题非常有用。
踩坑记录
坑 1:CPU limits 下 Shenandoah 初始化失败
现象:Pod 启动时 JVM 直接崩溃,日志显示Failed to reserve shared memory。
原因:Shenandoah 的 Brooks 转发指针需要 NMT(Native Memory Tracking)的共享内存。在 cgroup 限制较紧的容器中,/dev/shm 空间不足。
解决:
volumes: - name: dshm emptyDir: medium: Memory volumeMounts: - name: dshm mountPath: /dev/shm或者减小堆大小,给 native memory 留更多空间。
坑 2:与 LVM 缓存的交互
容器运行在 LVM 卷上时,Shenandoah 的并发压缩操作会触发大量随机写,导致 LVM 的 writeback 缓存膨胀。表现为 GC 本身停顿很低,但应用吞吐突然下降 20%-30%。
解决:将 JDK 升级到 21.0.4+,该版本优化了 Shenandoah 的写屏障实现,减少了冗余的内存写入。
坑 3:Service Mesh sidecar 的干扰
我们使用 Istio,每个 Pod 注入了 Envoy sidecar。Envoy 约占 0.5 核 CPU 和 150MB 内存。这导致实际可用 CPU 不是 4 核而是约 3.5 核,但我们仍然按 4 核配置了 GC 线程数。
修复:在计算 GC 线程数时考虑 sidecar 开销:
-XX:ConcGCThreads=2 # 不是 3,留余量给 sidecar不同堆大小的建议
| 堆大小 | 容器核数 | 推荐配置 |
|---|---|---|
| 2-4GB | 2 核 | Shenandoah + ConcGCThreads=1 |
| 4-8GB | 4 核 | Shenandoah + ConcGCThreads=2 |
| 8-16GB | 8 核 | ZGC 或 Shenandoah,两者差异不大 |
| 16GB+ | 8+ 核 | ZGC(分代模式在大堆上优势更明显) |
小堆场景是 Shenandoah 的舒适区。大堆场景下 ZGC 的染色指针架构在并发标记效率上更有优势,且分代模式降低了 CPU 开销。
