3、GC调优实战:大内存应用的GC策略选择与问题定位

说到大内存应用的GC调优,我脑子里立刻浮现出几年前的一个深夜。那是一个12GB堆的应用,一到业务高峰期就频繁STW,每次停顿都超过5秒。用户投诉电话都快打爆了。嗯,今天我们就来聊聊这个场景下的GC策略选择,以及怎么通过日志把问题揪出来。

3.1 大内存场景下的GC策略对比

先说说我的个人习惯。面对12GB+的堆,我基本不会考虑Parallel GC或CMS。为什么?说白了,它们在大堆下的表现太不稳定了。CMS的并发标记阶段在超大堆里容易产生浮动垃圾,而Parallel GC的Full GC停顿时间跟堆大小成正比——12GB堆的Full GC,你想想看,那得停多久?

目前主流的选择就三个:G1、ZGC、Shenandoah。我分别说说它们的适用场景。

GC策略 适用堆大小 目标停顿时间 JDK版本要求 我的评价
G1 6GB~64GB 100ms~200ms JDK 8u40+ 成熟稳定,但大堆下需精细调参
ZGC 8GB~16TB <10ms JDK 11+ (实验性) 低延迟王者,但CPU开销高
Shenandoah 8GB~64GB <10ms JDK 12+ (实验性) 与ZGC类似,但实现思路不同

我在项目中遇到过这样一个案例:一个16GB堆的在线交易系统,要求GC停顿不超过50ms。当时我第一反应是上ZGC。但后来发现,这个应用的对象分配速率极高,每秒超过2GB。ZGC在这种场景下反而因为并发处理跟不上,导致堆内存压力持续升高。最后我换回了G1,配合精细的Region大小调整,才把问题解决。

3.2 G1调优实战:参数与策略

G1是我用得最多的GC。对于12GB+的堆,有几个关键参数我建议你重点关注。

核心参数组合(12GB堆示例)

-Xms12g -Xmx12g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:G1HeapRegionSize=16m
-XX:G1NewSizePercent=5
-XX:G1MaxNewSizePercent=40
-XX:InitiatingHeapOccupancyPercent=45
-XX:ConcGCThreads=4
-XX:ParallelGCThreads=8

这里我解释几个关键点:

  • G1HeapRegionSize:我习惯设为16MB。Region太小会导致RSet过大,太大又会影响回收粒度。12GB堆用16MB,大概768个Region,刚刚好。
  • InitiatingHeapOccupancyPercent:默认是45%。但如果你发现GC频繁触发,可以适当调高到50%甚至55%。我曾经在一个项目中调到了60%,效果反而更好——因为那个应用的对象存活率很低。
  • ConcGCThreads:这个值别设太大。我见过有人设成8,结果并发标记阶段把CPU吃满了,业务线程反而被饿死。一般设为ParallelGCThreads的一半左右。

小技巧:如果你不确定参数怎么调,可以先加上-XX:+PrintGCDetails-XX:+PrintGCDateStamps,跑一段时间看看日志。日志会告诉你很多信息。

3.3 ZGC调优:低延迟的代价

ZGC号称停顿时间不超过10ms,这确实很诱人。但我要提醒你,它是有代价的。

ZGC的调优参数相对简单:

-Xms12g -Xmx12g
-XX:+UseZGC
-XX:ZCollectionInterval=120
-XX:ZAllocationSpikeTolerance=2.0
-XX:+ZProactive

这里有个坑。ZGC的并发标记和重定位阶段会占用大量CPU。我曾在48核的机器上跑ZGC,发现GC线程吃掉了8个核。如果你的应用本身CPU密集,ZGC可能会让整体吞吐量下降10%~15%。

另外,ZGC对内存带宽要求很高。它使用染色指针技术,每次访问对象都需要额外的内存操作。在内存带宽紧张的机器上,ZGC反而可能成为瓶颈。

注意:ZGC在JDK 11中还是实验性功能,JDK 15之后才正式发布。如果你在生产环境使用,建议至少用JDK 16+。

3.4 Shenandoah:另一种选择

Shenandoah和ZGC类似,也是追求低停顿。但它们的实现思路不同。Shenandoah使用Brooks Pointer来做并发移动,而ZGC用的是染色指针。

我个人觉得Shenandoah在中等堆大小(8GB~32GB)下表现更好。它的参数也很简洁:

-Xms12g -Xmx12g
-XX:+UseShenandoahGC
-XX:ShenandoahGCMode=adaptive
-XX:ShenandoahGCHeuristics=adaptive
-XX:ShenandoahUncommitDelay=1000

我曾经在一个32GB堆的批处理系统上测试过Shenandoah。它的停顿时间确实控制在10ms以内,但吞吐量比G1低了大约8%。如果你的应用对延迟极度敏感,但对吞吐量要求不高,Shenandoah是个好选择。

3.5 通过GC日志定位问题

好了,策略选好了,参数也配了。但怎么知道它工作得好不好?看日志。

我一般会开启这些日志参数:

-Xlog:gc*:file=gc.log:time,uptime,level,tags
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintHeapAtGC
-XX:+PrintTenuringDistribution

拿到日志后,我重点关注这几个指标:

  • GC频率:如果Young GC每几秒就触发一次,说明新生代太小了。
  • 停顿时间:G1的停顿时间如果超过200ms,说明参数需要调整。
  • 晋升失败:日志里出现"to-space overflow"或"promotion failed",说明老年代空间不足或GC线程不够。
  • Humongous Allocation:G1中如果频繁出现大对象分配,会导致GC效率下降。

实战案例:有一次我看到日志里频繁出现"GC Pause Duration: 500ms",但MaxGCPauseMillis设的是100ms。我仔细一看,发现是Mixed GC阶段回收的Region太少。后来我把-XX:G1MixedGCCountTarget从默认的8调到了16,问题就解决了。

3.6 避坑指南

最后,分享几个我踩过的坑:

  • 不要盲目追求低停顿:我曾经把MaxGCPauseMillis设成20ms,结果GC频率飙升,吞吐量暴跌。后来我调回100ms,一切正常。
  • 注意大对象:G1中超过Region大小一半的对象会被视为大对象,直接分配到老年代。如果你的应用频繁创建大对象,建议把Region大小调大。
  • 监控CPU使用率:ZGC和Shenandoah的并发阶段会占用大量CPU。如果CPU已经接近100%,再上这些GC反而会拖垮系统。
  • 测试环境要模拟生产:我见过有人在测试环境用4GB堆调优,然后直接搬到生产环境的16GB堆上。结果GC行为完全不一样。大内存场景一定要用真实堆大小测试。

嗯,关于GC调优就聊这么多。记住一点:没有银弹。G1、ZGC、Shenandoah各有优劣,关键是要根据你的应用特点来选择。多看看日志,多跑跑测试,你很快就能找到最适合你的方案。