← 返回博客
2026-09-22 08:00:01

对象晋升老年代的完整四条件:动态年龄判定才是提前晋升的主因

对象晋升老年代的完整四条件:动态年龄判定才是提前晋升的主因

面试里问「对象什么时候进老年代」,八成的人只答得出「熬过 15 次 GC」。这个答案在真机上跑一遍就会碎掉:把晋升日志打开,你会看到上限明明是 15,实际阈值却是 1 —— 对象活过一轮 Young GC 就进老年代了。

这篇是 JVM 这一单元的收尾,把三样东西补齐:晋升的完整判定链(配可复现的实测日志)、老年代和新生代互相引用时 GC 怎么找(卡表与写屏障)、以及 CMS 和 G1 在「漏标」上的不同解法。最后把执行引擎侧的方法分派、元数据区的 Metaspace、以及安全点和 Slot 复用一起收掉。

版本说明:本文命令与输出实测于 JDK 26。JDK 8 时代的 -XX:+PrintTenuringDistribution-XX:PretenureSizeThreshold 在 JDK 26 已被移除,照抄老参数会直接起不来 JVM,文中给了现代写法。

先立模型:一个对象要过四道关

对象在 Eden 出生,扛过一次 Young GC 就活到 Survivor,年龄加一。但从 Survivor 到老年代,判定不止看年龄 —— 有四道关,任何一道都可以让它提前过去。

对象晋升老年代的判定路径:Eden、S0/S1 与老年代之间的四道关,关 1 年龄阈值、关 2 大对象、关 3 动态年龄判定、关 4 空间分配担保

关 1:年龄阈值。 对象每熬过一次 Young GC,年龄加一,达到 MaxTenuringThreshold 就晋升。这个参数默认是 15(实测 JDK 26 的 ergonomic 值也是 15),但它是上限,不是实际阈值 —— 实际值由 JVM 动态调整,这正是后面那节要实测的东西。

关 2:大对象直接进老年代。 老规则是:超过 -XX:PretenureSizeThreshold 的对象不走 Eden。这条要打上版本标注 —— 实测 JDK 26 会直接告诉你这个参数已经没了:

OpenJDK 64-Bit Server VM warning: Ignoring option PretenureSizeThreshold; support was removed in 26.0

参数没了,需求还在。G1 用的是另一套机制:Humongous Region,后面单独一节实测。

关 3:动态年龄判定。 这才是让对象提前晋升的主角。HotSpot 会把 Survivor 中所有对象按年龄分组,从年龄 1 开始累加,一旦累计大小超过目标 Survivor 容量,就把阈值降到那个年龄。设计动机很直接:Survivor 装不下这么多同龄对象,与其等它们溢出触发分配担保失败、白白多一次 Full GC,不如提前送进老年代。下一节用日志把这条规则跑出来。

关 4:空间分配担保。 Young GC 之前,JVM 会检查老年代最大可用连续空间是否大于新生代所有对象的总大小,这是为了给「最坏情况全员晋升」兜底。不满足时会去看是否允许担保:允许就仍然尝试 Young GC,不允许就直接 Full GC。担保失败意味着晋升时老年代装不下,只能整堆回收。

实测:把 new threshold 从 15 打到 1

先造一个能稳定观察晋升的场景。逻辑很简单:反复分配 256 KiB 的数组,但只保留最近 40 个,这样每一轮 Young GC 都会有一批对象活着进 Survivor。

import java.util.ArrayList;
import java.util.List;

public class PromotionDemo {
    public static void main(String[] args) {
        List<byte[]> live = new ArrayList<>();
        for (int i = 0; i < 2000; i++) {
            byte[] b = new byte[256 * 1024];
            b[0] = 1;
            live.add(b);
            if (live.size() > 40) {
                live.remove(0);
            }
        }
        System.out.println("live=" + live.size());
    }
}

编译,然后用 -Xlog:gc+age=trace 打开年龄分布日志(JDK 9 之后 -Xlog 统一了所有 GC 日志开关,年龄分布对应 gc+age=trace)。命令写成一行,PowerShell 里可以直接粘:

javac PromotionDemo.java
java -Xms200m -Xmx200m -Xmn100m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -Xlog:gc+age=trace PromotionDemo

输出(节选):

[0.077s][debug][gc,age] GC(0) Desired survivor size 6815744 bytes, new threshold 15 (max threshold 15)
[0.080s][trace][gc,age] GC(0) Age table:
[0.080s][trace][gc,age] GC(0) - age   1:   10227848 bytes,   10227848 total
[0.084s][debug][gc,age] GC(1) Desired survivor size 6815744 bytes, new threshold 1 (max threshold 15)
[0.086s][trace][gc,age] GC(1) Age table:
[0.086s][trace][gc,age] GC(1) - age   1:   10224240 bytes,   10224240 total

