G1 新生代对象晋升老年代:实现机制与 GC 日志

关键词:G1、Young GC、对象晋升(promotion)、年龄表(age table)、tenuring threshold、survivor region、自适应阈值

本文是 G1 疏散系列的一篇专读,聚焦"一个年轻对象在什么条件下、被谁、搬到老年代",以及它会在 GC 日志里留下什么痕迹。


1. 背景:G1 里"晋升"到底意味着什么

先澄清一个容易混淆的点:G1 没有一块物理上独立的 Survivor 区

在传统的分代收集器(如 Parallel GC / CMS)里,堆被切成 Eden、Survivor0、Survivor1、Old 几块固定区域。一次 Young GC 把 Eden 和 From Survivor 的存活对象搬进 To Survivor,年龄到了阈值再搬进 Old。

G1 不一样。它的堆由很多大小相等的 Region(默认 1MB)拼成,每个 Region 在某一时刻被贴上"Eden / Survivor / Old / Humongous"的标签:

  • Eden region:这一轮新建对象的地方。
  • Survivor region :上一轮 Young GC 里"幸存下来、但还没老到进 Old"的对象所在 region。它本质上还是年轻代,只是当了一次"临时中转站"。
  • Old region:老年代。

所以 G1 里一次 Young GC 的 CSet(Collection Set)同时包含 Eden region + 上一轮的 Survivor region。疏散时:

  • 来自 Eden 的对象,要么直接被回收(死了),要么搬到一块新的 Survivor region(继续当年轻人),要么年龄够了直接搬进 Old region(晋升)。
  • 来自上一轮 Survivor region 的对象,同样面临"继续留在 Survivor 还是晋升 Old"的二选一。

结论先行 :G1 的"晋升"不是"搬进某个固定叫 Old 的区",而是------在疏散的拷贝决策点,把对象的目标位置(dest state)从 InCSetState::Young 改成 InCSetState::Old。一旦目标态是 Old,对象就被分配进一块老年 region,从此脱离年轻代、不再参与后续 Young GC 的 CSet。

决定这一点的,就是这个对象头上 mark word 里的 age 字段 (分代年龄),与收集策略算出的 tenuring threshold(晋升阈值) 之间的较量。


2. 设计挑战:为什么晋升不是"年龄到了就搬"一句话能说完

把这件事真正落地,要回答四个问题:

  1. age 从哪来、存在哪? 每个对象的分代年龄不能每次 GC 重新数,必须持久化在对象自己身上。
  2. 谁来做"留 Young 还是去 Old"的判决? 这个判决必须发生在并行拷贝的最热路径上(每个工作线程都在搬对象),要快、要无锁竞争。
  3. 阈值怎么定? 阈值定太高,Survivor region 装不下、引发晋升失败(evacuation failure);定太低,对象过早进 Old,老年代膨胀、Mixed GC 负担加重。所以 G1 用自适应阈值,依据上一次 GC 各年龄层的存活量反推。
  4. 并行怎么合并统计? 几十个工作线程各自在搬对象、各自在累加年龄分布,最后要汇总成一张全局年龄表来算下一轮阈值,还不能串行瓶颈。

下面顺着"一次对象被搬"的真实代码路径,把四个问题逐一拆开。


3. 年龄的家:对象头里的 mark word

分代年龄不存在别处,就存在对象头的 mark word 里。HotSpot 用 markOopDesc::max_age 表示最大年龄(OpenJDK 8 里是 15,因为 mark word 给 age 留了 4 个 bit)。年龄表 ageTable 的数组大小就是 max_age + 1

cpp 复制代码
// ageTable.hpp:45
enum { table_size = markOopDesc::max_age + 1 };

对象每次在 Young GC 里"活下来一次",年龄就 +1。当它被搬到 Old 后,年龄字段没有意义了(老对象不再数年龄),所以进 Old 时不再 +1,原样搬 mark word 即可(见第 4 节)。


4. 判决点:copy_to_survivor_space 里的留与走

真正决定一个对象去哪里的函数,是 G1ParScanThreadState::copy_to_survivor_space。每个工作线程在 BFS 拷贝循环里,每遇到一个需要搬的 CSet 对象就调用它。精简后的决策骨架如下:

