《Java 虚拟机原理与性能调优》5.2 分代假设与回收算法:复制、标记与整理

同样一段分配负载,Serial 打印 DefNew/Tenured,Parallel 打印 PSYoungGen/ParOldGen,G1 打印的是一串 region 计数。本节用三份真实 GC 日志拆开分代假设、三种回收算法与晋升年龄,并在 -Xmx64m 下量出年龄表最高只到 age 6。

本节目标:把一次 Young GC 拆成「分配—复制—晋升」三步,能从三种收集器的日志形状看出它们在用哪种算法。
适用版本:JDK 25(Temurin 25.0.5)。日志参数为 -Xlog:gc,gc+heap=info,gc+age=trace。

承接的问题

5.1 解决了「谁还活着」,但没有回答「活着的东西放在哪、死了的东西怎么清」。libraryd 的负载有两个截然不同的部分:

  • 借阅批处理每轮创建 500 个 Loan、500 个 4 KB 回执数组,算完即弃,存活时间以毫秒计;
  • LoanCache 里的 Loan、BookCatalog 里的 Book 存活时间以小时计。

如果只有一种回收算法,这两类对象只能用同一套策略处理:要么为短命对象频繁搬运长命对象,要么为长命对象容忍大量垃圾。分代假设就是为了解决这个矛盾。

用一个与 libraryd 批处理同构的 GcLoad 来观察。它每轮创建 500 个 Loan record 和 500 个 4 KB 数组,每 8 轮把 100 个 Loan 放进 retained 列表,模拟「少量对象会活下来」:

List<Loan> batch = new ArrayList<>(500);
for (int i = 0; i < 500; i++) {
    batch.add(new Loan(i, "9780134685" + (i % 10), "m-" + i, i % 40));
    scratch[i] = new byte[4096];
}
for (Loan loan : batch) {
    checksum += loan.overdueDays();
}
if ((rounds & 7) == 0) {
    retained.addAll(batch.subList(0, 100));
}

三种收集器跑同一份代码,-Xms128m -Xmx128m,各 1 秒,日志形状完全不同。下面逐个拆。本机为 Apple M1 Pro / 10 核 / 32 GB / macOS 26.3,Temurin 25.0.5+8;以下数字均为单次运行,未做统计。

弱分代假设:为什么 Eden 和 Survivor 要分开

分代收集建立在两个经验观察上:

  • 弱分代假设:绝大多数对象朝生夕灭;
  • 强分代假设:熬过越多次回收的对象,越可能继续活下去。

第一条决定了新生代要小而快,第二条决定了老年代要少回收。于是堆被切成新生代和老年代,新生代再切成 Eden 和两块 Survivor(From、To)。

一次 Young GC 的过程是:

  1. 新对象在 Eden 里分配(走 TLAB,见 3.3);
  2. Eden 满,触发 Young GC;
  3. 从 GC Root 出发标记 Eden 和 From 里的存活对象;
  4. 把存活对象复制到 To,年龄加一;
  5. 清空 Eden 和 From,两块 Survivor 交换角色。

复制算法的代价是浪费一块 Survivor 空间,收益是不产生碎片、存活对象紧凑排列。它对「存活率低」的新生代非常划算:只搬运少量存活对象,其余整块丢弃。

libraryd 的批处理正好符合这个假设。Loan 在循环结束后就没人引用,LoanState.Overdue 更是每次 state() 调用新建一个。这类对象的存活率接近零,复制成本极低。

三种基本算法与三色标记

回收算法只有三类,收集器的差异在于把它们组合在哪个区域:

算法动作碎片适用JDK 25 中的例子
标记-复制存活对象搬到新区域,旧区域整体清空无存活率低所有收集器的新生代
标记-清除标记存活对象,清除未标记对象有存活率高、追求低停顿G1 的 Mixed GC 回收 old region
标记-整理标记后把存活对象向一端移动无存活率高、可接受停顿Serial Old、Parallel Old、G1 Full GC

标记阶段用三色抽象描述对象状态:

  • 白色:尚未访问,回收时仍为白色就是垃圾;
  • 灰色:自己已访问,子引用还没扫完;
  • 黑色:自己和子引用都扫完。

并发标记时,应用线程还在改引用,可能出现「黑色对象指向白色对象」而白色对象被漏标。两种应对:

  • 增量更新(incremental update):记录黑色对象新指向的白色对象,重新扫描黑色对象;
  • SATB(snapshot-at-the-beginning):记录被覆盖的旧引用,按开始时刻的快照标记。

G1 与 Shenandoah 用 SATB,ZGC 用增量更新式的染色指针方案。这些差异在 6.1 的 gc+ref 日志和 6.3 的 ZGC 一节会再出现。