max threshold 15 就是 MaxTenuringThreshold 的上限,new threshold 是这一次实际生效的阈值。第一次 GC 还是 15,第二次就掉到 1 —— 对象只活过一轮 Young GC 就够岁数了。所谓「熬过 15 次」,在这里把 15 换成 1 才对。

Desired survivor size 6815744 bytes 这个数不是随便来的,拆开算一遍就明白动态判定的判据是什么。默认 G1 把堆切成 region,实测这个堆的 region 大小是 1 MiB:

java -Xms200m -Xmx200m -XX:+PrintFlagsFinal -version
   size_t G1HeapRegionSize                         = 1048576                                   {product} {ergonomic}
    uint G1ReservePercent                         = 10                                        {product} {default}
    uint MaxTenuringThreshold                     = 15                                        {product} {default}

200 MiB 的堆算出 1048576 字节的 region,也就是 1 MiB。Survivor 占 13 个 region,于是:

目标 Survivor 容量 = 13 × 1048576 = 13631488 字节
其中一半          = 13631488 ÷ 2  = 6815744 字节

正好等于日志里的 Desired survivor size。判据就此明确:把 Survivor 里各年龄的对象按大小从年龄 1 开始累加,累计值一旦超过目标 Survivor 容量的一半,阈值就降到该年龄

回头看 GC(1) 的年龄表:age 1 累计 10224240 字节,而判据是 6815744 字节,10224240 > 6815744 成立,所以 new threshold 被压到 1。数字和结论能对上,不是背下来的。

这里有个容易踩的坑:这条命令里的 -XX:SurvivorRatio=8 根本没有生效,而 -Xmn100m 也只在启动时起作用 —— 原因就是默认收集器是 G1。下一节把两个参数拆开做对照实验。

实测:G1 下 SurvivorRatio 完全失效,-Xmn 只定起点

很多人照着 JDK 8 时代的教程写 -Xmn100m -XX:SurvivorRatio=8,然后在日志里找不到想象中的 Eden、S0、S1,就开始怀疑参数写错了。到底哪个参数还生效?把两个参数拆开做对照实验就清楚了。

同一个程序,五种参数组合,各看第一次 GC 的 region 分布:

java -Xms200m -Xmx200m -Xmn100m -XX:SurvivorRatio=8 -Xlog:gc+heap=info -Xlog:gc PromotionDemo
java -Xms200m -Xmx200m -Xlog:gc+heap=info -Xlog:gc PromotionDemo
java -Xms200m -Xmx200m -Xmn100m -Xlog:gc+heap=info -Xlog:gc PromotionDemo
java -Xms200m -Xmx200m -XX:SurvivorRatio=8 -Xlog:gc+heap=info -Xlog:gc PromotionDemo
java -Xms200m -Xmx200m -Xmn50m -Xlog:gc+heap=info -Xlog:gc PromotionDemo

单看一组是这样:

