停顿短不等于延迟低:G1、ZGC 与低延迟服务的目标分野、代价账本与选型标定

目录

一、认真揣摩「换个收集器」

(一)被合并的七个问题

[(二)为什么「低延迟就上 ZGC」不是答案](#(二)为什么「低延迟就上 ZGC」不是答案)

(三)从口径分辨到验证纪律

(四)动手换收集器之前

1、分配速率

2、晋升速率

3、活跃集大小

4、停顿账与处理器账

5、服务等级目标的形状

6、资源余量

二、目标分野:三个互相牵制的量

(一)吞吐、延迟、占用:同一份预算的三种花法

(二)「停顿」这个词至少指四件事

(三)四类负载各自在意什么

三、停顿怎么变成延迟:从一次暂停到一条尾部曲线

(一)排队放大:一次停顿影响的不止那一段时间

(二)并发回收把停顿换成了什么

(三)安全点:日志里没写的那一段

(四)先量占比再谈选型

四、收集器全景:五个实现与它们各自的赌注

(一)串行与并行:把延迟换成吞吐

(二)G1:把停顿目标写进算法

(三)ZGC:把停顿从堆规模里解耦

[(四)Shenandoah 与 Epsilon:两个边界](#(四)Shenandoah 与 Epsilon:两个边界)

(五)哪些阶段停、哪些阶段不停

(六)一张八维对照表

[五、G1 的机制账本:分区、记忆集、写屏障与混合回收](#五、G1 的机制账本:分区、记忆集、写屏障与混合回收)

(一)分区与回收集:停顿目标为什么可兑现

(二)一个完整周期的三个阶段

(三)并发标记与写前屏障

(四)卡表、记忆集与精化线程

(五)巨型对象:半个分区换一条路径

(六)混合回收与它的三个阈值

[六、ZGC 的机制账本:染色指针、读屏障与并发搬迁](#六、ZGC 的机制账本:染色指针、读屏障与并发搬迁)

(一)指针里的那几位

(二)读屏障:让「对象会动」在运行期可修复

(三)三段短停顿各自在做什么

(四)分代之后改掉了什么

(五)压缩指针的缺席

七、代价账本:把两条路线的开销逐项摆出来

(一)屏障成本:写屏障与读屏障各贵在哪

[(二)CPU 账](#(二)CPU 账)

(三)内存账

(四)同一个服务换过去之后的七项对照

八、选型:从服务等级目标、堆规模与资源余量推出结论

(一)四条主判据与它们的优先级

(二)一条可执行的决策路径

(三)六类典型服务的推荐与理由

1、网关与接入层

2、交易主链路

3、大缓存与检索服务

4、离线批处理与数据管道

5、小容器微服务

6、短生命周期任务

(四)三种看起来该换其实不该换的情形

[九、G1 的关键配置:哪些该设、哪些该调、哪些不该碰](#九、G1 的关键配置:哪些该设、哪些该调、哪些不该碰)

(一)先设对的四个参数

(二)停顿目标是目标不是承诺

(三)固定新生代等于关掉自适应

(四)触发点与预留

(五)线程数:容器里最容易设错的一项

(六)不该动的一类参数

[十、ZGC 的关键配置:旋钮很少,但有五件事必须做对](#十、ZGC 的关键配置:旋钮很少,但有五件事必须做对)

(一)堆大小就是主旋钮

(二)软上限与内存归还

(三)并发线程数与分配尖峰容忍

(四)大页、预触碰与绝不交换

(五)分代模式与版本边界

十一、七类现场:从日志一行到第一个动作

(一)先按「谁在等」把现场分成三组

[1、G1 的三类现场](#1、G1 的三类现场)

[1.1 疏散失败与转移空间不足](#1.1 疏散失败与转移空间不足)

[1.2 巨型对象把老年代打碎](#1.2 巨型对象把老年代打碎)

[1.3 混合回收追不上晋升](#1.3 混合回收追不上晋升)

[2、ZGC 的两类现场](#2、ZGC 的两类现场)

[2.1 分配停滞](#2.1 分配停滞)

[2.2 内存不归还与常驻集不降](#2.2 内存不归还与常驻集不降)

3、常被算到收集器头上的两类

[3.1 安全点拖长](#3.1 安全点拖长)

[3.2 容器限流与线程数错配](#3.2 容器限流与线程数错配)

(二)七类现场共用的三个动作

十二、观测:把这些东西看见

(一)八类观测手段与它们各自能回答什么

[(二)从一份 GC 日志能直接算出的五个量](#(二)从一份 GC 日志能直接算出的五个量)

(三)四问分诊

十三、标定:一次可复现的对照实验

(一)实验设计的六个约束

(二)延迟-吞吐曲线:唯一可比的口径

(三)需要采集的十二项指标

(四)五种伪结论

十四、治理:把判断变成默认行为

(一)三层防线与十二条规则

1、十二条可机器检查的规则

[1.1 进构建与静态检查的四条](#1.1 进构建与静态检查的四条)

[1.2 进部署模板校验的四条](#1.2 进部署模板校验的四条)

[1.3 进监控与评审的四条](#1.3 进监控与评审的四条)

2、落地次序与它为什么不能反过来

(二)迁移与回滚的五步

(三)一页纸速查

(四)为什么这类问题长期高发

结语

参考资料


干货分享,感谢您的阅读!

「响应时间不达标,把收集器换成 ZGC」------这句话在很多技术评审里被当成结论,而它其实只是一个假设。

垃圾收集器的选型之所以反复出问题,是因为四件不同的事被压成了一件:单次停顿的长度、停顿之后排队恢复的时间、并发回收吃掉的处理器份额、以及回收跟不上分配时那段没有出现在停顿统计里的等待。前两件决定尾部延迟的形状,后两件决定这套方案在负载升高时会不会反噬。

我们尝试把它拆成三段:先建立一套能把「停顿」翻译成「延迟」的量化口径,再把 G1 与 ZGC 两条路线的机制与代价逐项摆开,最后给出一条能落地的判据链------从服务等级目标、堆规模、活跃集与资源余量推出结论,并用一次可复现的对照实验把结论钉住。

一、认真揣摩「换个收集器」

(一)被合并的七个问题

「这个服务的延迟毛刺是 GC 引起的,换个收集器吧。」这句话听上去像一个方案,实际上把七个性质完全不同的问题合并成了一个印象。分不开这七个问题,讨论就会在「先换了再说」和「先把代码里的垃圾降下来」之间来回摆动,最后往往是前者取胜------因为它只需要改一行启动参数,尽管它把真正的原因留在了原地。

第一问是归因:在这条服务的尾部延迟里,垃圾回收到底占多大比例。若一次请求的 p999 是四百毫秒,其中回收停顿只贡献了六十毫秒,那么把停顿降到亚毫秒的收益上限就是百分之十五,而不是「延迟问题解决了」。归因不做,后面所有的比较都失去分母。

第二问是口径:说的「停顿」指哪一个量。是回收日志里那一行暂停时长,是安全点日志里应用线程被停住的总时长,是线程走到安全点花掉的那一段,还是分配请求被挡在门外的那一段。四个量在同一次事故里可以相差一个数量级,而它们分别对应四种完全不同的修法。

第三问是放大:一次停顿会影响多长时间的请求。在一个利用率六成的服务上,一次六十毫秒的停顿会造成一百五十毫秒左右的受影响窗口,因为停顿期间涌进来的请求需要在恢复之后排队消化。只看停顿时长而不看排队恢复,会系统性低估回收对尾部的贡献。

第四问是代价:并发回收把停顿换成了什么。并发标记、并发搬迁、屏障的执行都要占用处理器时间,这部分开销不出现在停顿统计里,却会抬高系统的有效利用率------而排队延迟对利用率是超线性敏感的。在利用率已经很高的服务上,这笔交换有可能是亏的。

第五问是余量:这套方案需要多少堆余量与多少空闲核心。并发回收的本质是「一边回收一边分配」,它要求回收速度跑得赢分配速度,而这个「跑得赢」是用堆里的空闲空间和空闲的处理器换来的。余量不够时,低停顿的收集器会用另一种形式把延迟还回来。

第六问是版本:这个结论绑定在哪个大版本上。分代模式、区域固定、并行整理、屏障实现、记忆集结构在近几个长期支持版本之间都发生过变化,一个在旧版本上成立的结论,换个版本可能整个倒过来。任何不写版本号的收集器选型建议都不具备可执行性。

第七问是验证:换过去之后怎么知道确实变好了。同一份流量、同一个堆、只改一处参数的对照实验;覆盖多个负载点的延迟-吞吐曲线;跨过一个完整缓存填充周期的观察时长。这三件事缺一件,结论就只是一次运气。

这七问的顺序不能颠倒。归因不做,收益无从估算;口径不定,两个人拿着两张图会得出相反结论;放大不算,尾部账永远算不平;代价不摆,高负载时会翻车;余量不留,低停顿会变成长等待;版本不写,建议无法复用;验证不做,一切都是传说。

(二)为什么「低延迟就上 ZGC」不是答案

这条经验之所以流行,是因为它在某一类场景里确实立竿见影:一个堆开到六十四千兆、活跃集很大、原本每隔几分钟就要停顿几百毫秒的服务,换过去之后停顿曲线会直接压平成一条贴着横轴的线。这个效果是真实的,问题出在它被推广成了普遍规律。它作为一条方法论有三个缺口。

第一个缺口是它把「停顿」当成了延迟的全部。 一次请求的响应时间由排队、执行、等待依赖、序列化、网络往返若干段组成,回收停顿只是其中一段的一部分。当一个服务的尾部延迟主要来自下游依赖抖动、连接池排队、日志同步刷盘或者容器的处理器配额被打满时,把停顿从六十毫秒降到零点二毫秒,在监控图上看不出任何变化。更糟的是,换收集器带来的额外处理器开销会让本来就紧张的配额更紧张,尾部反而变差。所以第一件事永远是归因,而不是选型。

第二个缺口是它默认「并发」是免费的。 并发回收没有消灭工作量,它只是把工作量从「集中停下来做」改成「和业务线程抢着做」。抢的对象是处理器时间和内存带宽。在利用率三成的机器上这笔交换几乎白赚;在利用率八成的机器上,多吃掉一成处理器会把等效利用率推到接近九成,而排队延迟与「一减去利用率」成反比------这一步就可能把省下来的停顿全部吐回去,甚至倒亏。低延迟收集器需要的是有余量的机器,这一点在成本压力下最容易被忽略。

第三个缺口是它忽略了内存这一侧的账。 采用染色指针的实现无法使用压缩引用,这意味着同一份对象图在同样的堆里会占更多字节;并发搬迁需要转发结构、需要额外的堆余量来容纳回收期间新产生的分配;分代实现还要为跨代引用维护记住集。于是一个原本在八千兆堆里跑得舒服的服务,换过去之后可能需要十二千兆才能跑到同样的舒服程度。如果容器规格不跟着改,结果就是回收更频繁、并发线程更忙、然后出现分配停滞------一种不计入停顿统计、但对请求而言就是纯粹卡住的等待。

真正可用的答案是分层的:先量出回收在尾部里的占比,再算出分配速率、晋升速率与活跃集这三个决定回收工作量的量,然后把候选收集器的机制代价逐项摆开,最后在有余量的前提下做一次同流量对照。这四步里没有一步需要高深的知识,难的是把它们当成一套固定动作,而不是四条互不相干的经验。

(三)从口径分辨到验证纪律

把七问展开,一次完整的收集器选型能力实际要覆盖八个能力项。它们互相独立,缺一项都会在某个规模、某次升级或者某个负载高峰上暴露出来。

第一是口径分辨能力:分得清回收停顿、安全点停顿、到达安全点耗时与分配停滞四个量,知道每个量各自出现在哪份日志的哪一行,知道哪个量会被默认配置藏起来。

第二是负载建模能力:会从回收日志反算分配速率、晋升速率与活跃集,会用利用率把停顿换算成受影响的请求数,会判断一个服务是分配密集型还是驻留密集型。

第三是机制理解能力:说得出分区式回收为什么能给出停顿目标,说得出写前屏障与读屏障各自在维持什么不变式,说得出并发搬迁如何做到不让业务线程看到搬走的旧副本。

第四是代价核算能力:算得出屏障的指令开销、并发线程的核心占用、转发与记忆结构的内存占用,以及引用宽度变化带来的堆放大。

第五是判据构造能力:能把服务等级目标、堆规模、活跃集、分配速率、处理器余量与版本这六项组合成一条可执行的判定路径,而不是背一张「大堆用这个、小堆用那个」的表。

第六是配置纪律能力:知道哪些参数必须设、哪些参数是目标而非承诺、哪些参数一旦设上就会关掉自适应、哪些参数在容器里必须显式给。

第七是现场处置能力:看到疏散失败、巨型对象、混合回收追不上、分配停滞、内存不归还这几行日志,能立刻说出第一个动作是什么。

第八是验证纪律能力:设计得出一次同流量对照实验,采得齐十二项指标,画得出延迟-吞吐曲线,并且认得出五种常见的伪结论。

评审与复盘里,真正拉开差距的地方几乎全在第二、第四、第五项上。会说「ZGC 停顿低」的人很多,能说清「这个服务的晋升速率是每秒多少兆、活跃集是多少、换过去之后需要多留几个核和几千兆堆」的人少得多。这三项的共同点是:都需要一次实际测量,而不是一句记住的结论。

(四)动手换收集器之前

关于收集器的争论最容易变成立场之争,因为双方都没有数。下面六个数中的任意一个都能在半天之内估出来,估出来之后争论通常会自动结束,讨论会从「换哪个」转向「先改哪一处」。

1、分配速率

从回收日志里取相邻两次年轻代回收之间的堆占用增量,除以两次之间的时间间隔,得到每秒分配了多少字节。这个数决定了年轻代回收的频率,也决定了并发回收需要跑多快才追得上。每秒几十兆的服务几乎不用为回收操心;每秒几百兆属于常见的在线服务量级;每秒超过一千兆通常意味着热路径上存在可以直接消除的分配------比如日志拼接、无谓的集合复制、每次请求都新建的序列化器。分配速率是唯一一个「降下来之后所有收集器都受益」的量,所以它必须排在第一位。

2、晋升速率

取相邻两次年轻代回收之间老年代占用的增量,除以间隔时间,得到每秒有多少字节从年轻代活着走进了老年代。这个数决定并发标记与混合回收的压力,也是判断「回收器追不追得上」的核心依据。绝大多数分配都应当在年轻代里死掉,晋升速率理想情况下应比分配速率低两个数量级。当晋升速率持续处在每秒几十兆以上时,说明大量对象的存活时间恰好跨过了年轻代的回收间隔------这类对象通常来自请求级的缓存、批量任务的中间结果或者过大的批次,处置办法是改代码或者调整年轻代容量,而不是换收集器。

3、活跃集大小

取一次完整回收之后(或者并发标记周期结束时)的老年代占用,作为活跃集的近似。这个数是所有容量决策的基准:堆的大小应当是活跃集的若干倍,倍数不够时,无论用哪个收集器都会陷入频繁回收。经验区间是两到三倍------低于两倍,回收频率会陡升;高于四倍,多出来的内存对延迟几乎没有额外贡献,只是在推迟问题。活跃集还决定了并发标记一轮要走多少对象,从而决定了并发线程要吃掉多少处理器时间。

4、停顿账与处理器账

从回收日志统计一段时间内的停顿次数、停顿时长分布(不要只看均值,要看分位与最大值)以及累计停顿占运行时长的比例;再从带处理器统计的日志里取回收线程消耗的用户态与内核态时间,除以「墙上时间乘以核数」,得到回收占掉的处理器份额。前一个数告诉你「换收集器最多能省多少」,后一个数告诉你「换过去之后要多付多少」。两个数放在一起,才是一次交换的完整报价。

5、服务等级目标的形状

把延迟目标写成可判定的形式:是「p99 小于五十毫秒」,是「p999 小于一百毫秒」,还是「每天允许不超过若干次超过两百毫秒」。这三种写法对应完全不同的选型。以 p99 为目标时,只要受回收影响的请求比例低于百分之一,回收就完全不进入判定;以 p999 甚至最大值为目标时,任何一次几十毫秒的停顿都会被记账。目标的形状没确定之前,任何选型讨论都缺少判定标准。

6、资源余量

记下容器或机器的核数配额、内存配额,以及日常峰值时的处理器利用率与常驻集占用。这个数决定了并发回收方案是否可行:并发线程需要核心,并发回收需要堆余量。在一个两核、利用率长期在七成以上的容器里讨论低停顿收集器,是在讨论一个没有原料的方案。余量不足时的正确顺序是:先降分配、再扩规格、最后才谈换收集器。

六个数拿到之后,处置顺序几乎是自动的:分配速率异常高,先降分配;晋升速率异常高,先查请求级驻留与批次大小;活跃集接近堆的一半,先扩堆;停顿占比很低但延迟仍差,去查回收之外的原因;停顿占比高且余量充足,这时候换收集器才是收益最高的一步;余量不足,先解决余量。

二、目标分野:三个互相牵制的量

(一)吞吐、延迟、占用:同一份预算的三种花法

垃圾回收领域有一条流传很久的说法:吞吐、延迟、内存占用这三个目标,任何一个实现最多同时优化两个。这句话作为记忆口诀足够了,但它没有解释「为什么」,而不理解为什么,就无法判断一个具体的实现把预算花在了哪里。

三者之间的牵制关系来自同一个根源:回收工作总量在给定的分配行为下基本是恒定的------要遍历的对象图就那么大,要复制的存活对象就那么多。实现能改变的只有三件事:什么时候做 (集中做还是分摊做)、谁来做 (停下业务线程做还是和业务线程并行做)、用多少空间做(是否需要额外的空闲空间来腾挪)。

集中停下来做,单位工作量的成本最低------不需要屏障维持一致性,不需要处理搬迁过程中的并发访问,缓存局部性也最好。代价是这段时间业务完全停摆,停顿长度与工作量成正比,而工作量与存活对象量成正比,于是堆越大、活跃集越大,停顿越长。这是并行整理式实现的选择:把预算全部押在吞吐上。

分摊着做、并行着做,能把单次停顿压到与工作量无关,代价有三笔:一是维持一致性的屏障要插进业务代码的读或写路径,构成一笔固定的吞吐税;二是并发线程要占用处理器时间,在负载高时与业务线程直接竞争;三是回收期间业务还在分配,必须预留空闲空间容纳这些新分配,否则会退化成等待。这是并发式实现的选择:用吞吐和内存换延迟。

省内存的做法则相反:不预留余量、不使用额外的转发结构、不搞多份映射,代价是回收必须更频繁、更彻底,于是要么停顿变长,要么处理器开销变大。嵌入式与函数计算场景常常选这一头。

需要强调的是,这三个目标之间不是简单的此消彼长,而是存在「效率前沿」的概念:一个实现可能在三个方向上都劣于另一个实现(比如参数配错的情况),也可能在某个特定负载形状上同时优于另一个。所以正确的问法不是「哪个收集器最好」,而是「在我这份负载形状、这份资源规格、这个服务等级目标下,哪个实现落在前沿上」。负载形状主要由分配速率、晋升速率与活跃集三个数刻画,这也是上一章要先把它们量出来的原因。

(二)「停顿」这个词至少指四件事

选型讨论里最常见的鸡同鸭讲,是双方说的「停顿」根本不是同一个量。至少有四个量都被叫做停顿,它们的成因、观测入口与修法互不相同。

第一个是回收暂停时长。这是回收日志里每一行末尾那个数字,指回收器在安全点内部实际工作的时间。这个量是收集器实现最直接负责的部分,也是各家宣传材料里比较的对象。

第二个是应用线程被停止的总时长。这是安全点日志里那一行「应用线程被停止的时间」。它等于「所有线程走到安全点的时间」加上「在安全点内工作的时间」加上「唤醒恢复的时间」。第一项与收集器无关,而是与代码形态有关------长的计数循环、大块的本地方法调用、大量线程都会把它拉长。一次「回收只停了三毫秒、应用却停了八十毫秒」的事故,八成出在这一项上,而只看回收日志的人会得出「收集器没问题」的错误结论。

第三个是到达安全点的耗时,也就是上一项里的第一段。它值得单独拿出来,因为它是唯一一个「换收集器完全无效」的停顿来源。诊断它需要打开安全点日志并单独看「到达」这一列。

第四个是分配停滞。当并发回收的速度追不上分配速度、可用空间被耗尽时,请求分配的线程会被挂起,直到回收器腾出空间。这段等待不是安全点,不出现在停顿统计里,但对一次请求而言与停顿没有区别,而且它的长度可以是几十甚至几百毫秒------远超那个宣称亚毫秒的停顿指标。低停顿收集器在资源不足时的典型失效形态就是它。

四个量的关系可以写成一句话:用户感受到的卡顿等于到达安全点的时间加上安全点内工作的时间,再加上完全在安全点之外的分配停滞,最后乘上排队恢复的放大系数。宣传材料里的那个数字只是第二项的一部分。选型时如果只比较这一项,就等于只比较了四项中的一项。

(三)四类负载各自在意什么

同一个收集器在不同负载上的表现差异,往往大于不同收集器在同一负载上的差异。把常见负载归成四类,可以让选型讨论少走很多弯路。

第一类是分配密集、驻留很小的负载,典型是无状态的接口服务与消息消费者。它们每秒产生大量短命对象,但活跃集只有几百兆。这类负载对分代式实现极其友好:年轻代回收的成本与存活对象量成正比,而这里的存活比例极低,于是回收几乎「免费」。这类服务往往用调好的分区式收集器就能拿到很好的尾部,换低停顿实现的边际收益有限。

第二类是驻留密集、分配平缓的负载,典型是进程内大缓存、检索索引、规则引擎。它们的活跃集可能占到堆的一半以上,每次并发标记都要遍历海量对象。这类负载是并行整理式实现的噩梦------一次完整回收要整理几十千兆的存活对象;也是分区式实现的难题------混合回收要一点一点啃老年代,稍有不慎就退化成完整回收。它们是低停顿实现收益最大的场景。

第三类是吞吐优先、延迟不敏感的负载,典型是离线批处理、数据管道、编译与构建任务。这类任务只关心总耗时,停顿多久无所谓。给它们上低停顿收集器是纯粹的浪费:屏障与并发线程会直接吃掉百分之十以上的吞吐,任务变慢,成本上升。

第四类是短生命周期负载,典型是函数计算、命令行工具、单元测试进程。它们跑几秒到几分钟就结束,很多时候一次完整回收都不会发生。这类负载真正在意的是启动时间与内存足迹,最合适的往往是最简单的串行实现,甚至是「不回收」的实现。

多数真实服务不会是纯粹的某一类,但一定有一个主导类型。判断主导类型的最简办法就是看上一章的三个数:分配速率高、活跃集小,是第一类;活跃集大、分配速率中等,是第二类;没有延迟目标,是第三类;进程寿命短于一次完整回收周期,是第四类。

三、停顿怎么变成延迟:从一次暂停到一条尾部曲线

(一)排队放大:一次停顿影响的不止那一段时间

一次持续 D 毫秒的停顿,对请求延迟的影响远不止 D 毫秒。原因很直观:停顿期间到达的请求没有被处理,它们排在队列里;停顿结束之后,系统不但要处理新到的请求,还要把积压的这批消化掉,而消化需要的是「超出常态的处理能力」,也就是原本的空闲部分。

设服务的处理器利用率为 ρ(零到一之间),请求到达速率为 λ。停顿期间积压的请求数约为 λ·D。恢复之后,系统每秒能多消化的请求数约为 λ·(1−ρ)/ρ 的规模,于是排空积压所需的时间约为 D·ρ/(1−ρ)。把停顿本身算进去,一次停顿造成的受影响时间窗约为 D/(1−ρ)。

这个简单的式子有几个非常实用的推论。利用率五成时,受影响窗口是停顿的两倍;七成时是三点三倍;八成时是五倍;九成时是十倍。也就是说,同一个收集器、同一个停顿长度,在高负载机器上对尾部延迟的破坏力要大得多。这解释了一个常见现象:同一个服务在压测时表现良好,一到大促流量就出现大量超时,而回收日志里的停顿长度并没有变化------变的是利用率,是放大系数。

第二个推论是关于受影响请求比例的。一次停顿影响的请求数约为 λ·D/(1−ρ)。若停顿每 T 秒发生一次,则受影响请求的比例约为 D/(T·(1−ρ))。举例:停顿六十毫秒、每两秒一次、利用率六成,比例为 0.06/(2×0.4)=7.5%。这个比例大于百分之一,意味着 p99 分位点直接落在受回收影响的请求集合内部------此时 p99 会被回收主导。反过来,若停顿只有五毫秒、每十秒一次、利用率四成,比例为 0.005/(10×0.6)≈0.08%,远小于百分之一,p99 完全看不到回收的影子,但 p999 会看到。

第三个推论最重要:判断「回收是否是尾部瓶颈」不需要做实验,用回收日志里的停顿长度、停顿间隔与监控里的利用率就能算出来。算出来的比例若远小于目标分位(比如目标是 p99,算出来只有万分之一),那么换收集器不会改善 p99,去找别的原因;比例若与目标分位同量级或更大,回收就是主因之一,值得投入。这一步只需要十分钟,却能省掉一次为期两周的无效迁移。

(二)并发回收把停顿换成了什么

并发回收把「集中停顿」换成了「持续占用」。这笔交换在延迟上的净收益,取决于停顿减少带来的好处,能不能盖过处理器占用抬高利用率带来的坏处。

设并发回收额外占用的处理器份额为 c(相对于总核数)。业务可用的容量从 1 降到 1−c,等效利用率从 ρ 升到 ρ/(1−c)。排队延迟的规模与 1/(1−ρ) 成正比,于是排队部分被放大的倍数为:

java 复制代码
放大倍数 = (1 − ρ) / (1 − ρ/(1−c))

代入几组数看结论。c 取百分之十:ρ=0.3 时放大 1.05 倍,几乎无感;ρ=0.6 时 1.20 倍;ρ=0.7 时 1.35 倍;ρ=0.8 时 1.80 倍;ρ=0.85 时 2.70 倍;ρ=0.88 时 5.40 倍;而 ρ 一旦达到 0.9,等效利用率正好被推到 1,系统直接进入饱和------在接近饱和的区间,多吃一成处理器是灾难性的。这条曲线解释了为什么低停顿收集器在「机器很空」的环境里口碑极好,而在「成本压到极致」的环境里屡屡翻车。

把两部分放在一起,可以写出一个粗略的判定式。原方案下回收对尾部的贡献约为 D/(1−ρ),新方案下这一项几乎归零,但所有请求的排队部分都被放大了 (1−ρ)/(1−ρ/(1−c)) 倍。于是净收益为正的条件,大致是「回收对尾部的贡献比例」要大于「排队放大带来的整体抬升」。在利用率低于六成、回收贡献超过尾部三成的场景里,这个条件通常轻松满足;在利用率超过八成的场景里,往往不满足------正确的动作是先扩容或者先降分配,把利用率压下来,再谈换收集器。

这里还有一个容易被忽略的细节:并发回收占用的不只是处理器周期,还有内存带宽与缓存。并发标记会把大量对象读进缓存,把业务线程的工作集挤出去;并发搬迁会产生大量写。在内存带宽本来就吃紧的机型上,这部分干扰可能比处理器占用更明显,而它在监控上表现为「利用率没变但每请求耗时变长」,很难归因。

(三)安全点:日志里没写的那一段

所有需要「全局一致视图」的操作------包括回收的若干阶段、去优化、偏向撤销、栈转储、类重定义------都要求所有业务线程停在安全点上。安全点的实现方式是在方法返回处、循环回边等位置插入轮询指令,线程执行到轮询点时检查标志位并挂起。

于是一次全局操作的总停顿由三段构成:发起并等待所有线程到达安全点 、在安全点内执行操作 、释放并唤醒线程。收集器只对中间那段负责,而前后两段的耗时与线程数量、线程当前所处的代码形态直接相关。

三类代码形态会显著拉长到达时间。第一类是长的计数循环:以整型为索引的循环,在即时编译之后可能不含轮询点,线程要跑完整个循环才会检查------现代实现用循环分段技术把长循环切成若干段并在段间插入轮询,缓解了这个问题,但在某些形态下仍会退化。第二类是长时间停留在本地方法里的线程:它们对安全点是「已经安全」的,不会阻塞发起,但它们从本地方法返回时会被挡住,若持有关键资源就会传导为业务阻塞;而使用本地关键区的代码会真正阻塞回收的某些操作。第三类是线程数量极多:到达时间与需要停下的线程数近似线性相关,几千个线程的进程里,光是遍历与唤醒就要花掉可观的时间。

实践上的纪律很简单:打开安全点日志,把「到达」与「驻留」两列分开看。若到达时间占了大头,那是代码与线程规模的问题,换收集器一点用都没有;若驻留时间占大头,才轮到收集器。这一步是所有回收相关排查的第一个岔路口,而它经常被跳过------因为默认配置只打开了回收日志,安全点日志需要显式加上。

(四)先量占比再谈选型

把前三节合起来,就得到了这一章唯一的结论:在做任何选型决定之前,先量出回收在尾部延迟里的占比。

具体动作有三种,按成本从低到高排列。第一种是纯算:用回收日志的停顿分布与监控的利用率,按上面的公式算出受影响请求比例,与目标分位比较。十分钟,不需要改任何东西,能排掉一大半的伪需求。第二种是对齐:打开飞行记录器,把回收停顿事件的时间戳与慢请求的时间戳对齐,统计有多少比例的慢请求落在停顿窗口内(记得把窗口按放大系数放宽)。半天,需要采集一次记录文件,结论比纯算更硬。第三种是实验:在预发环境用同一份回放流量跑两组配置,比较尾部曲线。几天,最准,但只在前两种给出「值得做」的信号之后才做。

需要提醒一句:这三种方法都可能给出「回收占比很低」的结论,而这恰恰是最有价值的结论之一。它把注意力从一个改起来很麻烦、风险不小的方向,转回到那些真正的延迟来源上------连接池排队、下游超时设置、序列化开销、日志刷盘、容器配额限流。这些问题的修复成本通常远低于一次收集器迁移。

四、收集器全景:五个实现与它们各自的赌注

(一)串行与并行:把延迟换成吞吐

串行实现是最简单的一个:单线程完成年轻代复制与老年代标记-整理,全程停顿。它的价值不在性能而在成本------没有并发线程、没有屏障开销、数据结构最少、代码路径最短,因此启动最快、内存足迹最小、在单核环境下反而最快。运行时的自动选择逻辑在识别到「非服务器级机器」(核心数很少、内存很小)时会默认选它。它的适用场景是明确的:几百兆的堆、单核或双核的容器、跑几秒钟就退出的进程。在这些场景里给它换成任何并发实现都是负优化。

并行实现把年轻代复制与老年代整理都改成多线程,仍然全程停顿。它的赌注非常清楚:把所有预算压在吞吐上。没有并发标记、没有屏障维持一致性(只有分代所需的卡表写屏障,成本极低)、回收期间可以完全利用所有核心、整理之后堆内存连续、分配就是指针碰撞。在批处理与数据管道这类只看总耗时的负载上,它至今仍是吞吐最高的选择之一,通常比分区式实现快百分之五到十五。它的代价同样清楚:老年代回收的停顿与存活对象量成正比,几十千兆的堆意味着几秒甚至十几秒的停顿。

值得注意的是,并行实现也接受停顿目标参数,并会通过自适应调整年轻代与老年代的比例来靠近它------但这个机制只对年轻代停顿有效,老年代的完整回收无法被拆分,所以它给不出真正的停顿保证。把它用在有延迟目标的在线服务上,本质上是在赌「完整回收不会在业务高峰发生」。

(二)G1:把停顿目标写进算法

分区式实现的核心思想是:既然停顿长度与「这次回收要处理多少存活对象」成正比,那就让「这次回收处理多少」变成一个可以选择的量。做法是把堆切成大量等大的分区,每次回收只挑选其中一部分组成回收集,把回收集里的存活对象疏散到空闲分区,然后整片回收原分区。回收集的规模可以按停顿预算反推,于是「停顿目标」第一次从愿望变成了算法的输入。

它的设计里有四个关键决定。第一,分代仍然保留 ,但不再是物理上连续的两块,而是分区的逻辑角色------一个分区可以是伊甸区、幸存区或老年代,角色随回收动态变化。第二,年轻代回收始终是停顿式的 ,因为疏散复制在停顿内做最简单最快;能给出停顿目标,靠的是控制回收集大小而不是并发。第三,老年代的存活判定通过并发标记完成 ,标记与业务并行,只在开始与结束时有短暂停顿。第四,老年代的回收混在年轻代回收里做,每次带上若干个「垃圾最多」的老年代分区------这就是「垃圾优先」这个名字的来源,也是它兼顾停顿与整理的方式。

这套设计的赌注是:大多数时候,通过选择回收集就能同时满足停顿目标与回收速度。这个赌注在活跃集不太大、晋升速率不太高时成立得很好。当晋升速率超过混合回收的清理速度时,老年代会持续涨,直到没有空闲分区可供疏散------此时会发生疏散失败,进而退化成一次完整回收,而这次完整回收是「所有停顿里最长的那一次」。这是它最重要的失效模式,后面会专门讲。

(三)ZGC:把停顿从堆规模里解耦

低停顿实现走的是另一条路:把标记与搬迁全部改成并发,只保留几个与堆规模无关的短停顿。它的三段短停顿分别用于开始标记、结束标记与开始搬迁,每一段的工作量都被设计成常数级或者只与线程数相关,因此停顿长度与堆大小、活跃集大小基本无关------这正是它敢在几百千兆甚至几千千兆堆上宣称亚毫秒停顿的原因。

要在业务线程还在跑的时候搬迁对象,必须解决一个根本问题:业务线程手里可能握着指向旧地址的引用。解决方案是在引用被使用时做一次检查与修复,也就是读屏障。每次从堆里加载一个引用,屏障会检查这个引用的「颜色」是否与当前回收阶段一致;不一致就走慢路径,完成标记或者转发到新地址,并把修好的值写回原处------这个「自愈」动作保证同一个引用最多只走一次慢路径。

颜色被编码在指针的若干位里,这就是染色指针。它带来两个直接后果:其一,它需要 64 位地址空间中的若干位来放元数据,因此不能使用压缩引用;其二,判断一个引用是否需要修复只需要一次位测试,快路径极短,可以内联进业务代码。

它的赌注是:用吞吐与内存换取延迟的确定性。屏障插在引用加载路径上------引用加载在真实业务代码里远比引用写入频繁,所以这笔吞吐税比写屏障重;并发线程需要核心;并发搬迁需要空闲空间与转发结构。当这些资源充足时,它给出的延迟曲线是其他方案给不出的;当资源不足时,它会以分配停滞的形式把延迟一次性还回来。

(四)Shenandoah 与 Epsilon:两个边界

Shenandoah 与低停顿实现的目标一致:并发标记、并发整理、停顿与堆规模无关。它的路线差异在于不使用染色指针,而是使用引用加载屏障配合转发信息(早期版本在对象头前置一个转发指针,后来改为在需要时用对象头承载转发关系)。这条路线的好处是内存足迹更小、可以正常使用压缩引用,在中等堆规模上比染色指针方案更省内存;代价是屏障的实现更复杂、某些阶段的开销更高。它还提供了不同的并发模式以适配不同负载。需要注意的是它并不出现在所有发行版里------它是社区版本里的实现,某些商业发行版不包含它,这一点在选型时必须先确认构建产物。

Epsilon 是另一个边界:它什么都不回收,堆用满就让进程失败。它存在的意义是提供一个「零回收开销」的基准,用于性能回归测试、极短生命周期任务,以及那些故意把所有分配都控制在可预算范围内的场景。把它列进全景图不是为了让人拿去生产,而是为了说明一件事:回收开销的下界是零,任何实现的开销都应当与这个下界比较,一次基准测试如果不知道零开销时的数字,就无法判断当前的开销是大是小。

(五)哪些阶段停、哪些阶段不停

把三条主要路线的工作阶段按时间画成一条带子,停顿段与并发段用不同方式标出,是理解它们差异最快的方式。并行实现是一整段停顿;分区式实现是「短停顿---长并发标记---短停顿---若干次短停顿疏散」的交替;低停顿实现是「三小段停顿散落在一大片并发工作里」。

这张图最重要的信息不是「谁的黑段少」,而是黑段之外的灰段并不是免费的。分区式实现的并发标记阶段会占用若干个核心,低停顿实现的并发标记与并发搬迁阶段会占用更多核心并且持续更久。把灰段也画出来,才能看清「停顿减少」的对价是什么。

(六)一张八维对照表

把五个实现按八个维度并排,可以作为选型讨论的共同基准。八个维度分别是:停顿与堆规模的关系、单次停顿的典型量级、吞吐相对损失、额外的处理器需求、额外的内存需求、是否整理堆、是否支持压缩引用、以及适用的堆规模区间。

看这张表时要守住两条纪律。第一,所有数字都是量级示意,不能直接引用为承诺 :吞吐损失在不同负载上可以相差三倍,停顿量级在不同堆规模与配置下同样如此。第二,表格只用于缩小候选范围,不用于做最终决定;最终决定必须来自自己环境里的一次对照实验。表格能做的是把五个候选缩成两个,省下的是实验成本,不是实验本身。

五、G1 的机制账本:分区、记忆集、写屏障与混合回收

(一)分区与回收集:停顿目标为什么可兑现

堆被切成若干个等大的分区,分区大小是二次幂,按「让分区总数落在两千个上下」的原则从堆大小推导,并被限制在一个上下界之内(下界通常是一兆,上界随版本变化)。分区大小可以显式指定,但绝大多数情况下不该指定------它会同时影响巨型对象阈值、记忆集粒度与回收集的挑选粒度,改动的连锁反应很难预估。

每个分区在任一时刻扮演一个角色:空闲、伊甸、幸存、老年代、巨型。角色不是固定的,一个分区这轮是伊甸区,被回收之后变空闲,下轮可能被用作老年代的疏散目标。这种「角色可变」是它与传统固定分代布局最本质的区别,也是它能自适应调整年轻代大小的前提。

一次回收的流程是:挑选一组分区组成回收集,在停顿内扫描根与记忆集找出回收集内的存活对象,把它们复制到空闲分区,更新所有指向它们的引用,然后把回收集里的原分区整片标为空闲。这里的关键是成本模型:这次停顿的时长约等于「根扫描时间 + 记忆集扫描时间 + 存活字节数 ÷ 复制带宽 + 固定开销」。其中存活字节数是可控的------挑更少的分区、挑存活率更低的分区,就能压低停顿。运行时会持续记录每个分区的历史存活率与每次回收的实际耗时,用这些数据预测「再加一个分区会多花多少时间」,从而在停顿预算内尽量多回收。

这套机制解释了几个常见疑问。为什么停顿目标设小了反而回收更频繁?因为每次能回收的分区变少,而分配速率不变,只能提高频率。为什么停顿目标设得太小会失效?因为「根扫描 + 固定开销」有下界,回收集再小也降不到目标以下,同时频率飙升会让处理器开销与总停顿占比恶化。为什么年轻代大小会自己变?因为它正是运行时用来逼近停顿目标的主要旋钮。

(二)一个完整周期的三个阶段

分区式实现的运行是三类回收的交替,理解它们的触发条件就理解了大部分行为。

年轻代回收在伊甸区被填满时触发,回收集只包含年轻代分区。存活对象复制到幸存区,年龄增长;年龄超过阈值或者幸存区放不下时,晋升到老年代分区。这类回收最频繁、最便宜,是稳态下的主旋律。

并发标记周期 在老年代占用超过触发阈值时启动。它由若干阶段组成:一次短停顿完成初始标记(通常搭在一次年轻代回收上),然后是并发的根区扫描与并发标记,接着是一次停顿的最终标记(处理标记期间的变更与引用处理),最后是一次短停顿的清理(统计每个分区的存活量、回收全空的分区)。这个周期不直接回收老年代的部分存活分区,它的产出是每个老年代分区的存活比例排名------为下一步做准备。

混合回收紧接着并发标记周期发生。它的回收集在年轻代分区之外,额外带上若干个存活率最低的老年代分区。混合回收会连续进行若干次,逐步把「值得回收」的老年代分区清干净,然后回到普通年轻代回收,等待下一次触发。

除了这三类,还有两个必须知道的兜底路径。一是疏散失败 :疏散时找不到足够的空闲分区容纳存活对象,此时会就地保留部分对象并进入代价高昂的修复流程,严重时直接退化。二是完整回收:单线程或多线程的全堆标记-整理,停顿最长,是所有其他机制都失败之后的最后一道防线。稳态运行的服务里,完整回收的次数应当是零;出现一次就应当当作事故来查。

(三)并发标记与写前屏障

并发标记要解决的问题是:标记正在进行时,业务线程还在改引用,如何保证「标记开始时活着的对象不会被漏标」。采用的方案是开始时快照:以并发标记开始那一刻的对象图为准,那一刻可达的对象在本轮一律视为存活,即使标记过程中它变得不可达(这部分「浮动垃圾」留到下一轮回收)。

实现方式是写前屏障:业务线程在把某个引用字段从旧值改成新值之前,先把旧值记录到一个队列里,标记线程后续会把这些旧值当作根继续遍历。这样一来,任何「原本从 A 能走到 B,中途把 A→B 这条边删掉」的情况,B 都因为旧值被记录而不会被漏标。

这条屏障的代价需要说清楚:它插在引用写入路径上,而不是读取路径上。真实业务代码里引用写入远少于引用读取,所以这笔税相对便宜------这也是分区式实现吞吐损失小于染色指针方案的主要原因。但它不是零成本:屏障包含条件判断、可能的队列写入与队列满时的处理,在引用写入密集的代码(比如大量构建对象图、大批量集合填充)里会明显体现出来。

浮动垃圾这件事值得单独说一句:它意味着并发标记的结果总是偏保守------被判定为存活的对象里,有一部分在标记结束时其实已经死了。保守是安全的(不会误回收),代价是回收效率下降,也意味着「堆占用曲线在并发标记期间会继续上涨」是正常现象,不该被当成泄漏。

(四)卡表、记忆集与精化线程

只回收堆的一部分,就必须回答一个问题:回收集之外指进回收集的引用怎么找到。全堆扫描是不可接受的,因为那样停顿就又与堆规模相关了。解决方案是为每个分区维护一份「谁指向我」的记录,也就是记忆集。

记录的产生依赖写后屏障:业务线程写入一个引用之后,屏障判断「写入位置」与「被写入的引用目标」是否跨分区,若跨分区就把写入位置所在的卡(堆被划分成固定大小的卡片,通常是几百字节一张)标记为脏。脏卡先进入一个本地缓冲,缓冲满了交给精化线程;精化线程扫描脏卡,把跨分区引用登记到目标分区的记忆集里。这样在回收时,只需要扫描回收集内各分区的记忆集,就能找齐外部引用。

这套机制的账要分三笔算。第一笔是吞吐 :写后屏障比写前屏障更常执行(每次引用写入都要判断),构成分区式实现的主要吞吐开销之一。第二笔是内存 :记忆集本身要占内存,在跨分区引用密集的对象图上(典型是一个大数组指向散布在各处的对象)可能膨胀到堆的百分之几甚至更多;较新的版本对这块结构做过重写,占用明显下降,但它仍然是一笔必须计入的账。第三笔是处理器:精化线程持续在后台处理脏卡,跨分区写入越多,它越忙。

由此可以推出一条设计层面的建议:减少跨分区的引用写入,对分区式实现是直接的性能优化。具体表现为:少用「长寿对象持有大量短命对象」的结构、批量填充集合时尽量一次性构造、缓存的值对象尽量自包含而不是持有一大堆散落引用。这条建议在换成染色指针方案之后部分失效(那边的记账方式不同),但在分代实现里依然成立。

(五)巨型对象:半个分区换一条路径

一个对象只要超过分区大小的一半,就会被当作巨型对象处理:它不走普通分配路径,而是直接在老年代占用一个或多个连续分区,最后一个分区剩下的空间不再分配给别人。

这条规则带来三个后果。第一是空间浪费 :一个略超过半个分区的对象会占满一整个分区,浪费接近一半;一个略超过一个分区的对象要占两个分区。第二是连续性要求 :巨型对象需要连续的空闲分区,堆碎片化之后可能明明总空闲够、却找不到连续的一片,从而触发并发标记甚至完整回收。第三是回收时机不同:巨型分区不参与普通的疏散,只在特定条件下被回收(无人引用时可以在年轻代回收里被快速回收,否则要等并发标记)。

哪些对象会成为巨型对象?在默认分区大小下,主要是大数组:大块的字节数组(文件读取、序列化缓冲、压缩缓冲)、大容量的哈希表底层数组、超长字符串。一个常见的现场是:某个接口偶尔返回几十兆的结果集,序列化时申请一块大缓冲,于是每次调用都产生一个巨型对象,老年代被打成筛子。

处置办法按优先级排列:首选是改代码 ,把大缓冲改成分片处理或者复用池化缓冲,把大结果集改成流式返回;其次是改分区大小 ,把分区调大让原本的巨型对象变成普通对象(代价是回收集粒度变粗、停顿控制变差);最后才是加堆,给碎片留出更多余量。三条里第一条的收益远大于后两条,因为它同时改善了分配速率与晋升速率。

(六)混合回收与它的三个阈值

混合回收决定了老年代能不能被持续清理,它受三个阈值控制,理解这三个阈值就理解了「为什么老年代一直涨」这类问题。

第一个是触发阈值:老年代占用达到堆的某个比例时启动并发标记。默认值是一个中间偏低的比例,并且运行时会根据历史数据自适应调整------它会预测「从现在开始标记,到标记完成时老年代会涨到多少」,从而尽量早地启动以避免来不及。自适应可以关掉并手工指定,但除非有明确证据,否则不该关。这个阈值定得太高的典型症状是:并发标记刚开始,老年代就已经满了,直接退化。

第二个是存活率阈值 :只有存活率低于某个百分比的老年代分区才会被放进混合回收的回收集。这个设定是为了性价比------疏散一个存活率九成的分区,要复制九成的数据却只回收一成空间,不划算。副作用是:如果老年代里的分区普遍存活率很高(典型是大缓存),那么几乎没有分区符合条件,混合回收等于不做事,老年代只涨不落,最终走向完整回收。这是大缓存服务在分区式实现上最典型的失效路径。

第三个是可容忍浪费比例:当可回收空间已经低于堆的某个百分比时,就停止继续做混合回收,因为收益太小。它决定了「清理到什么程度就收手」。

三个阈值之外还有一个分摊参数:混合回收的目标次数,也就是把老年代候选分区分几次清完。次数多则每次停顿短、总耗时长;次数少则相反。

把这一节的内容压成一句可操作的判断:如果老年代分区的普遍存活率高于存活率阈值,分区式实现在这个负载上就是结构性不合适的------不是参数没调好,而是它的性价比模型在这份负载上不成立。这时候要么把缓存移到堆外,要么换到不依赖「挑低存活率分区」的实现上去。这是本文里为数不多的、可以在做实验之前就下的结论。

六、ZGC 的机制账本:染色指针、读屏障与并发搬迁

(一)指针里的那几位

64 位地址空间远远大于任何真实堆能用到的范围,于是指针的高位有大量空闲。染色指针的思路是把回收器需要的元数据直接编码进引用本身:引用不再是一个纯粹的地址,而是「若干位颜色 + 地址」的组合。颜色表示这个引用当前处于哪种状态------是否已在本轮被标记、是否指向已被搬迁的对象、属于哪一代等等。

这样做的价值在于:判断一个引用是否需要处理,只需要一次位运算,不需要访问对象、不需要查表。对比之下,把状态存在对象头里就必须先解引用,而解引用本身可能就是不安全的(对象也许已经被搬走了)。把状态放进引用,才能在「还没碰对象之前」就做出判断,这正是并发搬迁能够成立的技术基础。

代价有三笔。第一笔是地址空间的使用方式变复杂 :早期实现用多重映射,把同一块物理内存映射到多个虚拟地址视图,不同颜色对应不同视图,于是进程的虚拟内存看起来是堆的好几倍(这只是虚拟地址,不是真实物理占用,但会把不了解的人吓一跳,也会与某些内存统计工具冲突);分代实现改掉了多重映射并重新安排了颜色位的位置与含义。第二笔是不能使用压缩引用 ------颜色位要占地方,引用必须是完整的 64 位。第三笔是对某些操作系统与硬件平台有额外要求,尤其是地址位数与大页配置。

需要提醒的是,具体有多少位颜色、颜色位落在哪个位段、每一位的含义,在不同版本之间发生过变化,尤其是引入分代之后有较大调整。理解机制时不必记住位序,需要记住的是三条性质:颜色在引用里、判定只需一次位测试、修复由屏障就地完成并自愈。

(二)读屏障:让「对象会动」在运行期可修复

并发搬迁的核心难题是:搬迁进行到一半时,业务线程手里的引用有的指向旧地址、有的指向新地址,如何保证它们看到的是同一个对象、并且不会往一个即将作废的副本里写数据。

解决方案是在引用被加载时拦截 。业务代码里每一次「从堆里读出一个引用」的操作,编译器都会在后面插入一小段检查:取出颜色位,与当前的期望颜色比较,一致则直接使用(快路径,通常是一两条指令);不一致则进入慢路径,根据当前阶段执行相应动作------可能是把对象标记为存活并加入标记队列,也可能是查转发表拿到新地址(若对象尚未被搬迁,则由这个线程亲自把它搬过去)。慢路径结束时,屏障会把修好的引用写回它被读出来的那个位置,这就是自愈:同一个字段第二次被读时就走快路径了。

自愈这个设计非常关键,它保证了慢路径的总执行次数有上界------大致等于「堆中指向被搬迁对象的引用数量」,而不是「引用被读取的次数」。因此搬迁刚开始时慢路径较多,随着自愈推进迅速衰减。

这条屏障的成本要和写前屏障做对比来看。它插在引用加载路径上,而引用加载在真实代码里极其频繁------遍历一个链表、访问一个字段、调用一个虚方法,都伴随引用加载。虽然快路径只有一两条指令,但乘上巨大的执行次数,累计开销明显高于插在写入路径上的屏障。这是低停顿实现吞吐损失较大的根本原因,也是它无法通过调参消除的固有成本。分代实现引入之后,还需要在引用写入时维护跨代记录,于是写入侧也有了屏障,成本结构变得更复杂。

(三)三段短停顿各自在做什么

在一片并发工作里,仍然保留了几段短停顿,理解它们在做什么,就能理解「为什么这几段与堆规模无关」。

第一段是标记开始:切换全局颜色状态,让之后加载的引用都被视为「需要处理」;同时安装栈水位标记,为后续的并发栈扫描做准备。这一段的工作量只与线程数相关,不遍历任何对象。

第二段是标记结束:确认标记工作已经收敛,处理终结与引用队列相关的收尾,切换到下一阶段。这一段需要检查各线程的标记队列是否清空,若发现还有残留会退回并发标记继续------所以它也不会因为对象多而变长。

第三段是搬迁开始:选定要疏散的分页集合,切换颜色状态,处理根引用。早期版本这里要扫描全部线程栈,停顿与线程数强相关;引入并发栈扫描之后,栈的处理被移出停顿,这一段才真正降到亚毫秒级别。

三段之外的所有工作------并发标记、并发引用处理、并发重定位集选择、并发搬迁、并发重映射------都与业务线程并行。停顿与堆规模无关,是因为「与规模相关的工作」全部被移出了停顿,而不是因为这些工作消失了。它们仍然要做,只是改由并发线程在业务运行期间做,这笔账落在处理器占用与吞吐上。

(四)分代之后改掉了什么

早期的低停顿实现是不分代的:每一轮回收都要遍历整个堆的存活对象。这个设计简化了实现,但违背了「绝大多数对象很快死掉」这条最基本的经验------它意味着每一轮都要为那些明明马上就要死的对象付出完整的标记成本。后果是:在分配速率高的负载上,回收周期必须开得很密,处理器占用居高不下,而且很容易出现「回收追不上分配」。

引入分代之后,年轻代与老年代分别有自己的标记与搬迁周期:年轻代周期频繁但廉价(只遍历年轻代),老年代周期稀疏(只在需要时启动)。带来的变化可以概括成四点:处理器占用显著下降 ,因为不再反复遍历长寿对象;堆余量需求下降 ,因为回收更及时;高分配速率下的稳定性大幅提升 ,分配停滞出现的门槛提高;新增了跨代引用的记账成本,需要在引用写入时维护记录,因此多了写屏障。

版本边界在这里非常重要,因为它直接决定选型结论:分代模式最初以可选开关的形式提供,需要显式打开;随后成为默认模式;再往后,非分代模式被移除。这意味着同一个「用不用低停顿实现」的问题,在三个相邻的大版本上答案可能不同------在只有非分代实现的版本上,高分配速率服务大概率不适合;在分代成为默认的版本上,同样的服务可能非常合适。任何选型建议都必须写清版本,任何跨版本升级都必须重新做一次标定。

(五)压缩指针的缺席

这是一笔在选型讨论里经常被整个忽略的账。在 64 位环境下,当堆小于某个阈值(通常在三十二千兆附近,具体取决于对齐设置)时,运行时会用压缩引用:对象引用在堆中以四字节存储,通过基址与位移解码成完整地址。这项优化能显著降低对象图的内存占用------引用密集的结构(哈希表、树、链表、大量小对象组成的图)里,引用可能占到总字节数的三到四成。

染色指针方案不能使用压缩引用。这意味着把一个原本使用压缩引用的服务换过去,同样的对象图会占用更多字节。放大幅度取决于对象图的形状:引用稀疏的结构(大数组、大字符串、以基本类型字段为主的对象)几乎不受影响;引用密集的结构可能膨胀百分之十五到三十。类指针的压缩也一并失效,每个对象的固定开销随之上升。

这笔账要落到两个具体动作上。第一,容量必须重算 :换过去之前,用同一份数据在两种配置下各跑一次并比较活跃集大小,而不是假定不变;一般要为堆预留额外的百分之十五到三十,再叠加并发回收所需的余量。第二,三十二千兆这条线的意义反过来了:在使用压缩引用的方案里,把堆从三十一千兆调到三十三千兆会让可用容量不升反降;而在染色指针方案里没有这条坎,堆可以线性放大。所以在「堆需要跨过三十二千兆」这个场景下,换过去反而可能同时改善延迟与容量------这是少数几个明确有利的信号之一。

七、代价账本:把两条路线的开销逐项摆出来

(一)屏障成本:写屏障与读屏障各贵在哪

屏障是并发回收的税基,理解它的成本结构,就理解了各方案吞吐差异的来源。

写前屏障(维持标记快照)插在引用字段赋值之前:判断标记是否处于活跃阶段,若是则读出旧值、判空、写入本地队列。它只在引用写入时执行。

写后屏障(维护跨分区记录)插在引用字段赋值之后:判断新旧地址是否跨越分区边界,若是则把对应的卡标脏并可能入队。它同样只在引用写入时执行,但判断逻辑比写前屏障多。

读屏障(支持并发搬迁)插在引用加载之后:取颜色位做一次比较,快路径直接放行。单次成本比写屏障低,但执行次数高出一到两个数量级。

三者的净效果可以这样理解:分区式实现把税收在「改结构」上,染色指针实现把税收在「看结构」上。一段以遍历和读取为主的代码(典型是缓存查询、序列化、模板渲染)在后者上的损失更大;一段以构造和写入为主的代码(典型是批量导入、对象图组装)在前者上的损失更明显。这也解释了为什么同一个基准在不同项目里给出的吞吐差异范围很宽------从百分之三到百分之二十都有人报告过,差别主要来自代码形态。

顺带说明一个常见误解:屏障不是「每次访问都要走一遍完整逻辑」。即时编译器会把快路径内联进业务代码,把慢路径放到冷区;分支预测器在稳态下几乎总是预测正确。所以真实开销远低于「按指令条数直接相乘」的估算,但也确实不为零,且无法通过配置关掉------它是实现的一部分。

(二)CPU 账

并发回收需要专门的线程,它们与业务线程竞争核心。这笔账有三个组成部分。

第一部分是并发线程数。默认值由运行时按可用核心数推导,通常是核心数的若干分之一。核心数少时,默认值可能只有一两个,回收能力不足;核心数多时,默认值可能偏大,在业务高峰抢占过多。在容器里,「可用核心数」的推导是否正确取决于容器支持是否生效------若运行时看到的是宿主机的核心数而非配额,推导出的线程数会严重偏大,这是容器环境里最典型的错配。

第二部分是并发工作的持续时间占比。同样是四个并发线程,如果它们百分之十的时间在跑,与百分之八十的时间在跑,对业务的影响完全不同。这个占比由「分配速率与活跃集决定的工作量」除以「回收能力」得到,也是判断余量是否充足的核心指标。

第三部分是停顿期间的并行线程数。停顿内的疏散、标记收尾等工作由并行线程完成,线程越多停顿越短,但如果核心数不足,多开线程反而因为调度竞争而变慢。这个参数同样默认按核心数推导,同样会在容器里错配。

一条实用的估算法:把回收日志里的处理器时间统计(用户态加内核态)累加,除以「墙上时间 × 核心配额」,得到回收占掉的处理器份额。分区式实现在正常负载下通常在百分之二到八;染色指针实现在同样负载下通常高出一档。这个数字若超过百分之十五,说明要么分配速率过高,要么堆余量不足导致回收过于频繁------两者都不是靠调线程数能解决的。

(三)内存账

内存这一侧有四笔账,选型时必须一起算。

第一笔是堆余量。并发回收期间业务仍在分配,必须有足够的空闲空间容纳这些新分配,否则回收还没完成空间就用光了。所需余量与「分配速率 × 一轮回收耗时」成正比。这就是为什么低停顿实现的经验法则是「堆要开到活跃集的三倍以上」,比停顿式实现更宽松的要求。

第二笔是回收器自身的数据结构。跨分区/跨代记录、标记位图、标记栈、转发表都要占内存。转发表在搬迁期间按被搬迁对象数量分配,搬迁完成后释放,属于峰值占用;记录结构则是常驻的。这部分通常占堆的百分之几,但在引用密集或跨代写入密集的负载上会更高。

第三笔是引用宽度。前一章已经讲过:压缩引用失效带来的放大,在引用密集的对象图上可达百分之三十。

第四笔是常驻集与归还行为 。低停顿实现默认会把长期不用的内存归还给操作系统,归还延迟可配;停顿式实现在默认配置下更倾向于「占住不放」。这决定了容器内存监控上看到的曲线形状,也决定了「同一台机器上能塞几个实例」。需要注意的是:归还本身也有代价,归还之后再次使用需要重新触碰页面,会带来缺页开销;在延迟极敏感的服务上,通常反而要关掉归还并预先触碰全部堆页。

(四)同一个服务换过去之后的七项对照

把上面几笔账落到一个具体例子上,比抽象讨论更清楚。设一个在线服务:十六千兆堆、活跃集四千兆、分配速率每秒四百兆、晋升速率每秒十五兆、八核容器、峰值利用率六成、目标是 p999 小于八十毫秒。从分区式实现换到分代染色指针实现,七项指标的典型变化方向是:

停顿分位 :从「p99 停顿三十毫秒、最大一百八十毫秒」降到「几乎全部低于一毫秒」------这是唯一大幅改善的一项,也是换过去的全部理由。服务端 p999 :改善幅度取决于回收原本在尾部里的占比,若占六成则大约改善五成(扣掉排队放大的抬升)。吞吐 :下降百分之五到十五,表现为同样的处理器配额下最大承载量减少。处理器占用 :上升,回收份额从百分之四左右升到百分之八到十二。堆需求 :上升,十六千兆可能需要调到二十到二十四千兆才能维持同样的回收频率与余量。常驻集 :上升,且曲线形状变化(有归还行为时会看到锯齿)。启动与预热:略慢,屏障代码更多,即时编译需要更多时间收敛。

这张对照表的用法不是「照抄结论」,而是提醒每一项都要在自己的环境里测一次。七项里只要有一项超出预算(最常见的是堆需求与处理器占用),这次迁移就不该做,或者应该先解决预算问题再做。把七项一次性摆到评审桌上,比只拿一张停顿曲线去说服人,成功率高得多,事后翻车的概率也低得多。

八、选型:从服务等级目标、堆规模与资源余量推出结论

(一)四条主判据与它们的优先级

选型不该从「哪个更先进」开始,而该从四条判据依次过滤开始。四条判据有严格的优先级,前一条不满足时后面几条无需讨论。

第一条是延迟目标是否存在。没有延迟目标的负载(批处理、离线计算、构建任务)直接选吞吐最优的实现,讨论结束。有延迟目标但目标很宽松(比如 p99 小于一秒)的服务,同样不需要低停顿实现。这一条能过滤掉相当比例的伪需求。

第二条是资源余量是否足够。核心配额少于四个、或者峰值利用率长期高于百分之七十五的容器,不具备上并发回收方案的条件------并发线程抢不到核,反而会让尾部更差。这一条不满足时,正确的动作是先扩容或者先降分配速率,而不是换收集器。同样地,如果内存配额没有余地再加百分之二十到五十,也不具备条件。

第三条是负载形状。活跃集占堆比例高、老年代分区普遍存活率高的负载(大缓存、大索引),分区式实现的混合回收在结构上就吃力,这类负载换过去收益最大。反之,活跃集很小、分配几乎全部在年轻代死掉的负载,分区式实现调好之后停顿本来就短,换过去收益有限而代价照付。

第四条是堆规模。堆小于四千兆时,分区式实现的停顿通常已经在十毫秒量级,换过去的绝对收益很小,而压缩引用失效带来的相对代价很大------这是最不划算的区间。堆在四到三十二千兆之间是灰色地带,要靠前三条判据决定。堆超过三十二千兆时,压缩引用本来就失效了,代价的一大块自动消失,而停顿式与分区式实现的停顿会随规模上升,这时候低停顿实现的优势最明显。

四条判据的顺序不能换。先看有没有目标,再看有没有资源,然后看负载形状,最后才看规模。经常出错的是把第四条提到最前面,变成「大堆就上低停顿」------大堆但没有延迟目标的批处理服务换过去,只会让任务变慢。

(二)一条可执行的决策路径

把四条判据串成一条判定路径,就得到一张可以贴在评审文档里的图。路径上的每个分叉都对应一个能用数据回答的问题,而不是一个需要经验判断的问题------这是它可执行的原因。

路径之外还有三条横向规则,任何分支都适用。规则一:先降分配再谈选型 。每秒一千兆以上的分配速率,几乎总能通过消除日志拼接、复用缓冲、避免无谓复制降下来一半,而这一半对所有实现都是净收益。规则二:结论绑定版本 。写建议时必须写清楚运行时的大版本与具体构建,因为分代模式、并行整理、区域固定等能力的可用性随版本变化。规则三:留一条回滚路径。收集器选择只是一行启动参数,回滚成本应当被设计成「改一行、重启一批」,而不是「回退一个发布」。

(三)六类典型服务的推荐与理由

判定路径给出的是通用方法,落到常见服务形态上可以直接给出推荐。以下推荐都以「有余量的现代长期支持版本」为前提。

1、网关与接入层

特征是分配密集、活跃集小、连接数多、对尾部极其敏感。推荐分区式实现配合较小的停顿目标与充足的年轻代------这类负载的对象几乎全部在年轻代死掉,年轻代回收本身就很便宜。真正需要关注的不是收集器,而是线程数与安全点到达时间(连接多意味着线程多),以及直接内存的使用(网络缓冲通常在堆外)。只有当单实例堆被迫开得很大、或者出现明显的年轻代停顿毛刺时,才考虑换低停顿实现。

2、交易主链路

特征是延迟目标严格、活跃集中等、有明确的容量规划与资源余量。这是最需要认真做标定的一类。建议先把分区式实现调到位(停顿目标、年轻代下限、触发阈值、并行度),量出回收在尾部里的占比;若占比高于三成且余量充足,再迁移到低停顿实现,并按前一章的七项做完整对照。这类服务通常还有一个隐性要求:结果的可预测性比平均值更重要,因此低停顿实现「停顿与规模无关」这条性质本身就有价值,即使平均延迟没有改善。

3、大缓存与检索服务

特征是活跃集巨大、老年代存活率高、分配速率中等。这是低停顿实现收益最明确的一类,因为分区式实现的混合回收在这种存活率下几乎挑不出候选分区。迁移前必须先算清内存账:压缩引用失效叠加堆余量需求,容量可能要加百分之三十到五十。另一条同样值得评估的路是把缓存移到堆外------用堆外结构承载大块数据,让堆回到几千兆的量级,这时候分区式实现完全够用,总成本可能更低。两条路都要摆到桌上比较,不要默认只有换收集器一条。

4、离线批处理与数据管道

特征是只看总耗时、没有延迟目标、往往独占资源。推荐吞吐最优的并行实现,并把堆开大、年轻代开大,减少回收次数。给这类任务上低停顿实现是典型的误配:屏障与并发线程直接吃掉可观的吞吐,任务时长上升,集群成本上升,而换来的低停顿没有任何人消费。

5、小容器微服务

特征是一到两核、几百兆到两千兆堆、实例数量多。推荐串行或分区式实现(按核心数与堆大小决定),并把重点放在启动时间与内存足迹上。在这种规格下,并发回收方案的并发线程与业务线程会直接抢同一个核,尾部反而更差;同时压缩引用失效对小堆的相对影响最大。这一类是「明确不该换」的典型。

6、短生命周期任务

特征是进程存活几秒到几分钟、可能一次完整回收都不会发生。推荐串行实现,把启动开销压到最低;若任务的内存用量完全可预算,甚至可以用「不回收」的实现作为极限优化,前提是必须有严格的用量上界并做好失败处理。这类场景真正的优化方向是启动与类加载,而不是回收算法。

(四)三种看起来该换其实不该换的情形

有三种现场很容易被误判成「该换收集器」,值得单独指出。

第一种是完整回收频发。日志里每隔几分钟就有一次几秒的完整回收,看上去是收集器扛不住。但完整回收的成因往往是堆太小、活跃集接近堆容量、或者存在泄漏------这三种原因换任何收集器都不会消失,换过去只会把「长停顿」变成「持续的分配停滞」,现象更难诊断。正确顺序是先确认活跃集与堆的比例,再确认有没有泄漏。

第二种是停顿曲线有偶发尖刺。偶尔一次几百毫秒的停顿,而平时都很短。这类尖刺的成因经常不在回收算法本身,而在到达安全点耗时、引用处理(大量弱引用或终结对象)、巨型对象分配、或者宿主机层面的抖动(内存回收、页面交换、邻居干扰)。换收集器对前三者部分有效、对最后一项完全无效,而诊断成本远低于迁移成本。

第三种是「别人换了效果很好」。别人的负载形状、堆规模、资源余量、版本与你的都不同,尤其是资源余量------很多成功案例的前提是机器很空。把别人的结论直接搬过来而不做标定,是这类迁移失败最常见的起点。可迁移的是方法(算占比、摆代价、做对照),不是结论。

九、G1 的关键配置:哪些该设、哪些该调、哪些不该碰

(一)先设对的四个参数

在讨论调优之前,有四个参数必须先设对,它们与性能无关,与「出问题时能不能查」有关。

第一是堆的上下限相等 。把初始堆与最大堆设成同一个值,避免运行期堆容量反复伸缩带来的额外回收与内存抖动;在容器里则用「按容器内存百分比」的参数来推导,避免写死数值与容器规格脱节。第二是回收日志 。用统一日志把回收、堆、处理器统计、自适应决策与安全点一起打开,写到独立文件并配置轮转。日志开销极小,而没有日志时任何回收问题都无法诊断。第三是失败时的现场 。打开内存溢出时自动转储并指定路径,确保路径所在卷有足够空间。第四是堆外上限。显式设置直接内存上限与元空间上限,避免它们无声增长直到进程被外部终止。

这四项是「不设就一定会后悔」的一类,与选哪个收集器无关。

(二)停顿目标是目标不是承诺

停顿目标参数是分区式实现最有名的旋钮,也是被误解最深的一个。它的语义是「请尽量让停顿不超过这个值」,实现方式是按预测模型缩减回收集规模------它是一个软目标,不是保证。

三件事必须清楚。第一,目标设小会提高回收频率 。回收集小了,每次回收的空间少了,而分配速率不变,只能更频繁地回收。频率上升会让总停顿占比与处理器开销一起上升,超过某个点之后尾部反而变差。第二,存在无法突破的下界 。根扫描、记忆集扫描与固定开销加起来构成停顿的底,把目标设到远低于这个底(比如几毫秒)不会让停顿变小,只会让年轻代被压缩到极小、晋升速率飙升、并发标记与混合回收压力剧增,最终以疏散失败或完整回收的形式爆发。第三,默认值往往是合理的起点。默认值是一个折中,多数在线服务从默认值开始、按实测尾部小幅调整即可;一次把目标从两百毫秒改到十毫秒的做法,几乎总是有害的。

调整的正确方式是小步:每次调整一个档位(比如两百到一百),跑一段完整周期,观察停顿分位、回收频率、晋升速率与完整回收次数四个量一起变化的方向。若停顿分位下降但晋升速率明显上升,说明已经越过了合理区间。

(三)固定新生代等于关掉自适应

这是分区式实现上最常见、也最隐蔽的配置错误:显式设置年轻代大小(无论是固定值还是上下限),会让停顿目标失去主要的调节手段。

原因在前面讲过:运行时逼近停顿目标的主要方式,就是动态调整年轻代的分区数量------分配快了就缩小年轻代以保持回收集规模,存活少了就扩大年轻代以降低回收频率。一旦年轻代被写死,这个反馈回路就断了,停顿目标退化成一个被忽略的数字。

这个错误之所以常见,是因为它常常来自「从旧配置复制过来」:一份为固定分代布局的老实现写的启动参数,被原样搬到新实现上,其中的年轻代设置就这样留了下来。它也常常来自一次「有效」的临时处置------调大年轻代确实能立刻降低回收频率,于是这个参数被固化下来,代价是从此再也无法用停顿目标控制尾部。

正确做法是:默认不设年轻代大小;确实需要约束时,只设百分比形式的下限或上限,给自适应留出调节空间;如果为了压制晋升而必须调大年轻代,那说明真正的问题是晋升速率,应该去查请求级驻留与批次大小。

(四)触发点与预留

并发标记的触发阈值决定了「什么时候开始为老年代做准备」。默认行为是自适应的:运行时根据历史的标记耗时与晋升速率,预测需要提前多久启动,并动态调整触发点。这个自适应在绝大多数情况下工作良好,不该轻易关掉。

需要手工干预的情形只有一种:晋升速率有剧烈的周期性尖峰(比如每小时一次的批量任务),自适应基于历史平均做出的预测会来不及。此时可以把触发阈值调低并关掉自适应,用固定的、更保守的触发点换取安全。代价是并发标记更频繁,处理器开销上升。

与触发点配套的是保留区:一部分堆空间被保留下来不参与分配,专门用于应对疏散时的突发需求。它的默认值是一个较小的百分比。在晋升速率波动大的服务上适当调高保留区,可以显著降低疏散失败的概率,代价是可用堆变小。这是「用容量换稳定性」的一个直接旋钮,比调停顿目标更值得优先尝试。

(五)线程数:容器里最容易设错的一项

停顿内的并行线程数与并发标记线程数,默认都由运行时按「可用处理器数」推导。问题在于容器里的「可用处理器数」是什么。

现代运行时具备容器感知能力,会读取容器的处理器配额并据此推导。但这条链路有若干个失效点:某些运行时版本对某些容器接口版本的支持不完整;配额以「份额」而非「配额」形式表达时无法推导;显式指定处理器数的参数被设错;以及使用了不感知容器的旧版本。任何一个失效点都会导致运行时按宿主机的核心数推导线程数------在一台九十六核的宿主上给一个两核容器开出几十个回收线程,结果是这些线程在两核的配额里疯狂争抢,停顿被拉长数倍,同时触发配额限流让业务线程一起变慢。

诊断办法很直接:打印运行时看到的处理器数与实际生效的线程数参数,与容器配额比对。若不一致,显式设定线程数参数,不要依赖推导。经验值是:停顿内并行线程数不超过配额核心数,并发线程数取其四分之一左右且至少为一。

(六)不该动的一类参数

有一类参数在网络上的「调优模板」里出现频率极高,但在没有明确证据时不该动:存活率阈值、可容忍浪费比例、混合回收次数目标、分区大小、年龄阈值、幸存区比例、精化线程的各类阈值。

它们不该动的原因不是「危险」,而是它们各自都有自适应逻辑或者与其他参数强耦合,单独改动一个几乎总是把系统推离运行时自己找到的平衡点。更重要的是,需要动它们的场景,往往真正的问题在别处:存活率阈值挡住了混合回收,真正的问题是活跃集太大;分区大小不合适,真正的问题是有大数组在热路径上;年龄阈值需要调,真正的问题是晋升速率异常。

一条可以直接执行的纪律是:启动参数里每多一个非默认的回收参数,就要在文档里留一行「为什么」和「什么条件下可以去掉」。做不到这一行的参数,就不该加。三年之后没有人记得当初为什么加,而它可能正在阻止运行时做出正确的自适应决策------这是遗留系统里回收配置逐年劣化的主要机制。

十、ZGC 的关键配置:旋钮很少,但有五件事必须做对

(一)堆大小就是主旋钮

低停顿实现的设计哲学是「尽量不提供旋钮」:没有停顿目标参数,没有年轻代大小参数,没有触发阈值参数------这些都由实现自己决定。留给使用者的主要变量只有一个:堆有多大。

堆大小在这里的作用与停顿式实现不同。在停顿式实现里,堆大小主要影响回收频率与单次停顿长度;在这里,堆大小决定的是回收器有多少时间余量。回收是并发进行的,从「决定开始回收」到「回收完成腾出空间」之间有一段时间差,这段时间里业务还在分配。若空闲空间不足以覆盖这段时间的分配量,分配线程就会被挂起等待------分配停滞。

于是有了那条经验法则:堆应当是活跃集的三倍以上。这个倍数比停顿式实现的两到三倍更高,因为要额外覆盖并发窗口内的分配。分配速率越高、活跃集越大(一轮回收耗时越长),需要的倍数越大。反过来,若堆已经开到活跃集的五倍以上还出现分配停滞,那问题不在堆,而在并发线程不够或者分配速率异常。

一个具体的估算方式:所需余量约等于「分配速率 × 一轮回收的墙上耗时 × 安全系数」。分配速率每秒四百兆、一轮回收耗时两秒、安全系数取二,则需要约一点六千兆的空闲空间------这还只是覆盖一轮,实际配置应当在此基础上留出尖峰余量。

(二)软上限与内存归还

除了硬上限之外,还可以设置一个软上限:回收器会尽量把堆占用控制在软上限以下,但在必要时(分配压力大)允许突破,直到硬上限。这个机制的价值在于把「常态占用」与「应急余量」分开:常态按软上限运行,容器规格按硬上限规划,突发流量时有一段缓冲而不是直接停滞。

软上限的典型用法是:硬上限设为容器内存减去堆外预算之后的可用值,软上限设为硬上限的百分之七十到八十。这样既保留了应急空间,又避免了「堆一直涨到硬上限才回收」导致的常驻集虚高。

内存归还是另一项默认开启的行为:长时间未使用的堆页会被归还给操作系统,归还延迟可配。它对「多实例共享一台机器」的部署形态很有价值,能让空闲实例把内存让出来。但它有两个副作用需要权衡:一是归还之后再次使用需要重新触碰页面,产生缺页开销,对延迟敏感的服务是额外抖动源;二是容器内存监控上会看到锯齿形曲线,容易被误判为异常。

在延迟极敏感的服务上,通常的选择是关掉归还并在启动时预先触碰全部堆页:用启动时间与常驻集换取运行期的确定性。在成本敏感、允许一定抖动的服务上,则保留归还。这个取舍必须显式做出,而不是让默认值替你决定。

(三)并发线程数与分配尖峰容忍

并发线程数是少数几个值得显式设置的参数。默认值按可用核心数推导,在容器里同样面临前一章讲过的推导失效风险。设置原则是:并发线程数要足以让一轮回收在「堆余量能覆盖的时间」内完成。若观察到分配停滞而堆余量充足,第一个动作就是增加并发线程数;若增加之后业务吞吐明显下降,说明核心配额本身不够,应当扩容而不是继续加线程。

另一个相关参数是分配尖峰容忍度:回收器用历史分配速率预测何时该启动下一轮回收,这个参数告诉它「预测时要为尖峰留多少倍的余量」。默认值适合分配速率相对平稳的负载;对于分配速率有明显尖峰(比如定时批量任务、突发大请求)的服务,调高它可以让回收启动得更早,代价是回收更频繁、处理器开销上升。

还有一个兜底参数是最长回收间隔:即使分配很少,也强制在若干秒之后启动一轮回收。它的用途是保证弱引用与堆外资源的清理及时发生------这一点在下面会展开。

(四)大页、预触碰与绝不交换

这三件事与收集器算法无关,但对低停顿目标的达成影响极大,而且经常被漏掉。

大页:使用大页可以显著减少地址转换缓冲的缺失,对遍历密集的回收工作尤其有效,通常能带来可观的吞吐与停顿改善。需要注意的是显式大页与透明大页的差别:显式大页需要操作系统预留,配置麻烦但行为确定;透明大页由内核动态合并,配置简单但合并动作本身可能造成毫秒级的停顿尖刺------在延迟敏感场景里,通常建议关闭透明大页的自动整理,或者改用显式大页。

预触碰:在启动时把整个堆的页面全部触碰一遍,让物理内存立即分配到位。代价是启动变慢(大堆可能多花几秒到几十秒),收益是运行期不再出现「首次触碰某页时的缺页停顿」。对追求确定性的服务,这笔交换几乎总是划算的。

绝不交换:一旦进程的堆页被换出到磁盘,任何一次全堆遍历都会把它们全部换回来,造成分钟级的卡死。这是所有回收相关事故里最严重的一种。纪律是:宿主机关闭交换或把交换倾向调到最低,容器内存配额留足,监控里把「主要缺页次数」作为一级告警指标。

(五)分代模式与版本边界

前面提过,分代模式的可用性与默认性随版本变化:先是可选,然后成为默认,再往后非分代模式被移除。这条时间线对使用者意味着三件具体的事。

第一,在分代还是可选的版本上,必须显式打开。忘记打开而直接用默认的非分代模式,会得到一个高处理器占用、高分配停滞风险的配置,然后据此得出「这个收集器不行」的错误结论------这是一个真实且常见的评估失误。

第二,跨版本升级必须重新标定。从「非分代默认」升到「分代默认」的那次升级,回收行为的变化幅度可能大于换一个收集器,原有的堆大小与线程数配置都需要重新验证。

第三,参数会随模式变化而失效或改变含义。分代引入之后,一部分只适用于非分代模式的参数被废弃;继续留在启动脚本里,轻则告警,重则拒绝启动。升级前把回收相关参数逐个对照当前版本的文档过一遍,是一个成本很低但经常被跳过的动作。

还有一件与分代相关的隐性事项:堆外资源的清理时机。直接缓冲区之类的堆外资源,其释放依赖于对应的堆内对象被回收之后清理动作被触发。在老年代回收很少发生的配置下(分配平稳、堆很大),堆外资源可能长时间不被清理,表现为「堆很空但直接内存用满」。处置办法有三条:设置最长回收间隔强制周期性回收、显式设置直接内存上限让其在接近上限时触发清理、以及最根本的------在代码里显式管理堆外资源的生命周期而不依赖回收器。另外要注意,禁用显式回收调用的参数会切断上面第二条路径,在大量使用堆外缓冲的服务上要谨慎。

十一、七类现场:从日志一行到第一个动作

(一)先按「谁在等」把现场分成三组

回收相关的现场,按「是谁在等、等什么」分成三组:分区式实现特有的三类、低停顿实现特有的两类、以及不属于收集器却经常被算到它头上的两类。分组的意义在于:每一组的第一个动作完全不同,分错组就会在错误的方向上花掉几天。

1、G1 的三类现场

1.1 疏散失败与转移空间不足

日志特征 :回收记录中出现疏散失败或者「转移空间耗尽」的标记,随后停顿时间异常长,往往紧跟一次完整回收。机理 :疏散需要空闲分区来容纳存活对象,当空闲分区不足时,部分对象无法被复制走,只能就地处理,代价极高。根因排序 :晋升速率突然上升(批量任务、大对象、请求级驻留)、堆余量不足(活跃集接近堆容量)、保留区太小、停顿目标过小导致年轻代被压得太小从而晋升过快。第一个动作:先看这次回收前后的老年代占用与晋升量,判断是「一次尖峰」还是「持续追不上」。尖峰型的处置是调高保留区并平滑批量任务;持续型的处置是扩堆或者降低晋升速率,调参解决不了。

1.2 巨型对象把老年代打碎

日志特征 :回收记录中巨型分区数量明显且波动大,或者出现「巨型分配触发并发标记」的记录;老年代占用锯齿状上升。机理 :超过半个分区的对象直接占用连续的老年代分区,回收路径与普通对象不同,且会造成碎片。根因排序 :热路径上的大数组分配(序列化缓冲、文件读取、批量结果集)、集合初始容量设置过大、字符串拼接产生的超长字符结果。第一个动作:用分配采样找出最大的几种数组分配点,看它们的大小分布与分区大小的关系。处置的首选永远是改代码(分片、流式、复用缓冲),调大分区大小只是缓解。

1.3 混合回收追不上晋升

日志特征 :并发标记周期一轮接一轮,混合回收次数很少或者每次带走的老年代分区极少,老年代占用长期高位且缓慢上升。机理 :老年代分区的存活率普遍高于阈值,符合条件的候选分区很少,混合回收无事可做。根因排序 :活跃集本身很大(大缓存)、存在泄漏、晋升速率过高导致老年代里堆积了大量仍然存活的对象。第一个动作:先判断活跃集是稳定的还是持续增长的------稳定说明是容量问题(扩堆、移到堆外或换实现),增长说明是泄漏(抓堆转储找持有者)。这两条路的处置完全不同,判断错了就会白干。

2、ZGC 的两类现场

2.1 分配停滞

日志特征 :日志中出现分配停滞记录,或者飞行记录里出现对应的停滞事件;服务端延迟出现远超停顿指标的长尾,而回收停顿统计依然是亚毫秒。机理 :并发回收腾出空间的速度追不上分配速度,分配线程被挂起等待。根因排序 :堆余量不足(活跃集相对堆太大)、并发线程数不足或抢不到核心、分配速率突然尖峰、一轮回收因为跨代引用过多而变慢。第一个动作 :同时看三个量------堆占用曲线是否长期贴近上限、回收处理器占用是否已经饱和、分配速率是否有尖峰。三者分别对应扩堆、加核心或加线程、以及调高尖峰容忍度。这类现场是「停顿短不等于延迟低」最直接的证据:所有停顿指标都完美,用户体验却很差。

2.2 内存不归还与常驻集不降

日志特征 :堆占用下降但容器常驻集不降,或者常驻集呈锯齿状波动引发容器内存告警。机理 :归还行为有延迟且按策略执行;同时回收器自身的结构、堆外缓冲、元数据都不在堆统计里。根因排序 :归还延迟设置过长或归还被关闭、堆外用量增长、软上限未设导致堆一直涨到硬上限。第一个动作:用本地内存跟踪把常驻集拆成堆、回收器结构、类元数据、线程栈、代码缓存、直接内存几项,看增长来自哪一项------在这个问题上,凭猜测调参的成功率极低,而拆账的成本只有几分钟。

3、常被算到收集器头上的两类

3.1 安全点拖长

日志特征 :回收停顿很短,但安全点日志里「应用线程被停止的时间」远大于回收时间,且「到达安全点」这一段占大头。机理 :某些线程迟迟走不到安全点。根因排序 :长计数循环、本地方法长时间执行或使用了本地关键区、线程数量极多、以及需要遍历大量线程状态的操作。第一个动作 :打开安全点日志把两段分开;若到达时间占大头,用采样剖析器找出停在什么地方的线程最多。这类问题换收集器完全无效,是最容易白白花掉一次迁移的方向。

3.2 容器限流与线程数错配

日志特征 :停顿时间在业务高峰显著变长且变化剧烈,处理器限流指标同时上升;回收线程数远大于容器核心配额。机理 :回收线程按错误的核心数推导出来,与业务线程争抢配额,并把整个容器推进限流状态。根因排序 :容器感知未生效、显式指定的处理器数错误、配额以份额形式表达。第一个动作:打印运行时看到的处理器数与实际生效的线程数,与配额比对,不一致就显式设定。这一项修复成本极低而收益极高,值得在所有容器化服务上做一次普查。

(二)七类现场共用的三个动作

七类现场的第一个动作各不相同,但有三个动作在所有现场里都值得先做,而且成本都很低。

第一个是把时间轴对齐。把回收日志、安全点日志、服务端慢请求日志、容器资源指标按同一个时间轴叠起来。很多归因争论在这一步就结束了------慢请求的时间戳与停顿窗口对不上,就说明不是回收的问题;对得上但比例很低,就说明回收只是尾部原因之一。

第二个是把常驻集拆开。用本地内存跟踪或者等价手段,把进程占用拆成堆、回收器结构、类元数据、线程栈、代码缓存、直接内存与其他若干项。内存类问题的一半在这一步就能定位到具体那一块。

第三个是先看有没有变更。回收行为的突变,绝大多数情况下对应着一次代码发布、一次配置变更、一次依赖升级或者一次流量结构变化。在深入分析算法行为之前,先拉一遍变更时间线,往往能省掉大量工作。

十二、观测:把这些东西看见

(一)八类观测手段与它们各自能回答什么

回收相关的观测手段可以按「能回答什么问题」分成八类。选择手段的原则是:先用成本最低、能回答当前问题的那一类,而不是一上来就抓堆转储。

统一日志 回答「发生了什么、什么时候、花了多久」,是所有分析的基础,开销极低,应当常开。安全点日志 回答「应用被停了多久、其中多少花在到达上」,是区分回收与非回收停顿的唯一手段。飞行记录器 回答「哪段代码在分配、哪次停顿与哪个请求重合、有没有分配停滞事件」,开销很低且可以常开或按需开启,是最被低估的工具。诊断命令 回答「此刻堆的状态、类的直方图、本地内存的分账」,适合在线快速取样。堆转储与分析工具 回答「谁持有了这些对象」,是泄漏定位的终点,但开销大、会造成长停顿,应当放在最后。采样剖析器 回答「分配热点在哪、线程停在哪」,对定位分配速率过高的源头最直接。日志解析工具 回答「一段时间内停顿的分布、各阶段耗时的构成、分配与晋升速率的曲线」,把原始日志变成可比较的图表。系统层指标回答「有没有缺页、有没有交换、有没有配额限流」,这一层缺失时,前七类都可能给出误导性的结论。

(二)从一份 GC 日志能直接算出的五个量

一份配置齐全的日志本身就是一个数据源,不需要额外埋点就能算出五个决策所需的量。这也是「日志必须常开」的实质理由。

第一个是分配速率 :相邻两次年轻代回收之间,堆占用从「上次回收后」到「这次回收前」的增量,除以时间间隔。第二个是晋升速率 :相邻两次年轻代回收之间老年代占用的增量,除以时间间隔。第三个是活跃集 :并发标记结束时或完整回收后的老年代占用。第四个是停顿分布与占比 :所有停顿记录的分位数、最大值,以及累计停顿除以总运行时长。第五个是回收的处理器份额:各次回收记录的用户态与内核态时间累加,除以「墙上时间 × 核心配额」。

这五个量应当被固化成一段脚本或者一个定时任务,而不是每次出问题时手工翻日志。把它们做成随版本发布一起产出的报表,能让「这次发布让分配速率涨了三成」这类问题在第一天就被发现,而不是等到某次大促。

(三)四问分诊

出现疑似回收相关的问题时,按四个问题依次判断,可以在很短时间内把范围缩小到具体一类。

第一问:应用被停了多久,其中到达安全点占多少? 到达占大头,走安全点方向,与收集器无关。第二问:慢请求与停顿窗口对得上吗,对得上的比例是多少? 对不上,去查回收之外的原因;比例远低于目标分位,同样不是主因。第三问:是停顿长,还是没有停顿却在等? 前者查回收集规模、活跃集、疏散失败;后者查分配停滞、缺页、限流。第四问:占用是稳定的还是在增长? 稳定是容量与配置问题,增长是泄漏问题------这两条路的后续动作完全不同。

这套分诊的价值不在于它有多精巧,而在于它强制先做区分再做深入。回收问题排查失败的最常见形态,就是跳过区分直接进入某个方向,然后在那个方向上越挖越深------最后发现方向本身就是错的。

十三、标定:一次可复现的对照实验

(一)实验设计的六个约束

「换过去之后好像快了一点」不是结论,是印象。要让结论可以被别人复核、被自己在半年后复用,实验必须满足六个约束。

第一是同流量。两组配置必须承受同一份流量:最好是同一份录制回放,其次是线上分流且分流规则与实例特征无关(不要按机房、按机型分流)。用「今天对比昨天」的方式比较是无效的,因为流量结构每天都在变。

第二是单变量。两组之间只改收集器相关参数,其他所有参数(堆大小除外,见下)保持一致;代码版本、运行时版本、依赖版本、容器规格完全相同。

第三是堆的处理要显式声明。这是唯一允许的第二个变量,因为不同实现对堆的需求本来就不同。正确做法是做两轮:第一轮堆完全相同(比较同等资源下的表现),第二轮各自调到「刚好不出现回收压力」的堆(比较各自最优表现下的资源消耗)。只做第一轮会低估低停顿实现,只做第二轮会掩盖它的内存成本。

第四是覆盖多个负载点。至少三个:日常负载、高峰负载、以及压到接近饱和的负载。单点比较是最常见的错误,因为两条曲线经常在中段交叉------低负载下低停顿实现全面占优,高负载下反转。

第五是足够的时长。至少要跨过若干个完整的老年代回收周期,以及一个完整的缓存填充周期;对于有定时任务的服务,还要覆盖一次定时任务。经验值是每个负载点不少于两小时,全天候观察不少于二十四小时(用于观察内存归还与常驻集的长期形状)。

第六是预热与稳态判定。前若干分钟的数据必须丢弃------即时编译尚未收敛、缓存尚未填充、连接池尚未建立。稳态的判定标准应当事先写好:吞吐波动、活跃集、编译队列长度都进入平稳区间之后才开始计数。

(二)延迟-吞吐曲线:唯一可比的口径

单点比较之所以不可靠,根本原因是延迟和吞吐是一对绑定量:在低负载下测延迟,任何实现都好看;在高负载下测吞吐,任何实现都会被延迟拖垮。唯一有意义的口径是在满足延迟目标的前提下,最大能承载多少吞吐。

做法是:固定延迟目标(比如 p999 小于八十毫秒),逐步提高负载,找到延迟刚好突破目标的那个吞吐点,把它作为这套配置的容量。对两组配置分别做一遍,得到两个数------这两个数是可以直接比较的,也是可以直接换算成成本的(容量高百分之十,就是同样流量少用百分之十的实例)。

画出完整曲线还有一个额外收益:能看到两条曲线的交叉点在哪。交叉点的位置直接告诉你这个结论的适用边界------「在每秒两千次以下低停顿实现更好,超过之后分区式实现更好」这样的结论,比一句「哪个更好」有用得多,也更容易在容量规划变化时重新判断。

(三)需要采集的十二项指标

每个负载点、每组配置,采集下面十二项。少采一项,事后往往要重跑一遍。

服务侧五项:吞吐(每秒完成请求数)、延迟的 p50 / p99 / p999 / 最大值、错误率与超时率、下游依赖的延迟(用于排除外部干扰)、以及请求在队列中的等待时间(若框架能给出)。

运行时侧四项:回收停顿的次数、分位与最大值;分配速率与晋升速率;活跃集与堆占用曲线;回收的处理器份额。

系统侧三项:容器处理器利用率与限流次数、常驻集与主要缺页次数、内存带宽(若可采集)。

十二项里最容易漏的是限流次数与主要缺页次数。它们不属于回收指标,却是解释「为什么两组结果差异如此之大」最常用的两个变量------很多看似诡异的实验结果,最后都归结为其中一项。

(四)五种伪结论

最后是五种在实践中反复出现、看起来有数据支撑但实际站不住的结论。

第一种是「微基准显示吞吐只差百分之三」。微基准的对象图形状、分配模式、存活比例与真实服务差异极大,屏障成本高度依赖代码形态,微基准得出的比例不能外推。

第二种是「压测五分钟看不出差别」。五分钟往往连一次老年代回收周期都跑不完,也来不及暴露内存归还、碎片累积与长期漂移。

第三种是「在空机器上测出来的结论」。空机器抹掉了这次交换里最重要的成本项------处理器竞争。低停顿方案在空机器上几乎总是赢,而生产环境很少是空的。

第四种是「只看停顿曲线不看服务延迟」。停顿曲线压平而服务延迟没变,是完全可能的结果,而且相当常见;它恰恰说明回收不是尾部主因,这次迁移的收益接近零。

第五种是「用默认参数比较」。两个实现的默认参数分别面向不同的目标,直接用默认值比较,比的是默认值的选择而不是实现的能力。至少要让两边都满足「不出现明显回收压力」的基本条件。

十四、治理:把判断变成默认行为

(一)三层防线与十二条规则

个人能力无法规模化,能规模化的只有默认行为。把这套判断固化成三层防线:构建期拦住明显错误的配置,部署期校验必备项,运行期监控关键量并在评审时复核。

1、十二条可机器检查的规则

1.1 进构建与静态检查的四条

第一条 :启动参数模板中不得同时出现停顿目标与固定年轻代大小------两者共存时前者失效,构建期直接报错。第二条 :任何非默认的回收参数,必须在参数文件的同一行或相邻行附带注释说明原因与撤销条件,缺注释则告警。第三条 :禁止在参数中出现已被当前运行时版本废弃的回收参数,升级时由检查脚本比对当前版本的参数清单。第四条:显式回收调用的禁用参数与大量使用堆外缓冲的依赖不得同时出现,出现则需要人工确认堆外资源有显式释放路径。

1.2 进部署模板校验的四条

第五条 :堆上下限必须显式设置(或使用按容器内存百分比推导的参数),且堆上限加上堆外预算不得超过容器内存配额的某个安全比例。第六条 :统一日志必须包含回收、堆、处理器统计与安全点四类标签,且配置了文件轮转。第七条 :内存溢出自动转储必须开启且路径可写、卷空间充足。第八条:回收线程数参数要么显式设置且不超过容器核心配额,要么校验容器感知已生效------两者必居其一。

1.3 进监控与评审的四条

第九条 :分配速率、晋升速率、活跃集、停顿分位、回收处理器份额五个量必须有常态监控面板,并在每次版本发布后自动对比前后差异。第十条 :完整回收次数、疏散失败次数、分配停滞次数三项任一大于零即触发告警------它们在稳态服务里应当恒为零。第十一条 :容器限流次数与主要缺页次数纳入一级告警,与回收指标同屏展示。第十二条:任何收集器变更、堆规格变更、运行时大版本升级,评审材料中必须包含一次同流量对照实验的十二项指标与延迟-吞吐曲线。

2、落地次序与它为什么不能反过来

十二条不能一次性推行,正确的次序是「先可诊断、再可判定、最后可优化」。

第一步是可诊断 :把日志、转储、监控面板这些「出事时能查」的东西补齐(第六、七、九、十一条)。这一步不改变任何运行行为,阻力最小,却把后续所有工作的地基打好了。第二步是可判定 :把必设参数与危险组合的校验加进部署模板(第五、八、一、四条),把「不该出现的组合」挡在上线之前。这一步会拦下一些历史配置,需要逐个处理,但每处理一个就消除一类隐患。第三步是可优化:在有数据、有拦截的基础上,才谈参数调整与收集器迁移(第二、三、十、十二条)。

次序反过来的后果是可预见的:先做优化,没有数据支撑,调完之后无法证明有效;先做拦截而不做诊断,被拦下来的团队问「凭什么」时给不出证据,规则会在推行阻力中被逐条豁免,最后名存实亡。

(二)迁移与回滚的五步

真的决定要换时,按五步推进,每一步都有明确的退出条件。

第一步是补齐观测 :在旧配置上先把日志、面板、告警补齐,并连续采集至少一周,作为基线。没有基线的迁移无法评估。第二步是预发标定 :按前一章的六个约束做完整对照实验,输出十二项指标与延迟-吞吐曲线。结论不支持迁移就到此为止------这是最有价值的一种结果。第三步是单实例灰度 :线上取一个实例切换,观察二十四小时,重点看常驻集曲线、分配停滞、限流与业务错误率。第四步是比例放量 :按一成、三成、五成推进,每一档观察一个完整业务周期(通常是一天,含高峰与低谷)。第五步是全量与固化:全量之后把新的参数、监控阈值、容量结论写回部署模板与容量文档。

回滚设计贯穿五步:切换必须只依赖启动参数,不依赖代码分支;每一档都保留未切换的实例作为对照;回滚判据事先写死(比如「分配停滞次数大于零」或「p999 相对基线劣化超过一成」持续十分钟即回滚),由值班同学按判据执行而不是临时讨论。

(三)一页纸速查

把全文压成一页,供评审与值班时快速对照。

(四)为什么这类问题长期高发

最后回答一个结构性的问题:这套东西并不深奥,为什么在团队里长期反复出问题。

第一个原因是反馈周期太长。一次配置错误可能在半年后的某次流量高峰才显形,而那时候人已经换了、上下文已经丢了。没有及时反馈的领域,经验无法自然积累,只能靠制度沉淀------这正是把判断变成默认行为的意义。

第二个原因是它跨了三个角色。分配速率归开发管,容器规格归运维管,延迟目标归业务定,而收集器选型需要三方的数据同时在场。缺乏一个把三方数据摆到同一张表上的机制时,讨论就会退化成各说各话,最后由声音最大的人决定。

第三个原因是「换一行参数」的门槛太低。修改代码需要评审、测试、发布,而修改启动参数常常只需要改一个配置项。低门槛使得「先换了试试」成为默认动作,而试的结果往往因为没有基线、没有对照而无法判断,于是参数越积越多,每一个都缺少解释,最终形成一份没人敢动的启动脚本。

第四个原因是行业信息的失真。公开材料里最容易传播的是「停顿降到亚毫秒」这类单点结论,而代价那一侧------吞吐损失、处理器占用、内存放大、余量要求------很少被同等强度地传播。信息的不对称让选型天然偏向「更新更先进的那个」,而不是「更适合这份负载的那个」。

这四个原因都不是技术问题,但它们决定了技术判断能否落地。能对抗它们的只有三件事:把量测成默认动作、把判断写成可机器检查的规则、把每一次结论连同它的适用条件一起记下来。

结语

收集器选型看上去是一道知识题,实际上是一道口径题与代价题。需要记住的实现不超过五个,需要记住的机制不超过六项,需要记住的参数不超过十个。真正稀缺的是遇到延迟问题时,不从「换哪个收集器」开始,而从「回收在尾部里占多少」开始的那个习惯。

这篇文章反复回到同一个结构:先把问题问准,再把口径统一,然后把代价逐项摆出来,最后用一次可复现的对照把结论钉住。这套动作对垃圾回收有效,对线程池规格、连接池容量、批量大小、缓存上限同样有效------它们都属于「一份有限资源被多方共用,收益明显而代价隐蔽」的那一类问题。代价隐蔽是关键:正因为代价不在明面上,才需要一套强制把它算出来的流程。

也要承认一件事:绝大多数服务不需要走完这套流程。一个堆在四千兆以内、分配速率平缓、延迟目标宽松的服务,用默认收集器配上必设的四项参数,可以几年不去看它。这套流程真正要保护的是另外那一小部分------活跃集在持续变大的、堆规模要跨过引用宽度那条坎的、延迟目标以千分位计的、以及跑在资源被压到极致的容器里的。把它们找出来通常不超过十个服务,而它们几乎承担了全部风险。

最后是一条可以立刻执行的建议:打开自己负责的服务,把启动参数抄下来,检查回收日志、安全点日志、自动堆转储、堆外上限这四项是否齐全。缺几项,就是接下来几个小时的工作清单;四项都在的话,去日志里把分配速率、晋升速率、活跃集、停顿占比、回收处理器份额这五个数算出来。半天时间,你大概率会发现:真正该改的地方,比「换一个收集器」具体得多,也便宜得多。

参考资料

官方调优与运行时文档

语言与运行时演进提案

实现说明与工程资料

论文与经典材料

工具

相关推荐
凤山老林8 小时前
Spring Boot 启动冲刺:耗时剖析、懒加载策略与事件驱动初始化优化
spring boot·后端·python
朱涛的自习室8 小时前
我给 Gemini 4 和 DeepSeek 搞了套「末位淘汰」,AI 干活会更猛吗?(Day 3)
android·前端·后端
woniu_maggie11 小时前
SAP MDG主数据治理实战:从架构原理到项目实施全解析
后端
xiaoqiMikko11 小时前
Redis 实战(一):内存满了 —— 拒写、淘汰、被系统杀掉,以及删了 key 内存为什么不降(6.2.4 实测)
redis·后端
工程师小爱11 小时前
电芯尺寸合格,不等于电池包尺寸合格
后端
geovindu12 小时前
rust:Memento Pattern(续)
开发语言·后端·设计模式·rust·行为模式·备忘录模
闲云lazycloud12 小时前
第 1 课:Harness 与 Agent 运行时
人工智能·后端
熊哈哈O_o12 小时前
260+异构同步链路:DDL语法自动翻译在大规模数据同步中的实现原理
后端
用户98883218717412 小时前
别把 IOptionsSnapshot 注入单例:从生命周期看 .NET 配置读取
后端