
背景我们的微服务部署在 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 被设为 4000004 核的时间片。对于 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 (默认)ZGCShenandoah平均 GC 停顿35ms0.8ms1.2msP99 停顿180ms15ms8ms吞吐量基准-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 (42)/4 1 # 调高到 2在 4 核容器中是合理的选择 -XX:ConcGCThreads2 -XX:ParallelGCThreads4ParallelGCThreads控制 STW 阶段如初始标记的并行线程数设为容器核数即可。ConcGCThreads控制并发阶段线程数建议设为容器核数的一半。启发式 GC 调优Shenandoah 默认使用启发式策略决定何时触发 GC。在容器环境中建议使用 adaptive 策略-XX:ShenandoahGCHeuristicscompact可选的启发式策略static基于固定阈值简单但不适应负载变化compact主动整理碎片适合堆使用率较高的场景aggressive更激进地触发 GC牺牲吞吐换低延迟adaptive默认根据分配速率和历史数据自适应我们的服务堆使用率常年维持在 70%-85%compact 策略通过主动并发整理避免碎片积累效果最好。Region 大小-XX:ShenandoahRegionSize4mShenandoah 默认根据堆大小自动计算 region 大小6GB 堆默认 2MB。我们在实测中发现 4MB region 在对象分配速率较高的场景下性能更好——region 太小导致跨 region 引用增多记忆集开销上升region 太大则回收粒度过粗。K8s 资源配置建议resources: requests: cpu: 4 memory: 8Gi limits: cpu: 4 memory: 8Gi关键原则CPU requests 和 limits 必须一致。如果不一致比如 requests2, limits4JVM 在启动时看到的是 requests 的值但运行时实际可用 CPU 会在 2-4 之间波动导致 GC 线程数计算不准确。Heap 比例容器内存的 70%-75% 分配给堆是比较安全的比例# 8GB 容器 → 6GB 堆 -Xmx6g -Xms6g # 剩余 2GB 分配 -XX:MaxMetaspaceSize256m -XX:ReservedCodeCacheSize256m -XX:MaxDirectMemorySize512m # 其余留给线程栈和 OS监控和诊断关键 JMX 指标java.lang:typeGarbageCollector,nameShenandoah Cycles - CollectionCount # GC 总次数 - CollectionTime # GC 总耗时 java.lang:typeGarbageCollector,nameShenandoah Pauses - CollectionCount # STW 暂停次数 - CollectionTime # STW 暂停总耗时这才是真正的停顿时间Shenandoah 的 GC 分为两个 MBeanShenandoah Cycles 记录完整的 GC 周期包含并发阶段Shenandoah Pauses 只记录 STW 阶段。监控停顿时间应该看 Pauses。GC 日志配置-Xlog:gc*info,gcergo*debug:file/var/log/gc_%t.log:time,uptime,level,tags:filecount8,filesize100Mgcergo标签输出启发式决策过程能看到 Shenandoah 为什么选择在某个时间点触发 GC对排查问题非常有用。踩坑记录坑 1CPU limits 下 Shenandoah 初始化失败现象Pod 启动时 JVM 直接崩溃日志显示Failed to reserve shared memory。原因Shenandoah 的 Brooks 转发指针需要 NMTNative 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 的写屏障实现减少了冗余的内存写入。坑 3Service Mesh sidecar 的干扰我们使用 Istio每个 Pod 注入了 Envoy sidecar。Envoy 约占 0.5 核 CPU 和 150MB 内存。这导致实际可用 CPU 不是 4 核而是约 3.5 核但我们仍然按 4 核配置了 GC 线程数。修复在计算 GC 线程数时考虑 sidecar 开销-XX:ConcGCThreads2 # 不是 3留余量给 sidecar不同堆大小的建议堆大小容器核数推荐配置2-4GB2 核Shenandoah ConcGCThreads14-8GB4 核Shenandoah ConcGCThreads28-16GB8 核ZGC 或 Shenandoah两者差异不大16GB8 核ZGC分代模式在大堆上优势更明显小堆场景是 Shenandoah 的舒适区。大堆场景下 ZGC 的染色指针架构在并发标记效率上更有优势且分代模式降低了 CPU 开销。