[0.013s][info][gc] Using G1
[0.107s][info][gc,heap] GC(0) Eden regions: 100->0(87)
[0.107s][info][gc,heap] GC(0) Survivor regions: 0->13(13)
[0.107s][info][gc,heap] GC(0) Old regions: 2->3
[0.107s][info][gc,heap] GC(0) Humongous regions: 0->0
[0.107s][info][gc     ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 100M->14M(200M) 5.144ms

Using G1 第一行就给了答案。五组对照的结果(Eden regions 记「GC 前 -> GC 后(下次目标)」):

参数Eden regionsSurvivor regions
-Xmn100m -XX:SurvivorRatio=8100 -> 870 -> 13
都不加47 -> 780 -> 6
只加 -Xmn100m100 -> 870 -> 13
只加 -XX:SurvivorRatio=847 -> 780 -> 6
-Xmn50m50 -> 430 -> 7

三行结论,逐条看清楚。

SurvivorRatio 完全没生效。 第二行和第四行的数字一模一样 —— 加不加 -XX:SurvivorRatio=8,Eden 和 Survivor 的 region 数一个都没变。这个参数是给连续分代的收集器算 Eden:Survivor 比例用的,G1 按自己的策略决定 survivor region 数量,根本不看它。

-Xmn 生效,但只管起点。 -Xmn100m 让 Eden 从 100 个 region 起步,-Xmn50m 对应 50 个 —— region 大小是 1 MiB,所以给的 MiB 数直接就是 region 数。但它不是硬边界:GC 之后 G1 会把目标改成自己算出来的值(100 -> 87、50 -> 43)。所以 -Xmn 在 G1 下是「初始值和上限的参考」,不是「新生代固定这么大了不许变」。

默认情况下 G1 自己算的年轻代是 47 个 region,GC 后目标 78 个,伸缩幅度相当大。

想复现教科书里的 8:1:1,必须显式把收集器换成连续分代的:

java -Xms200m -Xmx200m -Xmn100m -XX:SurvivorRatio=8 -XX:+UseSerialGC -Xlog:gc+age=trace -Xlog:gc PromotionDemo
[0.070s][debug][gc,age] GC(0) Desired survivor size 5242880 bytes, new threshold 1 (max threshold 15)
[0.070s][info ][gc    ] GC(0) Pause Young (Allocation Failure) 80M->11M(190M) 5.267ms
[0.083s][debug][gc,age] GC(1) Desired survivor size 5242880 bytes, new threshold 1 (max threshold 15)
[0.083s][info ][gc    ] GC(1) Pause Young (Allocation Failure) 91M->11M(190M) 4.661ms

两个数字链都能对上。-Xmn100m 把年轻代定成 100 MiB,SurvivorRatio=8 表示 Eden 与单个 Survivor 的比例是 8:1,两个 Survivor 各占一份,年轻代一共分 10 份:

每份         = 100 MiB ÷ 10 = 10 MiB
Eden         = 8 × 10 MiB   = 80 MiB
单个 Survivor = 10 MiB      = 10485760 字节
其中一半      = 10485760 ÷ 2 = 5242880 字节

Desired survivor size 5242880 对上了,Pause Young ... 80M->11M 也说明 Eden 确实在 80 MiB 处被填满 —— 8:1:1 真的生效了。

Serial 这边第一次 GC 就已经是 new threshold 1,G1 要到第二次才掉下来。两者的内部实现节奏不完全一样,但结论一致:实际阈值远远小于上限 15。盯住这个结论就够了,不必纠结第一次还是第二次。

大对象:PretenureSizeThreshold 没了,G1 用 Humongous

前面说过 PretenureSizeThreshold 在 JDK 26 已被移除。那 G1 怎么处理大对象?答:超过 region 一半的对象会被判定为 Humongous,直接分配在老年代的连续 region 里,不经过 Eden。

写个程序验证。交替分配不同大小的数组,每次打印老年代占用的前后差值:

import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;

public class PretenureSweep {
    static byte[] keep;

    static long oldGenUsed() {
        long used = -1;
        for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans()) {
            String n = p.getName();
            if (n.contains("Old") || n.contains("Tenured")) {
                used = p.getUsage().getUsed();
            }
        }
        return used;
    }

    public static void main(String[] args) {
        int kb = Integer.parseInt(args[0]);
        long before = oldGenUsed();
        keep = new byte[kb * 1024];
        keep[0] = 1;
        long after = oldGenUsed();
        System.out.println(kb + "KiB array -> oldGen delta = " + (after - before) + " bytes");
    }
}

编译后换不同大小各跑一次(每次单独一个 JVM,互不干扰):

javac PretenureSweep.java
java -Xms200m -Xmx200m PretenureSweep 511
java -Xms200m -Xmx200m PretenureSweep 512
java -Xms200m -Xmx200m PretenureSweep 1024
java -Xms200m -Xmx200m PretenureSweep 2560
java -Xms200m -Xmx200m PretenureSweep 3072
511KiB array -> oldGen delta = 0 bytes
512KiB array -> oldGen delta = 1048576 bytes
1024KiB array -> oldGen delta = 2097152 bytes
2560KiB array -> oldGen delta = 3145728 bytes
3072KiB array -> oldGen delta = 4194304 bytes

511 KiB 时老年代一动不动,数组老老实实待在 Eden;512 KiB 时老年代立刻涨了 1 MiB。边界精确落在 512 KiB = region 大小的一半 上。

再看增量的规律,512 KiB 涨 1 MiB 还好理解,但 1024 KiB 涨了 2 MiB、2560 KiB 涨了 3 MiB、3072 KiB 涨了 4 MiB —— 全都比数组本身大 1 MiB。这不是测量误差,是对象头在捣乱:

数组 1048576 字节,加上对象头 16 字节 = 1048592 字节
需要 region 数 = ceil(1048592 ÷ 1048576) = ceil(1.0000153) = 2 个
占用           = 2 × 1048576 = 2097152 字节