cpp 复制代码
// g1ParScanThreadState.cpp:216
oop G1ParScanThreadState::copy_to_survivor_space(InCSetState const state,
                                                 oop const old,
                                                 markOop const old_mark) {
  uint age = 0;
  // ① 判决:该留在 Young 还是去 Old?
  InCSetState dest_state = next_state(state, old_mark, age);

  // ② 按 dest_state 分配(Young 就进 survivor region,Old 就进 old region)
  HeapWord* obj_ptr = _g1_par_allocator->plab_allocate(dest_state, word_sz, context);
  ... // 失败则走 direct / new PLAB / next PLAB,再不行 evacuate failure

  // ③ 真正拷贝
  const oop forward_ptr = old->forward_to_atomic(obj);
  if (forward_ptr == NULL) {
    Copy::aligned_disjoint_words((HeapWord*) old, obj_ptr, word_sz);

    if (dest_state.is_young()) {
      // 留在年轻代:年龄 +1,并记进本线程的年龄表
      if (age < markOopDesc::max_age) {
        age++;
      }
      obj->set_mark(old_mark->set_age(age));   // 把新年龄写回对象头
      age_table()->add(age, word_sz);          // 本线程年龄表累加
    } else {
      // 去老年代:不再改年龄,原样搬 mark word
      obj->set_mark(old_mark);
    }
    ...
  }
}

判决函数 next_state 极短,是整件事的"灵魂":

cpp 复制代码
// g1ParScanThreadState.cpp:205
InCSetState G1ParScanThreadState::next_state(InCSetState const state, markOop const m, uint& age) {
  if (state.is_young()) {
    age = m->age();                       // 读出对象当前的年龄
    if (age < _tenuring_threshold) {      // 还没到阈值 → 留 Young
      return state;                       //   返回原 young 态(等价于"进新的 survivor region")
    }
  }
  return dest(state);                     // 到了阈值(或非年轻态)→ 去 Old
}

注意 dest(state) 这个映射是在线程状态构造时写死的:

cpp 复制代码
// g1ParScanThreadState.cpp:63
_dest[InCSetState::NotInCSet] = InCSetState::NotInCSet;
_dest[InCSetState::Young]     = InCSetState::Old;   // 年轻态的目标 = 老年态
_dest[InCSetState::Old]       = InCSetState::Old;

所以判决逻辑可以一句话概括:

复制代码
对象来自年轻代(Eden 或上一轮 Survivor)
   └─ 读年龄 age
        ├─ age < 阈值  → 目标态 = Young  → 搬进新 survivor region,age++
        └─ age >= 阈值 → 目标态 = Old    → 搬进 old region(晋升),age 不变

补充两点:

  • 阈值取自哪里 :每个线程构造时把全局阈值快照进 _tenuring_thresholdg1ParScanThreadState.cpp:41),即 g1_policy()->tenuring_threshold()。所以一轮 GC 内所有线程用同一个阈值,保证判决一致。
  • "非年轻态"分支 :Mixed GC 时 CSet 里会有 Old region,state 一进来就是 Oldnext_state 直接走 dest(state) 返回 Old------这类对象本来就在老年代里被疏散,不涉及"晋升"语义(只是老年 region 内部搬家),年龄也不动。

5. 并行下的年龄统计:每线程记账,再合并

判决用的是 age < 阈值,而阈值要"依据上一轮各年龄层存活量"反推------这就需要一张全局年龄表记录"每个年龄还剩多少字节活着"。

但搬运是并行的,几十个线程各自在搬对象、各自 age_table()->add(age, word_sz)。这里的设计是每线程一张本地年龄表 _age_table,GC 结束后合并进全局表

cpp 复制代码
// g1ParScanThreadState.cpp:42  构造时建一张线程本地表
_age_table(false), ...

// g1ParScanThreadState.cpp:282  搬运过程中只往本地表加
age_table()->add(age, word_sz);

一次 Young GC 收尾时,每个工作线程把自己的表并进去:

cpp 复制代码
// g1CollectedHeap.cpp:4820  每个 worker 撤离结束后
_g1h->g1_policy()->record_thread_age_table(pss.age_table());