跨代引用是另一个问题:老年代对象可能引用新生代对象,Young GC 不能扫整个老年代来找这些引用。HotSpot 用卡表(card table)把老年代切成 512 字节的卡,对象引用写入时把对应卡标记为 dirty;Young GC 只扫 dirty 卡。G1 更进一步,每个 region 维护一份记忆集(RSet),记录「谁引用了我」,Mixed GC 只处理有跨代引用的 region。

libraryd 里 BookCatalog 的 Map<String, Book> 是典型的老年代对象,它引用的 Book 也在老年代;但 LoanCache 的 value 可能引用刚创建的 Loan,这类跨代引用就会让对应卡变 dirty,增加 Young GC 的扫描成本。缓存越大、写入越频繁,这部分固定开销越高。

三种收集器的日志对照

同一份 GcLoad,-Xms128m -Xmx128m,各跑 1 秒。先看 Serial:

本机输出(java -XX:+UseSerialGC -Xms128m -Xmx128m "-Xlog:gc,gc+heap=info" -cp out GcLoad 1):

[0.010s][info][gc] Using Serial
[0.063s][info][gc,heap] GC(0) DefNew: 34944K(39296K)->2202K(39296K) Eden: 34944K(34944K)->0K(34944K) From: 0K(4352K)->2202K(4352K)
[0.063s][info][gc,heap] GC(0) Tenured: 1150K(87424K)->1150K(87424K)
[0.063s][info][gc     ] GC(0) Pause Young (Allocation Failure) 35M->3M(123M) 0.848ms

DefNew 是新生代,括号里 39296K 是容量;Eden 从 34944K 清到 0,From 从 0 涨到 2202K,说明存活对象被复制进了 Survivor。Tenured 是老年代,回收前后都是 1150K,这一轮没有对象晋升。35M->3M(123M) 是整堆:回收前 35 MB,回收后 3 MB,总容量 123 MB。

Parallel:

本机输出(java -XX:+UseParallelGC ...):

[0.007s][info][gc] Using Parallel
[0.060s][info][gc,heap] GC(0) PSYoungGen: 33280K(38400K)->2544K(38400K) Eden: 33280K(33280K)->0K(33280K) From: 0K(5120K)->2544K(5120K)
[0.060s][info][gc,heap] GC(0) ParOldGen: 1150K(87552K)->1158K(87552K)
[0.060s][info][gc     ] GC(0) Pause Young (Allocation Failure) 33M->3M(123M) 0.764ms

结构与 Serial 相同,名字换成 PSYoungGen(Parallel Scavenge Young)和 ParOldGen。Eden 与两块 Survivor 的比例由 -XX:SurvivorRatio 决定,本机 33280K : 5120K 正好是 6.5:1,接近默认的 8:1 再按对齐取整。

G1:

本机输出(java -Xms128m -Xmx128m ...,默认收集器):