数组大小正好是 region 的整数倍时,那 16 字节的对象头会把它顶到下一个 region 去。3072 KiB 同理:ceil(3145744 ÷ 1048576) = ceil(3.0000153) = 4,所以是 4 MiB。反过来 2560 KiB 只有 2.5 个 region,ceil(2621456 ÷ 1048576) = ceil(2.5000) = 3,占用 3 MiB,不会多花。

把这条规律收成一个公式:

老年代增量 = ceil((数组字节数 + 16) ÷ region 大小) × region 大小

实践含义:写缓存或大数组时,如果大小刚好卡在 region 的整数倍,实际内存开销会比预期多整整一个 region。要么留出余量,要么避开整数倍。

老年代指向新生代:卡表、记忆集与写屏障

分代回收有个绕不开的问题:做 Young GC 时要判断新生代对象是否可达,可达性要从 GC Roots 出发。但如果一个老年代对象引用着新生代对象,只扫新生代就会把后者误判为垃圾。而为了这几个引用去扫整个老年代,代价又不划算。

HotSpot 的解法是把「老年代里哪些地方可能有指向新生代的引用」记下来,这就是记忆集(Remembered Set)。记忆集的实现叫卡表(Card Table):把老年代按固定粒度切成卡页,HotSpot 用的是 512 字节一页,某页里出现指向新生代的引用就把该页标记为脏。

跨代引用与卡表:老年代对象指向新生代对象,被指向对象所在卡页被标记为脏,写屏障负责维护标记

维护这个标记的是写屏障。引用赋值时,JVM 在赋值动作前后插入一小段代码更新卡表 —— 把对应卡页置脏。这就是为什么写屏障是「GC 成本」的一部分:它加在每一次引用赋值上,而不是只在 GC 时才跑。

G1 的记忆集更重一些。它不分老年代新生代,而是每个 region 各自维护一份 RSet,记录有哪些别的 region 指向自己。好处是回收单个 region 时能立刻知道谁指向它,代价是内存和写屏障开销都上去了 —— G1ReservePercent 默认 10,就是给这份账留的余量,实测 JDK 26 的默认值也是 10。

CMS 与 G1 的四阶段,以及漏标怎么解

并发收集器的核心矛盾是:标记阶段用户线程还在跑,引用随时在变,怎么保证不把活对象当垃圾回收掉。要理解解法,先要知道标记过程本身怎么描述。

三色标记法:白灰黑三种状态,以及漏标必须同时满足的两个条件,CMS 增量更新与 G1 SATB 各切断一条

把对象分成三种状态:还没被 GC 访问到的是白色,自己访问到了但成员还没扫完的是灰色,自己和成员都扫完的是黑色。标记从 GC Roots 开始,灰色对象逐个变黑、并把它的引用对象染灰,直到没有灰色为止。结束时还是白色的是垃圾。

漏标发生在并发标记期间,而且必须同时满足两个条件:一是黑色对象新增了指向白色对象的引用(黑对象不会再被扫描,这条新引用没人看见),二是原本能到达那个白色对象的灰色引用被删掉了(另一条路也断了)。两条同时成立,那个白色对象就会被当成垃圾回收,但它其实可达。

CMS 和 G1 各自切断其中一条:

两个收集器的四阶段结构也值得并排看:

CMS 与 G1 四阶段时间轴对比:标出各自的 STW 停顿位置与并发阶段,以及漏标处理方式的差异

两者都是「两短两长」的节奏,停顿集中在中间那次重新标记/最终标记,这也是并发收集器调优时最常盯的地方。差别在于 G1 的筛选回收本身也是 STW —— 它要按 region 的回收价值排序挑选,这一步停不住。

一个必须补的版本信息:CMS 在 JDK 9 被标记为废弃,JDK 14 正式移除。现在 JDK 9 及以后的默认收集器是 G1,新项目不该再选 CMS。面试里答「用 CMS 调优」之前,先确认你说的是哪个版本的事情。

方法分派:重载看编译期,重写看运行期

同一个方法名,Java 有两套完全不同的选择机制。用一段极端的小程序就能把两者的分界线划出来:

public class DispatchDemo {
    static void pick(Object o) {
        System.out.println("pick(Object)");
    }

    static void pick(String s) {
        System.out.println("pick(String)");
    }

    public static void main(String[] args) {
        String s = "x";
        Object o = s;
        pick(s);
        pick(o);
        pick(null);
    }
}
javac DispatchDemo.java
java DispatchDemo
pick(String)
pick(Object)
pick(String)

