对象晋升老年代的完整四条件:动态年龄判定才是提前晋升的主因
面试里问「对象什么时候进老年代」,八成的人只答得出「熬过 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 到老年代,判定不止看年龄 —— 有四道关,任何一道都可以让它提前过去。
关 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 regions | Survivor regions |
|---|---|---|
-Xmn100m -XX:SurvivorRatio=8 | 100 -> 87 | 0 -> 13 |
| 都不加 | 47 -> 78 | 0 -> 6 |
只加 -Xmn100m | 100 -> 87 | 0 -> 13 |
只加 -XX:SurvivorRatio=8 | 47 -> 78 | 0 -> 6 |
-Xmn50m | 50 -> 43 | 0 -> 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 的四阶段,以及漏标怎么解
并发收集器的核心矛盾是:标记阶段用户线程还在跑,引用随时在变,怎么保证不把活对象当垃圾回收掉。要理解解法,先要知道标记过程本身怎么描述。
把对象分成三种状态:还没被 GC 访问到的是白色,自己访问到了但成员还没扫完的是灰色,自己和成员都扫完的是黑色。标记从 GC Roots 开始,灰色对象逐个变黑、并把它的引用对象染灰,直到没有灰色为止。结束时还是白色的是垃圾。
漏标发生在并发标记期间,而且必须同时满足两个条件:一是黑色对象新增了指向白色对象的引用(黑对象不会再被扫描,这条新引用没人看见),二是原本能到达那个白色对象的灰色引用被删掉了(另一条路也断了)。两条同时成立,那个白色对象就会被当成垃圾回收,但它其实可达。
CMS 和 G1 各自切断其中一条:
- CMS 用增量更新。 记录并发期间「新增的引用」,重新标记阶段把这些黑对象重新扫一遍。它针对的是第一条 —— 黑对象新增引用这件事被记下来了。
- G1 用 SATB(原始快照)。 记录并发期间「被删除的引用」,按并发标记开始那一刻的快照来标记。它针对的是第二条 —— 引用被删这件事被记下来了,那个对象仍按快照认为是活的。
两个收集器的四阶段结构也值得并排看:
两者都是「两短两长」的节奏,停顿集中在中间那次重新标记/最终标记,这也是并发收集器调优时最常盯的地方。差别在于 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)
s 和 o 指向同一个对象,走出的分支却不同 —— 因为重载在编译期就按变量的静态类型定死了。s 的静态类型是 String,o 的静态类型是 Object,跟运行时那个对象到底是什么无关。这叫静态分派,字节码里对应 invokestatic、invokespecial,以及 invokevirtual 在按参数静态类型选重载版本时的行为。
第三行 pick(null) 输出 pick(String),是同一个规则的特例:null 可以被赋给任何引用类型,编译器只能挑最具体的那个重载,String 比 Object 具体,于是选中 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 次数和累计停顿时间,比在日志里用工具数行数可靠得多。
面试速答
- 晋升四条件:年龄到阈值、大对象直接进(JDK 26 已移除
PretenureSizeThreshold,G1 改用 Humongous Region)、动态年龄判定、空间分配担保失败转 Full GC。 - 动态年龄判定:Survivor 中各年龄对象按大小累加,累计超过目标 Survivor 容量一半就把阈值降到该年龄。所以上限 15 不代表实际阈值是 15,实测能掉到 1。
- CMS 四阶段:初始标记、并发标记、重新标记、并发清除。G1 四阶段:初始标记、并发标记、最终标记、筛选回收。CMS 用增量更新(记新增引用),G1 用 SATB(记删除引用)。
- CMS 版本:JDK 9 废弃,JDK 14 移除。JDK 9 起默认 G1。
- 卡表与记忆集:记忆集是抽象,卡表是它在 HotSpot 的实现,卡页 512 字节;写屏障在引用赋值时维护标记。G1 每个 region 维护 RSet。
- 方法分派:重载看编译期静态类型,重写看运行期实际类型走 vtable;
invokeinterface查 itable,有内联缓存。 - Metaspace:在本地内存,默认不限,必须设
MaxMetaspaceSize;类加载器泄漏看jstat -gc的 MU 是否持续上涨不回落。 - 泄漏与溢出:泄漏是引用没断,溢出是内存不够;泄漏会导致溢出,反之不成立。
- 安全点与安全区域:STW 要等线程跑到安全点;阻塞中的线程由安全区域兜住。
- Slot 复用:局部变量表的 Slot 会复用,但不能依赖它做内存优化,JIT 可能改变布局。
核心收获:晋升不是只看年龄,四道关任何一道都能让对象提前进老年代,而其中真正在日常生效的是动态年龄判定 —— 上限 15 是给人看的,实际阈值可以低到 1。下一步,拿线上的 gc.log 打开 -Xlog:gc+age=trace,对着 Desired survivor size 和年龄表,把你那套参数下的实际晋升阈值算一遍。
本文关键词:对象晋升、动态年龄判定、Humongous Region、卡表与写屏障、CMS、G1、方法分派、Metaspace