// g1CollectorPolicy.hpp:920  合并(用原子加,避免串行锁)
void record_thread_age_table(ageTable* age_table) {
  _survivors_age_table.merge_par(age_table);
}

_survivors_age_table 就是收集策略持有的全局年龄表。它下一轮 GC 会被用来算新阈值。


6. 晋升阈值:一个会自己拧的旋钮

传统分代收集器里,对象活到第几岁就晋升老年代,基本是个写死的常数(MaxTenuringThreshold,默认 15)。G1 没有这么做。它把"晋升阈值"设计成一个会自己拧的旋钮------一个根据运行时反馈自动调节的控制变量。

为什么要这样设计?因为"第几岁晋升"这件事,没有放之四海皆准的正确值。它是一道权衡题的两头:

  • 阈值调,对象能在年轻代多留几轮,享受"年轻代回收很便宜"的好处,但留在 survivor 的对象变多,survivor 压力大;
  • 阈值调,对象早早晋升,survivor 轻松了,但老年代流入变快,后续 Mixed GC 负担加重。

G1 的求解思路很朴素:在"survivor 还能从容装下"的前提下,把阈值尽量拧高。也就是说,旋钮要找的,是"仍不撑爆 survivor 的最大年龄"。它不靠拍脑袋,而是靠一套闭环反馈去逼近这个值。下面把这套设计拆开讲。

6.1 旋钮调的是什么:留与升的临界点

先明确旋钮的物理意义。晋升阈值一旦定为某个年龄 N,就意味着:年龄小于 N 的幸存对象继续留在年轻代(survivor region),年龄达到 N 的则在本轮直接搬进老年 region。所以阈值本质上是一条分界线的位置------线左边"留",线右边"升"。

这条线每挪一格,都会同时牵动两端:

  • 线往右挪(阈值升高):更多对象留年轻代 → 年轻代回收效率提升、老年代流入放缓,但 survivor 占用水涨船高,逼近容量极限;
  • 线往左挪(阈值降低):对象更早晋升 → survivor 压力释放,代价是老年代更快被填、Mixed GC 更频繁。

可见旋钮拧的不是某个孤立指标,而是在"年轻代效率"与"survivor 安全"之间找一个动态平衡点。设计的精妙处在于:它不让人去静态地设一个"正确年龄",而是让系统根据实时负载自己把线放到合适的位置。

6.2 闭环反馈:用上一轮的实测,调下一轮的行为

这个旋钮不是开环的"设一次用永久",而是一个负反馈控制器。把控制论的四要素摆出来,内环就清楚了:

  • 被控量:survivor 的实际占用;
  • 设定值(目标):期望 survivor 体量------即"survivor 装到多满就该放行晋升"的那条线;
  • 扰动:每一轮各年龄层的真实存活分布(运行时才看得到,无法预知);
  • 执行机构:阈值本身。

反馈过程:取上一轮汇总出的年龄分布,从最年轻的层开始,把每层存活量一层层累加进计数器,边加边看"总量有没有撞上设定值"。没撞上就说明这一层留在 survivor 仍在预算内,继续往更老的年龄试;加上某一层就撞线了,说明"若把这一层也留下 survivor 就超载",于是这一层就是新阈值------它和更老的对象下一轮都晋升。

之所以说它"自我调节",是因为天然收敛:survivor 被中等年龄对象挤满 → 计数器在较年轻年龄就撞线 → 阈值自动下降 → 下一轮更早晋升 → survivor 泄压;survivor 一直很空 → 计数器迟迟撞不到线 → 阈值自动抬升 → 更多对象留年轻代。一压一抬,正是把被控量往设定值拉的负反馈。

但光看这一层,还称不上"闭环"。阈值只是把对象留下或放走,真正让环闭上的,是留下的对象反过来改了下一轮的预算

某一轮疏散时,阈值把一批对象留在年轻代,这批对象最终填满若干个 survivor region。本轮收尾,策略会把"这一轮实际占用了几个 survivor region"记下来;紧接着在重新计算下一轮年轻代的目标体量时,把"上一轮带过来的这些 survivor region 得先留位置"当作下一轮年轻代目标长度的下限,于是年轻代目标被整体抬高。等下轮 GC 启动前,策略再按"年轻代目标长度除以 eden 与 survivor 的比例"重算 survivor 的预算------年轻代目标变大,分给 survivor 的预算随之变大,期望体量变大,阈值才有往上走的余地。

