G1 GC 自适应年轻代大小之年轻代分区数原理 本文是《G1 采样线程功能设计与实现》的姊妹篇。前一篇介绍了采样线程量出了什么、怎么量本文聚焦采样值 X 如何一步步变成下一次 GC 应该回收多少个年轻代分区——也就是_young_list_target_length的推导全过程。0. 一句话结论采样线程量出的那个数Xyoung 区 RSet 卡片总量代表下次 GC 要扫描多少跨代引用本身不是分区数。把它变成分区数要经过三步先和旧预测比一比如果本轮实测的X比策略内部保存的旧预测值大就把那个旧预测值往上抬到X × 1.1多留 10% 余量否则就不动。被抬的是策略内部的预测值X作为实测值本身不变。折算成固定时间成本把X换算成一段不管收多少区都必花的时间base_time固定底盘时间。二分搜索出最大分区数在允许范围内反复试探找到最大的、但总暂停时间仍在预算内的年轻代分区数这就是下次 GC 的目标。核心公式如下总暂停时间 固定底盘 base_time由 RSet 工作量 X 决定收多少区都得花 N × 每分区拷贝成本收的区越多这笔钱花得越多 结论X 越大 → base_time 越大 → 留给每分区拷贝的余额越少 → 能收的分区数 N 越小 → 下次 GC 来得更早、收得更少 → 反而把暂停时间压住了RSet 工作量与年轻代目标分区数之间并非正相关扫描负担重时G1 反而把年轻代目标调小让 GC 更频繁、每次更轻从而把暂停时间压在用户设的期望内。1. 输入采样值 X 到底是个啥采样线程在两次 GC 之间周期性地把每个 young 区的 RSet 卡片数加总得到一个数字X。X 本质上表示**“下次 young GC 要从老年代扫描多少张’指向年轻代的卡片’”**。为什么这个数重要因为 young GC 做疏散时必须扫描老年代里那些指向年轻代对象的卡片才能把所有引用找全、不漏搬。这些卡片越多扫描越久。RSet 记的是谁引用了我。young 区 R 的 RSet记的是谁引用了 R。真正在下一次 young GC 时要被扫描的是老年代 → 年轻代的引用卡。这些脏卡在老年代那边并发Refine线程不会把它们丢掉被丢掉的是脏卡源区自己就是 young的情况。所以X 所有 young 区 RSet 里置位的卡片总数 下次 GC 必须扫描的跨代引用工作量。概括地说X越大 跨代引用越多 下次 GC 扫描越慢 这个实时工作量必须告诉 GC 策略。2. 第一关和旧预测比一比超标才重算采样线程每次量完会调这个方法代码位置g1CollectorPolicy.cpp:811。下面先说明它的作用代码附在后面对照。策略内部维护着一个RSet 工作量预测值_rs_lengths_prediction——它是上一次算年轻代目标时采用的 RSet 工作量估值单位与X一致都是卡片数不是分区数。采样线程量出的真实X实测值拿来和它比若X未超过旧预测则旧预测仍然有效维持不变不做重算。若X超过了旧预测则把旧预测值抬到X × 1.1多留 10% 余量免得马上又超标然后触发一次重算。这一步只决定了喂给重算逻辑的工作量是多少还没真正算分区数。算分区数是后面的事。voidG1CollectorPolicy::revise_young_list_target_length_if_necessary(){guarantee(adaptive_young_list_length(),should not call this otherwise);// 采样线程量出的当前 young 区 RSet 卡片总量size_t rs_lengths_g1-young_list()-sampled_rs_lengths();if(rs_lengths_rs_lengths_prediction){// 实测超过旧预测才上调预测10% 留余量并重算目标分区数size_t rs_lengths_predictionrs_lengths*1100/1000;update_young_list_target_length(rs_lengths_prediction);}}这样设计有两个考量只超标才动避免没事反复重算浪费 CPU。乘 1.1 留余量让新预测稍微领先实测减少短期内反复触发的频率。3. 第二关把 RSet 工作量折算成固定底盘时间 base_time这一步是整个推导的核心。先说明其含义再看代码。无论下次 GC 回收 5 个区还是 50 个区都有一笔时间无论如何省不掉——即扫描 RSet所需的时间。这笔成本只跟X要扫多少卡有关跟你收多少区无关。G1 把这笔固定成本算出来起名叫base_time固定底盘时间。base_time由这几块拼成代码位置g1CollectorPolicy.cpp:711-729doubletarget_pause_time_ms_mmu_tracker-max_gc_time()*1000.0;// 你设的单次 GC 最多停多久doublesurvivor_regions_evac_timepredict_survivor_regions_evac_time();// 上轮幸存区疏散耗时固定size_t pending_cards(size_t)get_new_prediction(_pending_cards_seq);// 预计还堆着多少没精细化的脏卡size_t adj_rs_lengthsrs_lengthspredict_rs_length_diff();// RSet 工作量 到 GC 时还会涨的部分size_t scanned_cardspredict_young_card_num(adj_rs_lengths);// 折算成实际要扫的卡数doublebase_time_mspredict_base_elapsed_time_ms(pending_cards,scanned_cards)// RSet 更新 扫描 常数开销survivor_regions_evac_time;// 幸存区疏散逐块说明如下字段含义与 X 的关系pending_cards“现在还堆着多少来不及并发精细化的脏卡”间接跟着 X 涨predict_rs_length_diff()“从采样到真正 GC 这会儿RSet 还会再多多少”直接跟着 X 涨predict_young_card_num()“每个 RSet 条目实际对应要扫几张卡”直接跟着 X 涨predict_base_elapsed_time_ms“扫描更新这些卡要多久 一些固定杂项”跟着要扫的卡数涨survivor_regions_evac_time“上轮留下的幸存区这次还得搬”跟本次收几个区无关base_time构成了一块固定底盘你收的区越多额外要拷贝的对象越多但底盘这块成本不随区数变化。所以X越大 →base_time越大 → 留给每分区拷贝的时间余额越小 → 最终能收的区数N越小。这就是采样值X影响分区数的根本杠杆——代码只是把上述关系表达成算式。4. 第三关二分搜索能塞进暂停预算的最大分区数目标很明确在允许范围内找到最大的、但总暂停没超预算的年轻代分区数N。怎么找二分搜索。先讲清楚二分搜索每一步在干什么再看代码。二分搜索搜的是什么在[最少区数, 最多区数]这个候选区间里找到最大的、且总暂停没超预算的那个区数N。搜的不是某个固定答案而是区间里那个临界的最大值。二分搜索每一步在干什么它反复做同一件事——在区间里选一个中点候选区数mid当前上界与下界的正中间把mid交给predict_will_fit(mid)由它预测如果年轻代长到mid个区这次 young GC 的总耗时大概是多少预测方式是总暂停 固定底盘 base_time mid 个区的拷贝时间 年轻代其它时间把预测出来的总耗时和用户设的暂停预算target_pause_time_ms比较没超预算说明mid还塞得下、可以尝试更大超了说明mid太大、得收着点。为什么二分能成立因为候选区数越多 → 预测暂停越久是单调递增的。一旦某个mid预测超时所有比它更大的候选也必然超时一旦某个mid预测不超时所有比它更小的候选也必然不超时。所以每试一个中点就能把不可能的一半区间整个砍掉反复对折直到锁到那个最大可行值。有了这个单步动作后再看整体入口逻辑先确认最少区数肯定塞得进预算至少能做一次合法 GC再试最多区数要是连最多都能塞进说明预算很宽裕直接顶到最多否则进入上面的二分循环。calculate_young_list_target_length()代码位置g1CollectorPolicy.cpp:691核心二分在733-788// 先确认最短长度能塞进目标暂停至少能做一次合法的 young GCif(predict_will_fit(min_young_length,base_time_ms,base_free_regions,target_pause_time_ms)){// 最短都行 → 再试绝对最大长度若最大也行说明预算很充裕直接顶到最大if(predict_will_fit(max_young_length,base_time_ms,base_free_regions,target_pause_time_ms)){min_young_lengthmax_young_length;}else{// 最大超标 → 二分搜索。循环不变式// min 已知能塞进暂停预算max 已知塞不进暂停预算且 min maxuint diff(max_young_length-min_young_length)/2;while(diff0){uint young_lengthmin_young_lengthdiff;// 当前上下界的中点候选区数// predict_will_fit 预测年轻代长到 young_length 个区时本次 young GC 的总耗时// 并拿预测总耗时与暂停预算 target_pause_time_ms 比较if(predict_will_fit(young_length,base_time_ms,base_free_regions,target_pause_time_ms)){min_young_lengthyoung_length;// 预测没超预算 → 还能更大抬高下界}else{max_young_lengthyoung_length;// 中点超标 → 得收着点压低上界}diff(max_young_length-min_young_length)/2;}}}// 最终结果 已存在部分幸存区 最优 eden 长度returnbase_min_lengthmin_young_length;上面的单调性正是二分搜索能收敛到最大可行值的依据。上下界不是凭空给定的而是由update_young_list_target_length()代码位置g1CollectorPolicy.cpp:612先算两对边界再取交集得到的。下面分别看它们的来源和理由。下界最少区数绝对下界absolute_min 幸存区数 1上一轮 young GC 留下的幸存区已经占坑base_min_length recorded_survivor_regions()最坏情况下至少还要再分出 1 个 eden否则连一次合法的 young GC 都做不了。期望下界desired_min自适应模式下用 MMU 追踪器算出距离下一次允许的最大 GC 时刻还有多久when_ms乘以预测的分配速率得到这段时间内应用大概会占满多少年轻代分区——这就是为了让 GC 别来得太晚至少要预留的大小再叠加用户通过-XX:G1NewSizePercent设的硬下限。最终下界 max(desired_min, absolute_min)即期望下界不能跌破幸存区 1这个硬底。上界最多区数期望上界desired_max基本就是用户通过-XX:G1MaxNewSizePercent/-XX:MaxNewSize设的年轻代大小上限。绝对上界absolute_max 空闲分区数 − 为晋升预留的分区数年轻代再大也不能把老年代晋升的退路吃掉否则对象晋升不出去会 OOM。最终上界 min(desired_max, absolute_max)即用户上限被可用空间反压。为什么二分搜索前要把上下界各减去幸存区数这是最容易看漏的一步。calculate_young_list_target_length()收到的min/max在进入循环前都做了− base_min_lengthuint min_young_lengthdesired_min_length-base_min_length;uint max_young_lengthdesired_max_length-base_min_length;// ... 二分搜索在 [min_young_length, max_young_length] 上做 ...returnbase_min_lengthmin_young_length;// 最后再加回幸存区原因二分搜索要决定的只是还能再新分配多少个 eden 区而上一轮留下的幸存区是已经固定、不可变的。幸存区的疏散开销不随本次新 eden 数量变化所以被提前并进了base_time_ms的固定底盘survivor_regions_evac_time。把上下界都扣掉这部分搜索变量N就纯粹等于新增 eden 数正好对应predict_will_fit(N, ...)里那笔随 N 线性增长的拷贝成本。最后返回时再把幸存区加回去得到完整的年轻代目标分区数。二分搜索的目标是扣完固定底盘、且新增 eden 数不超出上下界后、预算还能塞下的最大区数。5. predict_will_fit这个候选区数行不行的裁判三条规矩二分搜索每尝试一个候选N都要调用predict_will_fit()g1CollectorPolicy.cpp:517判断其是否可行。它按三条规则判定规则一空间是否足够若候选区数已逼近可用空闲分区直接判为不可行空间已不足。规则二暂停是否超出预算最关键总暂停 固定底盘 base_time 拷贝时间 年轻代其它时间超过你设的目标 → 不可行。规则三预留余量以策安全用 2 倍标准差sigma()放大要拷贝的字节估算若仍超过剩余空闲字节则保守判为不可行——防止在边界情况下拷贝失败、对象晋升不出去promotion failure。bytes_to_copy是预测值而非确定值真实存活会围绕它波动sigma()是历史预测误差的标准差2σ约对应 95% 置信上界即用悲观上界而非均值来判断。此条与规则二独立规则二防卡顿超时规则三防 GC 中途空间不足导致的晋升失败。示例预测要拷贝 100MB期望值历史误差标准差约 20MBσ2σ 40MB95% 悲观上界 140MB。若 GC 后剩余空闲 120MB 140MB连悲观上界都装不下 → 判不可行若剩余 160MB 140MB → 通过。即不按均值留空间而按 95% 悲观上界留空间避免晋升失败。下面是这三条规则对应的代码boolG1CollectorPolicy::predict_will_fit(uint young_length,doublebase_time_ms,uint base_free_regions,doubletarget_pause_time_ms){// ① 空间不够候选区数已逼近可用空闲分区if(young_lengthbase_free_regions){returnfalse;}// ② 拷贝 其它年轻代开销叠加到底盘时间上超暂停预算则不行doubleaccum_surv_rateaccum_yg_surv_rate_pred((int)young_length-1);size_t bytes_to_copy(size_t)(accum_surv_rate*HeapRegion::GrainBytes);doublecopy_time_mspredict_object_copy_time_ms(bytes_to_copy);// hpp:396doubleyoung_other_time_mspredict_young_other_time_ms(young_length);// hpp:409doublepause_time_msbase_time_mscopy_time_msyoung_other_time_ms;if(pause_time_mstarget_pause_time_ms){returnfalse;}// ③ 2σ 保守估计防止刚好够时拷贝失败导致晋升失败promotion failuresize_t free_bytes(base_free_regions-young_length)*HeapRegion::GrainBytes;if((2.0*sigma())*(double)bytes_to_copy(double)free_bytes){returnfalse;}returntrue;}其中拷贝时间来自下一节要讲的幸存率模型年轻代其它时间就是区数 × 每区固定开销随区数线性涨。6. 每分区拷贝成本从哪来幸存率模型predict_will_fit里那句accum_surv_rate_pred(young_length - 1)是每分区要拷贝多少的真正来源。它本质是一个按年龄分组的每区存活比例前缀和可拆成三个要点来理解。6.1 模型里的年龄指什么模型按年龄给区分组统计存活率这里的年龄不是对象真实年龄而是HeapRegion::_age_index每次 GC 分配新 survivor 区时由SurvRateGroup按本次停顿里的创建次序发的代际标签下次 GC 归零重排专门给存活率模型打近似槽位。所以agei 的区可理解为本次停顿里第 i 个新创建的 survivor 区模型假设它大致对应熬过 i 轮 GC 的内容。正是这个近似槽位让下面按年龄分组的存活率前缀和能够成立。6.2 每个年龄组的存活率是啥s_i 一个 agei 的区这次 GC 里有多大比例的对象会活下来要被拷贝走。它不是固定常数而是从历史实测里平滑预测出来的——每次 GC 后对年龄组测真实存活率喂进历史序列预测时取它的新预测值并夹紧到不超过 100%代码位置g1CollectorPolicy.hpp:459-468。doublepredict_yg_surv_rate(intage,SurvRateGroup*surv_rate_group){TruncatedSeq*seqsurv_rate_group-get_seq(age);doublepredget_new_prediction(seq);// 从历史事件序列平滑出预测值if(pred1.0)pred1.0;// 存活率不可能超过 100%returnpred;}补充存活率是按整个区大小算的相当于默认每个区都是满的所以预估会略偏高一点——这是个保守假设。6.3 s_i 是怎么算出来的s_i不是预先给定的常数也不是某个公式反解出来的而是实测 → 预测 → 外推三步得到的。实测原始存活率。每次真实 young GC 疏散年龄槽位i的区时G1 精确知道这轮有多少字节能活下来据此算出该槽位的原始存活率// survRateGroup.cpp:156doublesurv_rate(double)surv_words/(double)HeapRegion::GrainWords;_surv_rate_pred[age_in_group]-add(surv_rate);// 推进本槽位的历史序列也就是说s_i的原材料是实际存活字数 ÷ 满区字数来自真实 GC 的逐区测量不是拍脑袋。平滑预测。规划下一次 GC 前predict_yg_surv_rate(i)从历史序列算预测值g1CollectorPolicy.hpp:322// g1CollectorPolicy.hpp:322doubleget_new_prediction(TruncatedSeq*seq){returnMAX2(seq-davg()sigma()*seq-dsd(),// 均值 1σ偏悲观seq-davg()*confidence_factor(seq-num()));}取该槽位历史存活率的均值再往上抬 1 个标准差留悲观余量同时用置信下限托底样本少时不会估太低最后夹紧到 ≤1.0。这就是 §6.2 里被predict_yg_surv_rate返回、并填入前缀和数组的那个s_i。外推未观测年龄。若本次要规划的 N 超过了历史观测到的最高年龄比如头几次 GC 还没活到 age 7代码用最高观测槽位的最后实测值填充更高槽位survRateGroup.cpp:168-174即 §6.6 提到的线性外推。6.4 前缀和把每个年龄累加成到某年龄为止的总和每次 GC 后G1 会预先算好一个累计存活率数组累计和 0 从年龄 0 到最大年龄逐个来 预测该年龄存活率 s_i夹紧到 ≤ 1.0 累计和 累计和 s_i 记下第 i 项 累计和于是这个数组是前缀和第 0 项 s_0第 1 项 s_0 s_1第 2 项 s_0 s_1 s_2……好处想知道前 N 个区总共要拷贝多少比例直接查第N-1项就行不用每次现加。6.5 怎么变成总拷贝量回到裁判那句代码g1CollectorPolicy.cpp:527-529doubleaccum_surv_rateaccum_yg_surv_rate_pred((int)young_length-1);// s_0 ... s_{N-1}size_t bytes_to_copy(size_t)(accum_surv_rate*HeapRegion::GrainBytes);含义一个 agei 的区要拷贝s_i × 区大小字节把前 N 个区的拷贝量全加起来就是总拷贝量。而前 N 个区的存活率之和正好等于前缀和数组第N-1项一步查表搞定。6.6 为什么存活率要分年龄而不是取个平均数分年龄统计是因为一个区要拷贝多少和它熬过几轮 GC 强相关但相关的方式有两层容易混条件存活率s_i已经从年龄 i 活下来的对象里下一轮又活下来的比例老区的对象基本是长命种所以s_i反而高且稳定接近 1不会继续涨。累计存活比例当初分配的那批对象里有多少一路活到了年龄 i绝大多数对象短命、前几轮就死了能熬到老区的极少所以这个比例低、随年龄递减、很快压平在低位。模型真正要算的是总拷贝字节。年龄大的区虽然单区s_i高但这类区的数量极少累计存活比例低没几个对象走到那拷贝成本的大头来自 age 0/1 的区体积巨大、单区存活率中等。由于累计存活比例很快变平每增加一个区的边际拷贝增量趋于稳定总拷贝量随 N 近似线性增长——这正是前面二分搜索能成立单调、可预测的底层原因。对于超出已记录年龄的槽位代码用最后一个预测值线性外推相当于更老的区存活率已稳定在这个下限值。6.7 串回总公式总暂停 固定底盘 base_timeRSet 扫描由 X 决定与 N 无关 前 N 个区存活率之和 × 区大小 × 每字节拷贝成本随 N 增长 N × 每分区其它开销随 N 增长二分搜索每试一个候选N就用这套公式算总暂停跟预算比。算法无法控制每区存活率它由对象的实际存活情况决定它能控制的只有 N年轻代分区数从而把总拷贝量压在预算内。7. 收敛与 GC 时机一个会自我纠偏的环Young GC 回收整个年轻代。为了把暂停压在用户期望内算法控制年轻代能长到多大就触发 GC目标分区数。年轻代越小 → GC 越频繁 → 每次要回收的分区越少且两次 GC 之间 RSet老→新引用积累也更少 → 单次 GC 时间更短、更稳。算出的_young_list_target_length存好后G1 让年轻代eden持续长大一旦年轻代分区数达到这个目标值就触发一次 young GC把整个年轻代清空。于是形成一个稳定的负反馈环RSet 暴涨X 变大 → 固定底盘 base_time 变大 → 二分能容纳的 N 变小 → 目标年轻代分区数变小 → 下次 GC 来得更早、收得更少 → 两次 GC 之间分配的年轻区更少old→young 引用积累也更少 → X 增长变慢 → 暂停时间被稳稳压在预算内由此可见采样值大并不意味着年轻代要变大采样值大代表 RSet 负担重算法把年轻代目标调小让 GC 更频繁、每次更轻把暂停压在用户期望内。8. 设计哲学小结工作量前移把RSet 实时工作量在两次 GC 之间就反馈成年轻代目标而不是等 GC 发生时才发现年轻代已超预算。固定底盘 线性增量暂停模型清晰拆成与区数无关的扫描底盘和随区数增长的拷贝开销让二分搜索有单调可解的结构。保守预测2σ 防晋升失败、存活率按满区假设、预测值夹紧 ≤1.0宁可估高也不冒险。自适应闭环采样超标 → 上调预测 → 重算目标 → 改变 GC 时机/规模 → 反过来抑制 RSet 暴涨形成维持暂停目标的负反馈环。历史驱动RSet 增长、脏卡堆积、存活率等都用滑动序列平滑预测模型随负载自适应变化。

相关新闻

最新新闻

时空可组合性元框架:构建复杂时空应用的核心架构设计

时空可组合性元框架:构建复杂时空应用的核心架构设计

如果你是一名开发者,最近在构建需要处理复杂时空数据的应用——比如智慧城市、自动驾驶仿真、物联网数据分析,或者游戏世界生成——你很可能正面临一个共同的困境: 代码越写越乱,模块越加越多,但系统却越来越难维护和…

2026/8/16 12:14:20
亦唐科技:引领国产贴片机创新,推动智能制造升级

亦唐科技:引领国产贴片机创新,推动智能制造升级

在中国制造业转型升级的关键时期,贴片机作为电子制造业的重要设备,已经成为提高生产效率和精度的核心工具。随着智能制造和自动化生产的不断推进,国产贴片机在技术和市场份额上不断攀升。亦唐科技(YIKTANG)作为国产贴片…

2026/8/16 12:14:20
系统资源占用异常排查:CPU与内存消失之谜与专业工具指南

系统资源占用异常排查:CPU与内存消失之谜与专业工具指南

1. 问题现象与核心矛盾解析 “任务管理器里CPU和内存占用率都飙红了,但挨个看进程,却发现没有哪个进程的占用高到能撑起这个总量。” 这几乎是每一位运维工程师、开发人员乃至普通电脑用户都曾遇到过的经典“灵异事件”。表面上看,系统资源&a…

2026/8/16 12:14:20
C++ const关键字深度解析:从常量定义到代码安全承诺

C++ const关键字深度解析:从常量定义到代码安全承诺

1. 从“常量”到“承诺”:理解C const的核心价值 在C的世界里, const 这个关键字出现的频率,可能仅次于 int 和 void 。很多刚入门的开发者,包括当年的我,都曾把它简单地理解为“定义一个常量”。比如写个 cons…

2026/8/16 12:14:20
解决IntelliJ IDEA中JVM废弃参数警告的完整指南

解决IntelliJ IDEA中JVM废弃参数警告的完整指南

1. 问题现场:一个熟悉的“老朋友”又来了 如果你用 IntelliJ IDEA 开发 Spring Boot 项目,大概率见过下面这个老朋友。某天,当你满怀期待地点击那个绿色的运行按钮,控制台在项目启动信息之前,赫然打印出一行刺眼的黄色…

2026/8/16 12:14:20
VS Code高效文本编辑:从基础操作到高级技巧,提升开发效率

VS Code高效文本编辑:从基础操作到高级技巧,提升开发效率

1. 项目概述:为什么说文本编辑是“搬砖利器”的核心 干了这么多年开发,我越来越觉得,一个程序员的生产力,很大程度上不是由他掌握了多少高深算法决定的,而是看他能不能把那些最基础、最重复的“搬砖”操作,…

2026/8/16 12:09:19