4、分代收集理论:年轻代与老年代划分、对象年龄、Promotion机制

说起分代收集,我刚开始接触 JVM 的时候,觉得这玩意儿挺玄乎。为什么要把堆分成年轻代和老年代?直接一个堆全扫不就完了?后来线上出了几次 GC 问题,我才彻底理解了它的精巧之处。

其实说白了,它就是「分而治之」的思想。绝大多数对象都是「朝生暮死」的——分配完没多久就死了。如果你每次 GC 都去扫描整个堆,那效率太低了。所以我们把堆分成两个区域,分别用不同的策略来处理。

4.1 年轻代与老年代的划分

标准 HotSpot 堆结构大致如下:

  • 年轻代(Young Generation):存放新创建的对象。占堆总大小的 1/3 左右(默认比例可调)。
  • 老年代(Old Generation):存放存活时间较长的对象。占堆总大小的 2/3 左右。

年轻代内部又分成三块:一个 Eden 和两个 Survivor(from 和 to)。默认比例是 Eden : Survivor = 8 : 1。这么分是因为大部分对象在 Eden 分配后很快死亡,Minor GC 时把存活对象复制到一个 Survivor 区,另一个 Survivor 保持空。

关键点:年轻代使用「复制算法」,老年代使用「标记-清除」或「标记-整理」算法。为什么?因为年轻代存活率低,复制成本低;老年代存活率高,用复制算法会浪费大量空间。

4.2 对象年龄(Age)

每个对象在创建时年龄为 0。每次熬过一次 Minor GC(即从 Eden 或 Survivor 存活下来并进入另一个 Survivor),年龄就加 1。这个年龄记录在对象头的 Mark Word 里。

我习惯用 -XX:MaxTenuringThreshold 参数来控制最大年龄阈值。默认值是 15,但对于一些特定场景(比如大量短生命周期对象),我会调低到 6 或 8。

小技巧:可以通过 -XX:+PrintTenuringDistribution 查看各年龄对象的存活分布。我曾经在一个高并发服务里发现,年龄到 3 之后对象几乎都死光了,果断把阈值调到 3,减少了晋升数量,老年代增长明显变慢。

对象年龄并不是只靠硬阈值决定晋升的。JVM 还有一个隐式规则——动态年龄判断:如果 Survivor 区中相同年龄的所有对象大小之和超过了 Survivor 区的一半,那么所有大于等于该年龄的对象都会直接晋升到老年代。这个机制防止 Survivor 区被长期存活对象占满。

常用年龄相关参数
参数默认值说明
-XX:MaxTenuringThreshold15最大晋升年龄阈值
-XX:TargetSurvivorRatio50Survivor 区期望使用比率(%)
-XX:+PrintTenuringDistributionfalse打印年龄分布日志

4.3 Promotion 机制——晋升到老年代的几种路径

对象什么时候从年轻代升到老年代?主要有四种情况,我一个个讲。

  1. 年龄达到阈值:最常见。对象年龄超过 MaxTenuringThreshold 就晋升。
  2. 动态年龄判断:上面已经提过,Survivor 区半满时提前晋升。
  3. 大对象直接进入老年代:通过 -XX:PretenureSizeThreshold 设置,超过这个大小的对象直接在老年代分配。在我做的一个内存数据库里,我就把 1MB 以上的对象设成直接进老年代,避免在年轻代反复复制。
  4. 晋升失败(Promotion Failure):Minor GC 后,存活对象无法放入 Survivor 区,直接进入老年代。这种情况通常意味着 Survivor 空间太小或者晋升太密集,需要调整。

我曾经踩过的坑:有个服务频繁 Full GC,查了很久才发现是 Survivor 区设得太小,大量对象在 Minor GC 后直接晋升到老年代,老年代很快被打满。调大了 -XX:SurvivorRatio 后,晋升量降了 70%,Full GC 基本消失。吃一堑长一智啊。

4.4 分代收集的核心流程

嗯,到这里我们把流程串起来看。下面这张图能帮大家理清关系。

分代收集机制:对象从创建到晋升的全流程 新对象创建 Eden区(绝大部分对象在此分配) Minor GC 发生 Minor GC 发生 Survivor from 死亡对象(回收) 年龄+1,互换角色 Survivor to(交换后) 老年代(晋升后) 大对象直接分配 ▸ 晋升条件:年龄阈值 | 动态年龄判断 | 大对象阈值 | Promotion Failure ▸ 年轻代使用复制算法,老年代使用标记-清除/标记-整理

4.5 写在分代理论之后的一些体会

分代收集不是完美的,它有一个天然的问题:跨代引用。老年代对象引用了年轻代对象怎么办?为了处理这个,JVM 引入了「卡表(Card Table)」和「记忆集(Remembered Set)」。这块内容我们会在后面专门讲,但你得先明白——分代只是手段,不是目的。我们真正追求的是:

  • 减少 STW 时间
  • 降低 GC 频率
  • 提高吞吐量

还记得我之前提到的那个线上服务吗?调完 SurvivorRatio 和 PretenureSizeThreshold 之后,老化速度变慢,老年代增长平缓,整年再没出现过 Full GC 告警。所以理解这些机制,才能做到有的放矢。

如果你想快速验证晋升情况,可以加下面两个 JVM 参数:

-XX:+PrintGC -XX:+PrintTenuringDistribution

日志里会看到每一轮 Minor GC 之后各年龄对象数量,以及晋升到老年代的对象大小。调优就是基于这些数据反复 trial and error。

分代理论本身并不复杂,复杂的是根据实际业务场景调优。我个人的习惯是:先观察对象分配率,再调整年轻代大小、Survivor 比例和晋升阈值,最后才考虑更换 GC 算法。基础打牢了,后面遇到 G1 或者 ZGC 才不会发懵。