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 相关的几个参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
-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 实战:如何快速定位参数问题
讲了这么多参数,怎么知道自己的设置合不合理?我一般用三步法:
- 看 GC 日志:重点关注 Minor GC 后的晋升大小。如果每次晋升量都接近 Survivor 区容量,说明 Survivor 区太小。
- 看对象年龄分布:用
-XX:+PrintTenuringDistribution打印对象年龄分布。如果大部分对象在年龄很小时就晋升了,说明 Survivor 区不够用。 - 看大对象分配:用
-XX:+PrintHeapAtGC观察老年代增长曲线。如果老年代突然增长一大块,很可能是大对象直接分配进去了。
最后提醒一句:参数调优没有银弹。同样的参数,在不同的业务场景下效果可能天差地别。我的习惯是:先跑默认参数,观察一段时间,然后一次只改一个参数,对比前后效果。千万别一次性改一堆参数,出了问题你都不知道是哪个引起的。