另一条回流通道走暂停时间:策略在估算下一轮能放多少个 eden region 时,会把"要疏散这些 survivor region 预计要花多少时间"算进本轮 GC 的时间预算,再拿暂停时间目标去卡。保留的 survivor 越多,疏散耗时的预测越大,下一轮 eden 的目标就被压得越小------这是策略在保暂停时间不破上限。

内外两层合起来,才是完整的环:

复制代码
[内环] 年龄分布(扰动) → 阈值(执行机构) → 保留对象 → survivor 占用(被控量)
                                   ↑                              │
                                   │   撞线即调阈值,把占用拉回设定值   │
                                   └────────── 期望体量(设定值) ←──────┘

[外环] 阈值 → 保留对象 → 实际占用的 survivor region 数
       → 记进下一轮年轻代目标长度的下限
       → 下一轮 survivor 预算变大 → 期望体量变大 → 阈值(N+1)

也就是说,阈值既在"内环"里调节保留率去压 survivor 占用,又在"外环"里凭实际占用量悄悄推动下一轮的预算和设定值。所以 6.3 要讲的"期望 survivor 体量"并非凭空定的常数,它会被上一轮阈值造成的 survivor 占用推动;G1 的 survivor region 数也因此稳在一个跟近期保留量相关的位置,而非死守某个固定比例。这里要分清楚:阈值没有直接去改 survivor 大小,它改的是"保留率→实际占用量",实际占用量作为下一轮分区下限流回去------是两步间接反馈,不是阈值直接回写预算。(外环涉及的具体代码位置见文末附录。)

6.3 旋钮的量程:期望体量由谁定

控制器要工作,得先有"设定值"。G1 用两个配置项共同圈出 survivor 的预算包络

  • SurvivorRatio (默认 8):决定 survivor 相对年轻代"能长多大"。它其实就是经典分代里 eden : survivor = 8 : 1 这个比例在 G1 的投影------限定 survivor 最多占年轻代目标体量的 1/8。这是预算的上限
  • TargetSurvivorRatio (默认 50):决定 survivor 装到容量的百分之多少就算"满"、该开始放行晋升。这是预算的利用率触发点

两者相乘,得到"期望 survivor 体量"= 容量上限 × 触发比例。设计上,G1 把 survivor 容量当成一道软预算而非硬墙:它不是等 survivor 真塞满了才手忙脚乱,而是在占用逼近设定值时就通过调低阈值提前泄压。晋升因此是预设的"泄压阀",而不是故障后的补救。

6.4 两道硬边界:纯反馈也会翻车,所以加夹子

如果只靠 6.2 的负反馈,两个极端会出怪事,所以设计里给旋钮套了两道固定夹子,与反馈分工:

  • 上限夹子(MaxTenuringThreshold,默认 15):若 survivor 极空,反馈会想无限抬阈值。但对象年龄最大只能到 15,夹子保证"哪怕 survivor 再闲,年龄到 15 也必然晋升",杜绝对象永远赖在年轻代、永不晋升。
  • 下限夹子(阈值最低压到 1):若 survivor 极挤、连第一层都装不下,反馈会把阈值压到 1,即"活过一次 Young GC 就晋升"。这是兜底而非故障------它避免硬塞导致 evacuation failure,也保证至少经历一轮"留→升"的节律。

所以旋钮的最终输出 = 反馈算出的"容量允许年龄",再被这两道夹子夹回合理区间。反馈负责处理日常的平滑波动,夹子负责兜住工程上的极端,职责分明。

6.5 为什么允许"慢一拍":时序上的设计取舍

最后一点是这套旋钮的"时间属性"。阈值用的不是本轮刚发生的统计,而是上一轮汇总出的全局年龄表 ,在下一轮 GC 启动前 就算好。换句话说,反馈存在一轮延迟