[0.020s][info][gc] Using G1
[0.076s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 48M->3M(128M) 1.336ms

同样是 -Xmx128m,Serial 和 Parallel 的总容量显示 123M,G1 显示 128M。差出来的 5 MB 不是「G1 多拿了内存」,而是分代布局的切分方式不同:Serial/Parallel 的 123M 是 DefNew 39296K + Tenured 87424K 按半空间和对齐取整后的结果,G1 按 region 数乘 region 大小计算,128 个 1 MB region 正好 128M。两种口径都不能直接和 -Xmx 做减法。

打开 gc+heap 才能看到 region 形状:

本机输出(-Xlog:gc,gc+heap=info,128 MB 堆,GC(0)):

GC(0) Eden regions: 47->0(57)
GC(0) Survivor regions: 0->3(6)
GC(0) Old regions: 2->2
GC(0) Humongous regions: 0->0

G1 没有连续的 Eden/Survivor 空间,而是把堆切成等大的 region(128 MB 堆下是 1 MB,默认 8 GB 堆下是 4 MB,6.2 会专门讲)。Eden regions: 47->0(57) 表示回收前 47 个 region 被当作 Eden,回收后 0 个,括号 57 是当前 Eden 的 region 上限,不是「总共 57 个 region」。Old regions: 2->2 说明这一轮没有对象晋升:存活对象先被复制进 Survivor(0->3),年龄还不够。

三份日志的共同点是 Pause Young,不同点在「堆怎么切」:

  • Serial / Parallel 是连续分代,新生代和老年代各自一块连续空间;
  • G1 是分区堆,Eden、Survivor、Old、Humongous 都是 region 的角色,不是固定地址段。

这也解释了为什么 G1 不接受「把新生代固定成 64 MB」这种要求:新生代大小是每次 GC 时按停顿目标选出来的一组 region,下一次可能就变了。

晋升年龄:从 age 表看对象什么时候进老年代

对象每熬过一次 Young GC,年龄加一;达到阈值就晋升到老年代。阈值默认是 15,但不是「一定到 15 才晋升」,JVM 会根据 Survivor 的使用情况动态调整。

打开 gc+age=trace,G1 会打印每次 GC 后的年龄表。本机 -Xms64m -Xmx64m 跑 6 秒,取最后两次:

[6.039s][trace][gc,age ] GC(2948) - age   1:    2148888 bytes,    2148888 total
[6.039s][trace][gc,age ] GC(2948) - age   2:      27200 bytes,    2176088 total
[6.039s][trace][gc,age ] GC(2948) - age   3:      27200 bytes,    2203288 total
[6.039s][trace][gc,age ] GC(2948) - age   4:      27200 bytes,    2230488 total
[6.039s][trace][gc,age ] GC(2948) - age   5:      40392 bytes,    2270880 total
[6.039s][trace][gc,age ] GC(2948) - age   6:      27608 bytes,    2298488 total

年龄 1 有 2.1 MB,年龄 2 到 6 各只有 27–40 KB。这说明绝大多数对象活不过第一次 GC,少数 retained 里的对象在逐个年龄层停留。2.1 MB 的 age 1 就是下一轮的候选:大部分会被清掉,少数继续加龄。

动态年龄判定出现在同一份日志的 Desired survivor size 行:

[0.055s][debug][gc,age] GC(0) Desired survivor size 2621440 bytes, new threshold 15 (max threshold 15)
[0.061s][debug][gc,age ] GC(1) Desired survivor size 2097152 bytes, new threshold 1 (max threshold 15)
[0.064s][debug][gc,age ] GC(2) Desired survivor size 2621440 bytes, new threshold 15 (max threshold 15)

GC(1) 的阈值被临时降到 1,GC(2) 又回到 15。这是 HotSpot 的 ageTable 逻辑:如果某个年龄之前的对象总大小超过 Survivor 的一半,就把阈值降到那个年龄,避免 Survivor 溢出。阈值是动态的,-XX:MaxTenuringThreshold=15 只是上限。

用 -XX:MaxTenuringThreshold=1 强制所有存活对象一轮就晋升,年龄表会变成:

本机输出(-Xms64m -Xmx64m -XX:MaxTenuringThreshold=1):

[3.025s][trace][gc,age ] GC(1676) Age table:
[3.025s][trace][gc,age ] GC(1676) - age   1:    2148616 bytes,    2148616 total
[3.025s][info ][gc,heap] GC(1676) Eden regions: 35->0(35)
[3.025s][info ][gc,heap] GC(1676) Survivor regions: 3->3(5)
[3.025s][info ][gc,heap] GC(1676) Old regions: 18->18

年龄表只剩 age 1,没有 age 2 及以上。存活对象在一轮之后就进了老年代,老年代的增长速度显著变快。这个参数适合验证「提前晋升会不会让 Mixed GC 更频繁」,不适合生产长期使用。

在 libraryd 里落地:按生命周期分层

libraryd 的对象可以按存活时间分三层,每层的回收压力不同:

层代表对象生命周期回收路径
短命LoanState.Overdue、批处理中间数组毫秒Eden,Young GC 直接清
中命单次请求的 Loan、导出批次秒Survivor 熬几轮后晋升
长命BookCatalog 的 Book、FinePolicy.DEFAULT小时到天老年代,靠 Mixed/Full GC

OverdueDigest 是短命层的典型代表:

package com.plume.library.app;

import com.plume.library.domain.FinePolicy;
import com.plume.library.domain.Loan;
import com.plume.library.domain.LoanState;

import java.time.LocalDate;
import java.util.List;

public final class OverdueDigest {

    private final FinePolicy policy;

    public OverdueDigest(FinePolicy policy) {
        this.policy = policy;
    }

    public long totalFineCents(List<Loan> loans, LocalDate today) {
        long total = 0;
        for (Loan loan : loans) {
            LoanState state = loan.state(today);
            if (state instanceof LoanState.Overdue overdue) {
                total += policy.fineCents(overdue.days());
            }
        }
        return total;
    }
}

每次 loan.state(today) 都会新建一个 LoanState 子类型实例。报表任务处理 10 万个 Loan,就产生 10 万个短命对象。它们全部死在 Eden,不会进入老年代,代价只是更频繁的 Young GC。真正需要关心的是晋升速率:如果 Survivor 太小,这些对象会在第一次 GC 就被迫晋升,老年代被短命对象填满,Mixed GC 频繁触发。

诊断顺序是:

  1. 看 gc+age=trace 的年龄表。age 1 远大于 age 2 是健康形状;age 2 以上持续增长说明有对象在熬年龄。
  2. 看 gc+heap=info 的 Old regions 增长速率。每轮 Young GC 后老年代稳定增长,说明晋升量大于老年代回收量。
  3. 看 Desired survivor size 与 new threshold。阈值频繁被压到很小的值(比如 1),说明 Survivor 相对存活量太小。

调整方向不是「把新生代调大」,而是先确认晋升是否合理:

  • 短命对象不该晋升:检查是否有集合在请求之间被复用(静态 List、线程池里的缓存字段)。
  • 中命对象确实要活几秒:可以适当增大 Survivor,让它们在新生代多待几轮,避免污染老年代。
  • 长命对象本来就该在老年代:不用管,关注老年代的回收效率即可。

BookCatalog 用 Map<String, Book> 静态持有全量书目,这些对象从第一次加载就进入老年代。它们不是泄漏,但如果书目本身有几十万条、每条都带全文索引,老年代占用会很高,Mixed GC 每次能回收的 region 就少。这种情况应该把大字段移出常驻内存(放数据库或文件),而不是调 GC 参数。

常见错误与边界

错误一:用 -Xmn 精确控制 G1 的新生代。 JDK 25 下 -Xmn64m 会写入 NewSize 和 MaxNewSize(PrintFlagsFinal 可查),在 512 MB 堆的实验中 Eden region 数被限制在 60–64 之间波动,但它仍然是上限而不是固定值;G1 的 young 长度每轮按停顿目标重算。调 G1 新生代的正确入口是 -XX:MaxGCPauseMillis 加观测,不是 -Xmn。

错误二:以为对象一定活到 15 岁才晋升。 本机日志里 GC(1) 的 new threshold 是 1,GC(2) 又回到 15。动态年龄判定会让对象提前晋升,-XX:MaxTenuringThreshold 只是上限。只按「15 次 GC 后晋升」估算老年代增长速度会算错。

错误三:把 Eden regions: 47->0(57) 的括号当成「回收后剩余个数」。 前面两个数是回收前后的 region 数,括号里是当前的容量上限。把 57 当成「还有 57 个 Eden region 活着」会得出完全错误的存活率。region 的大小也不是固定的,128 MB 堆下是 1 MB,默认 8 GB 堆下是 4 MB。

错误四:在 zsh 里不写引号。 -Xlog:gc*:file=gc.log 中的 * 会被 zsh 当通配符展开,报 no matches found。所有 -Xlog 参数都要加引号:"-Xlog:gc*:file=gc.log"。

边界一:gc+age 需要 trace 级别。 只写 -Xlog:gc 看不到年龄表;gc+age=trace 才有 Age table 行,gc+age=debug 只有 Desired survivor size 一行。级别写错会以为收集器不支持。

边界二:日志里的 M 不总是 MB。 年轻代小时会出现 K(如 34944K),解析脚本必须按单位换算,不能假定所有值都是 MB。6.1 的脚本会对 K/M/G 分别处理。

边界三:分代假设不适用于所有负载。 如果应用真的会创建大量长命对象(比如一次性加载全量索引),新生代会频繁把对象搬到老年代,复制成本反而更高。这类负载要么调大新生代让对象直接进老年代,要么换用不依赖分代的收集器(ZGC 的分代形态仍然分代,但停顿与堆大小无关)。

小结

  • 分代收集依赖弱分代假设:大部分对象朝生夕灭;新生代用复制算法,老年代用标记-清除或标记-整理。
  • Serial 打印 DefNew/Tenured,Parallel 打印 PSYoungGen/ParOldGen,G1 打印的是 Eden/Survivor/Old/Humongous 的 region 计数;三种形状对应两种堆结构。
  • 本机同负载、同为 -Xmx128m 时,Serial 首轮 35M->3M(123M) 0.848ms,Parallel 33M->3M(123M) 0.764ms,G1 48M->3M(128M) 1.336ms;123M 与 128M 是两种记账口径,不是堆大小不同。
  • 三色标记配合 SATB 或增量更新解决并发标记的漏标;卡表与记忆集解决跨代引用,缓存写入越频繁,这部分固定成本越高。
  • gc+age=trace 的年龄表能看出晋升结构;本机默认阈值 15 下最高只到 age 6,-XX:MaxTenuringThreshold=1 时年龄表只剩 age 1。
  • 动态年龄判定会把阈值临时压低(本机 GC(1) 降到 1),MaxTenuringThreshold 只是上限。
  • libraryd 的优化顺序是「先看晋升速率,再决定新生代大小」,不要用 -Xmn 硬调 G1。

分代和算法是「怎么回收」,具体由哪个收集器执行、各自付出什么代价,是下一节的内容。5.3 会把五种收集器放在同一台机器、同一份负载下对照。

阅读导航:上一节:5.1 可达性分析与引用类型:从 GC Root 到 Cleaner · 下一节:5.3 收集器谱系:Serial、Parallel、G1、ZGC 与 Shenandoah 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发