so 指向同一个对象,走出的分支却不同 —— 因为重载在编译期就按变量的静态类型定死了s 的静态类型是 Stringo 的静态类型是 Object,跟运行时那个对象到底是什么无关。这叫静态分派,字节码里对应 invokestaticinvokespecial,以及 invokevirtual 在按参数静态类型选重载版本时的行为。

第三行 pick(null) 输出 pick(String),是同一个规则的特例:null 可以被赋给任何引用类型,编译器只能挑最具体的那个重载,StringObject 具体,于是选中 String 版本。

重写走的是另一条路。invokevirtual 会在运行时拿对象头里的方法表(vtable)查出实际类型对应的方法 —— 静态类型是父类,实际类型是子类,就调到子类的实现。接口调用用 invokeinterface,实现类多的时候查的是 itable,比 vtable 慢,JIT 会做内联缓存(inline cache)把常见情况优化掉。

一句话记:重载看编译期、看静态类型;重写看运行期、看实际类型。

Metaspace:永久代为什么被换掉

JDK 8 用 Metaspace 替掉了永久代,原因不是永久代不好用,是它的位置不对。永久代在堆里,大小固定、要和堆里其他区域抢地址空间、按堆的方式调优;而类元数据的生命周期和对象完全不同 —— 类加载得多、卸载得少,用堆的逻辑管它很别扭。

Metaspace 挪到了本地内存,默认不限大小,靠 -XX:MaxMetaspaceSize 兜底。不设上限的风险是把本地内存吃光,容器里尤其危险:堆还没满,容器已经被 OOM Killer 干掉了。

排查类加载器泄漏看这两个数:jstat -gc 的 MC 和 MU 两列,分别是 Metaspace 的容量和已用。MU 持续上涨且不回落,基本就是类加载器泄漏 —— 常见于反复创建类加载器的场景,比如热部署、脚本引擎、动态代理。

泄漏、安全点与 Slot 复用

内存泄漏和内存溢出是两件事。 泄漏是对象该回收却还被引用着,可用内存越来越少,是「该走的没走」;溢出是内存确实不够用了,是「实在装不下了」。泄漏最终会导致溢出,但溢出不一定由泄漏引起 —— 一次分配一个超大对象就能把堆撑爆。

安全点决定 STW 什么时候真的停。 收到 STW 指令,线程不是想停就能停,得跑到安全点才能停。安全点是代码里少数几个位置,比如方法调用处、循环回边。如果某个线程正好在 Sleep 或者被 Blocked,它没法主动走到安全点,这时候靠安全区域兜住 —— 线程在安全区域里,GC 就不等它了。

Slot 复用解释了一个经典现象。 局部变量表的 Slot 会被复用:一个变量出了作用域,它的 Slot 可能被后面的变量占用。所以「方法里定义一个大数组,用完置 null」这个老建议在某些字节码布局下确实有用 —— 大数组的 Slot 被后面的变量接管,GC 时它就成了不可达对象,可以被回收。但这不是个可靠的优化手段:JIT 编译后变量可能直接被优化掉,Slot 复不复用完全看编译结果,不要依赖它。

怎么自查

几条在真机上验证本文结论的命令:

jmap -histo <pid>
jmap -histo:live <pid>

两条的输出差异就是 GC 能回收的部分 —— 前者不触发 GC,后者会先做一次 Full GC 再统计存活对象。

jstack <pid>
jstat -gc <pid>

jstack 抓线程栈,看有没有线程卡在安全点附近;jstat -gc 看 MC/MU 两列判断 Metaspace 是否泄漏。

如果要统计 GC 次数,别去数日志。让程序自己报,用 GarbageCollectorMXBean

import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;

public class GcCount {
    public static void main(String[] args) {
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println(gc.getName() + " count=" + gc.getCollectionCount()
                    + " timeMs=" + gc.getCollectionTime());
        }
    }
}

把这段放在任何压测程序末尾,就能拿到稳定的 GC 次数和累计停顿时间,比在日志里用工具数行数可靠得多。

面试速答

核心收获:晋升不是只看年龄,四道关任何一道都能让对象提前进老年代,而其中真正在日常生效的是动态年龄判定 —— 上限 15 是给人看的,实际阈值可以低到 1。下一步,拿线上的 gc.log 打开 -Xlog:gc+age=trace,对着 Desired survivor size 和年龄表,把你那套参数下的实际晋升阈值算一遍。

本文关键词:对象晋升、动态年龄判定、Humongous Region、卡表与写屏障、CMS、G1、方法分派、Metaspace