这看似不"实时",却是刻意的设计:

  • 各年龄层的存活分布在两轮 GC 之间变化缓慢,慢一拍对稳态行为几乎没有影响;
  • 负反馈本身会收敛,延迟一拍不会让它发散;
  • 反过来,若要在 GC 进行中、对象正在被搬时现算阈值,既要读全局统计又要让所有线程同步,既贵又容易引入竞争。把计算挪到"下一轮启动前、基于已定稿的上一轮统计",实现上干净、无锁、可预期。

这正是"用历史反馈调节未来行为"这一自适应思想在 G1 里的落地:旋钮不追求瞬时精确,只追求在稳态上贴住真实负载。(旋钮所需的统计与计算的具体代码位置,见文末附录;它产生的日志见第 8 节。)


7. 一条完整链路串起来

把前面几节按顺序连成一条时间线:

复制代码
[第 N 次 Young GC 启动前]
   update_survivors_policy()
     └─ 用第 N-1 次汇总的全局年龄表算 _tenuring_threshold

[第 N 次 Young GC 并行搬运]
   每个 worker 搬对象 → copy_to_survivor_space
     ├─ next_state: 读 age,age < 阈值 ? 留 Young(age++) : 去 Old(晋升)
     └─ 留 Young 时 → 本线程 _age_table.add(age, size)

[第 N 次 Young GC 收尾]
   每个 worker → record_thread_age_table(本地表)
     └─ 合并进策略的全局 _survivors_age_table   (原子加,无锁)

[第 N+1 次 Young GC 启动前]
   update_survivors_policy() → 重新算阈值 ...... 循环

8. 它输出的 GC 日志是什么

晋升相关日志分两类,一类讲"晋升阈值与年龄分布",一类讲"这次堆各区域前后变化"。

8.1 年龄分布与阈值:-XX:+PrintTenuringDistribution

开启 PrintTenuringDistribution 后,每次算阈值都会打出年龄表与决策结果(逻辑在 ageTable.cpp:96-119)。典型输出:

复制代码
Desired survivor size 8388608 bytes, new threshold 4 (max 15)

- age   1:  3145728 bytes,  3145728 total
- age   2:  2097152 bytes,  5242880 total
- age   3:  4194304 bytes,  9437184 total
- age   4:   524288 bytes,  9961472 total

读法:

  • 第一行 Desired survivor size ... new threshold N (max M)N 就是本轮(下一轮生效)算出的晋升阈值,MMaxTenuringThresholdDesired survivor size 即第 6 节的 desired_survivor_size(期望 survivor 体量,字节)。
  • 下面每行 - age K: X bytes, Y totalX 是当前年龄 K 层存活的字节数,Y 是从年龄 1 累加到 K 的累计字节数。
  • 上面例子里,Desired survivor size 是 8MB,累加到 age 4 时(9.9MB)已超过 8MB,所以 age 4 被定为新阈值------年龄 ≥ 4 的对象下次 GC 直接晋升 Old(注意是"下次 GC"用的,因为阈值在 GC 启动前算)。

8.2 堆区域前后变化:-XX:+PrintGCDetails

Young GC 详情日志里有一行 Eden / Survivors / Heap 的前后对照(格式串在 g1CollectorPolicy.cpp:1229):

复制代码
   [Eden: 1024.0M(1024.0M)->0.0B(1024.0M) Survivors: 64.0M->128.0M Heap: 2048.0M(4096.0M)->1568.0M(4096.0M)]
  • Eden: ...->0.0B:Eden 被清空(对象要么死、要么被搬走),符合预期。
  • Survivors: 64.0M->128.0M:上一轮 Survivor 还有 64M 活着,本轮又搬进来 64M(留 Young 的部分),新的 Survivor 占 128M。
  • Heap: 2048M->1568M:整堆从 2G 降到 1.568G,下降的 480M 里有一部分就是晋升进 Old 的老对象 + 被回收的垃圾。Old region 的增长量 = 本轮晋升量(Mixed GC 时还会叠加被疏散的老年 region 自身存活量,需结合年龄表解读)。

