3. 堆内存调优(下):-XX:NewRatio 与 -XX:SurvivorRatio 详解、大对象直接进入老年代、TLAB 机制与调优

上一节我们把堆内存的宏观结构讲透了。这一节,咱们深入微观层面,聊聊几个直接影响 GC 频率和停顿时间的参数。

说实话,很多同学调 JVM 就只知道设个 -Xms 和 -Xmx,然后就不管了。结果线上频繁 Full GC,一看日志,年轻代对象晋升太快,或者 Survivor 区根本不够用。嗯,这些坑,我基本都踩过。

3.1 -XX:NewRatio:年轻代到底该占多大?

-XX:NewRatio 这个参数,控制的是年轻代与老年代的比例。默认值是 2,意思是老年代是年轻代的 2 倍。换句话说,年轻代占整个堆的 1/3。

举个例子:堆是 6GB,NewRatio=2,那年轻代就是 2GB,老年代 4GB。

核心原则:如果你的应用创建对象非常频繁,但大部分对象生命周期很短(比如无状态服务、RPC 调用),我建议把 NewRatio 调小,比如 1 甚至更小,让年轻代更大一些。反之,如果应用里有很多长期存活的大对象(比如缓存、Session),可以适当调大 NewRatio。

我在项目中遇到过一个问题:一个数据处理平台,堆给了 16GB,NewRatio 用的默认值 2。结果每次 Minor GC 后,Survivor 区根本放不下存活对象,大量对象直接晋升到老年代。老年代很快就满了,触发 Full GC,一次停顿好几秒。后来我把 NewRatio 改成 1,年轻代从 4GB 变成 8GB,Minor GC 频率虽然没变,但每次 GC 后存活对象能留在年轻代了,Full GC 几乎消失了。

NewRatio 值 年轻代占比 老年代占比 适用场景
1 1/2 1/2 高对象创建率、短生命周期对象为主
2(默认) 1/3 2/3 通用场景
3 1/4 3/4 长生命周期对象较多

注意:不要盲目把年轻代设得过大。年轻代太大,Minor GC 的停顿时间会变长,因为要扫描和复制更多的对象。你需要在这两者之间找到平衡点。

3.2 -XX:SurvivorRatio:Eden 和 Survivor 的博弈

年轻代内部又分为 Eden 区和两个 Survivor 区(From 和 To)。-XX:SurvivorRatio 控制的是 Eden 区与单个 Survivor 区的比例。默认值是 8,意思是 Eden 区是单个 Survivor 区的 8 倍。所以年轻代里,Eden 占 8/10,两个 Survivor 各占 1/10。

你想想看,如果 Survivor 区太小,会发生什么?Minor GC 后,存活对象放不进 To 区,只能提前晋升到老年代。这就是所谓的「过早晋升」。过早晋升会导致老年代增长过快,触发不必要的 Full GC。

我的经验:对于大多数应用,默认的 SurvivorRatio=8 其实够用。但如果你发现 Minor GC 后有很多对象晋升,而且这些对象其实还能活好几轮 GC,那就说明 Survivor 区太小了。可以试试 SurvivorRatio=6 或 4,让 Survivor 区大一些。

我曾经帮一个电商团队调优,他们的订单服务在促销期间频繁 Full GC。我一看 GC 日志,发现每次 Minor GC 后晋升的对象大小,几乎等于 Survivor 区的容量。说白了,就是 Survivor 区被撑爆了。我把 SurvivorRatio 从 8 改成 4,Survivor 区扩大了一倍,晋升量立刻降下来了。

3.3 大对象直接进入老年代:-XX:PretenureSizeThreshold

这个参数很有意思。它设置一个阈值,任何超过这个大小的对象,直接在老年代分配,不经过年轻代。

为什么要这么做?因为大对象在年轻代里,Eden 区放不下,会直接分配到老年代。但更关键的是,大对象在年轻代里做复制算法时,成本非常高。你想啊,一个 10MB 的大对象,在 Survivor 区里来回复制,多浪费。

// 设置大于 1MB 的对象直接进入老年代
-XX:PretenureSizeThreshold=1m

避坑指南:我曾经在一个日志分析系统里,把 PretenureSizeThreshold 设成了 512KB。结果发现老年代涨得飞快,Full GC 频繁。后来一查,系统里有很多 600KB 左右的临时缓冲区,全跑老年代去了。这个参数一定要结合业务对象的实际大小来设,别拍脑袋。

另外要注意,这个参数只对 Serial 和 ParNew 收集器有效。CMS 和 G1 有自己的大对象处理机制,设了这个参数也没用。

3.4 TLAB 机制与调优:线程本地分配缓冲区

TLAB,全称 Thread Local Allocation Buffer。说白了,就是每个线程在 Eden 区里划一小块私有的空间,用来分配对象。这样线程之间就不需要竞争 Eden 区的分配指针了,分配效率大大提高。

默认情况下,TLAB 是开启的。你不需要手动设置,但了解它的原理对调优有帮助。

TLAB 分配机制示意图 Eden 区 TLAB 线程1 TLAB 线程2 TLAB 线程3 TLAB 线程4 说明: 1. 每个线程在 Eden 区拥有独立的 TLAB 空间,无需竞争分配指针。 2. 对象优先在 TLAB 内分配,TLAB 空间不足时,在 Eden 区公共空间分配。 3. TLAB 大小由 JVM 动态调整,可通过 -XX:TLABSize 设置初始大小。

TLAB 相关的几个参数:

参数 说明 默认值
-XX:+UseTLAB 启用 TLAB true
-XX:TLABSize 设置 TLAB 初始大小 动态计算
-XX:ResizeTLAB 允许动态调整 TLAB 大小 true
-XX:TLABRefillWasteFraction TLAB 空间浪费比例,超过此比例则重新分配 64(即 1/64)

调优建议:一般情况下,保持 TLAB 默认配置就好。如果你发现应用中有大量线程竞争 Eden 区分配,或者 GC 日志中显示 TLAB 重分配次数很多,可以尝试固定 TLAB 大小:-XX:TLABSize=512k -XX:-ResizeTLAB。但说实话,我很少需要动这个参数。

3.5 实战:如何快速定位参数问题

讲了这么多参数,怎么知道自己的设置合不合理?我一般用三步法:

  1. 看 GC 日志:重点关注 Minor GC 后的晋升大小。如果每次晋升量都接近 Survivor 区容量,说明 Survivor 区太小。
  2. 看对象年龄分布:用 -XX:+PrintTenuringDistribution 打印对象年龄分布。如果大部分对象在年龄很小时就晋升了,说明 Survivor 区不够用。
  3. 看大对象分配:用 -XX:+PrintHeapAtGC 观察老年代增长曲线。如果老年代突然增长一大块,很可能是大对象直接分配进去了。

最后提醒一句:参数调优没有银弹。同样的参数,在不同的业务场景下效果可能天差地别。我的习惯是:先跑默认参数,观察一段时间,然后一次只改一个参数,对比前后效果。千万别一次性改一堆参数,出了问题你都不知道是哪个引起的。