一个重要区别 :G1 的 PrintGCDetails 日志不像 Parallel GC 那样有单独的 Promoted: X bytes 一行 。G1 里"晋升了多少"只能从两个侧面推断:① Old region 在本次 GC 后的占用增长;② PrintTenuringDistribution 里各年龄层、尤其是刚触发晋升那一层的字节数。换句话说,要看晋升细节,-XX:+PrintTenuringDistribution 是必不可少的开关。

8.3 统一日志框架(gc+tracer)的晋升事件

除文本日志外,G1 在拷贝路径上埋了 tracer 钩子:

  • 对象分配到 Old 且不在 PLAB 内时,发 report_promotion_outside_plab_eventg1ParScanThreadState.cpp:200);
  • 需要上报晋升事件时发 report_promotion_eventg1ParScanThreadState.cpp:245);
  • 每次 GC 结束上报当前阈值 report_tenuring_thresholdg1CollectedHeap.cpp:4361)。

这些主要用于 -Xlog:gc* 统一日志框架与 JFR 事件,是带结构化字段的事件,不是给人直接读的文本行,但和 8.1/8.2 的文本内容同源。


9. 一个容易忽略的"晋升":大对象直接进 Old

还有一种对象"一出生就在 Old",严格说不算 evacuation 晋升,但常被混为一谈,这里点一句以免歧义:

  • Humongous 对象 (超过半数 region 的大数组等)走 humongous_obj_allocate,直接分配在 Old region 链上。它从分配那一刻起就不在年轻代,后续由 Mixed GC / Full GC 处理,不参与本文的"年龄→晋升"机制。
  • 本文前面所有"年龄、阈值、survivor"的讨论,都只针对普通对象在 Young GC 疏散里的晋升路径

10. 小结

  • G1 晋升的本质 :疏散拷贝决策点把对象目标态从 Young 改成 Old,对象被搬进老年 region,从此脱离年轻代 CSet。G1 没有固定 Survivor 区,survivor region 只是当一轮中转站的年轻 region。
  • 判决极轻量next_state 读对象头 age,与线程快照的 _tenuring_threshold 比较,age < 阈值 留 Young(age++ 并记入线程本地年龄表),否则去 Old(晋升,age 不变)。整段在并行热路径、无锁。
  • 自适应阈值跨轮协作 :本轮 GC 并行累加每线程年龄表 → 收尾原子合并进全局 _survivors_age_table → 下一轮 GC 启动前用"全局表 + 期望 survivor 体量(TargetSurvivorRatio%)"反推出新阈值,且不超过 MaxTenuringThreshold。所以它天然带一轮延迟,却让 survivor 永不撑爆。
  • 日志两处最关键-XX:+PrintTenuringDistribution 给出 Desired survivor size ... new threshold N 与各年龄层字节;-XX:+PrintGCDetailsSurvivors: A->BHeap: ...->... 反映晋升前后的区域占用。G1 没有 Parallel GC 那种独立 Promoted: 行,晋升量靠 Old 增长 + 年龄表推断。
相关推荐
万法若空1 小时前
排列组合恒等式
c++·算法
AI人工智能+电脑小能手1 小时前
大白话说Java设计模式-34-命令模式(业务实战篇)
java·spring·设计模式·命令模式·异步任务·撤销重做·事务封装
Nil2081 小时前
leetcode 105从前序和中序遍历序列构造二叉树
算法·leetcode·职场和发展
净重21克1 小时前
万字长文!TLS协议升级避坑手册:JDK老项目适配TLS1.2/1.3全流程拆解
java·springboot·java安全
一直C1 小时前
【数据结构】哈希表+算法复杂度与经典排序查找(C语言)
java·linux·开发语言·数据结构·算法·ubuntu·散列表
paopaokaka_luck1 小时前
基于springboot3+vue3的车间生产管理系统(Echarts图形化分析、BI报表)
java·前端·spring boot·学习·echarts
淡海水1 小时前
04-02-哈希-Dictionary-TKey-TValue-上-核心数据结构
数据结构·算法·c#·哈希算法·编译·字典·dictionary
戴西软件1 小时前
国内有哪些数据轻量化格式转换软件?
数据库·算法·安全·信息可视化·自动化·rpa
Shadow(⊙o⊙)2 小时前
C++进阶知识5.0
jvm