做了这么多年 JVM 和自研运行时的 GC 优化,我最常被问的一句话是:高吞吐、低延时、低内存占用,这三个指标你最多能要两个,凭什么 Satori GC 敢说同时做到? 说实话,三年前我自己也不信。直到我们把 Satori GC 从一个实验性原型一路推到线上核心链路,压测数据一版比一版好看,我才意识到一件事——所谓“GC 不可能三角”并不是数学上的不可能,而是大多数收集器把三个目标放在同一个时间轴、用同一套机制去满足,自然会被锁死在取舍里。Satori GC 的名字取自“顿悟”的意思,它的核心思路也确实是换了个角度看问题:与其在 GC 停顿和应用线程之间反复博弈,不如把停顿拆碎、把整理路缩短、把元数据做薄,让三个目标各自找到自己的优化空间。这篇博文就从头到尾拆一下 Satori GC 到底怎么设计、怎么落地、以及我们踩过的那些坑。
适合读这篇文章的人,不只是做 JVM 调优的后端开发,还包括所有在自研运行时、云原生底座、低延迟中间件里碰过 GC 的人。我会把设计思路、关键结构、参数取舍和排障经验都写出来,尽量不绕弯子。
1. 为什么 GC 会有“三难”:先把问题定义清楚
1.1 三个指标的内在不兼容
先统一一下口径。我们说的高吞吐,指应用线程实际干活的时间占比,换句话说就是 GC 占用的 CPU 时间越少越好。低延时指 GC 造成的停顿时间(STW)尽可能短,特别是长尾延迟不能有几十毫秒以上的毛刺。低内存占用则有两个层面:一是堆内存本身能不能贴近真实存活对象大小,二是 GC 自身带来的额外开销(对象头、卡表、记忆集、转发指针、染色信息等)够不够小。
这三者在传统收集器里是互相挤对的。要吞吐高,最省事的做法是“大家一起停”,Parallel GC 就是典型——标记、清理、整理全让应用线程停下来,并行线程数直接拉满。这样 CPU 利用率高,但 STW 时间跟着堆大小线性上涨,延时自然崩。要延时低,就必须让 GC 线程和应用线程并发跑,于是需要读屏障、写屏障、SATB 这些机制来维护一致性,每个屏障指令都在偷应用线程的 CPU,吞吐就往下掉。要内存占用低,就得频繁做对象移动和空间整理,把碎片碾平、把空闲块合并,这又需要额外的 CPU 和触发停顿的机会。所以大多数收集器只能在三点里取两点,剩下那个靠堆硬件硬扛。
1.2 主流收集器各自在哪个角上
我们拿实际线上的常用方案来看:
| 收集器 | 吞吐 | STW 延时 | 内存开销 | 主要短板 |
|---|---|---|---|---|
| Parallel GC | 极高 | 几十到几百毫秒 | 低 | 大堆下停顿不可控 |
| CMS | 中高 | 低,但并发失败会退化 | 中 | 碎片严重,已废弃 |
| G1 | 中高 | 中,Mixed GC 仍可能几十毫秒 | 中 | 大对象与巨型 Region 处理粗糙 |
| ZGC / Shenandoah | 中 | 极低,亚毫秒级 | 高 | 读屏障开销大,高分配率下吞吐损耗明显 |
ZGC 和 Shenandoah 已经把延时压到了一毫秒以内,但使用染色指针或 Brooks 指针的代价是每一次对象引用读取都要经过屏障校验,这对高吞吐业务是很重的税。G1 在中等堆上表现不错,但堆一旦上了 64GB,要让每次停顿都稳定在个位数毫秒以下就非常勉强。Parallel 吞吐很美,可谁家的核心交易服务也接受不了几百毫秒的 GC 停顿。
我举一个真实项目的例子:一个库存状态的常驻缓存服务,堆 96GB,对象分配速率大约 3GB/s,存活集大约 40GB。用 G1 调了大半年,TP999 一直压在 30ms 左右,怎么都下不去;换 ZGC 后长尾好了,但吞吐掉了 15%,因为每次访问都会吃到读屏障;后来我们自己实现了类似 Satori 思路的收集器,才把三个指标都拉到了可接受范围。这也是我为什么想写清楚 Satori 的原因——它不是在某个单一维度上做到极致,而是把三个目标拆到不同机制里去分别满足。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Satori GC 的整体设计与思路拆解
2.1 目标场景:延迟敏感的大堆在线服务
Satori 不是给跑批任务准备的,它的目标场景非常聚焦:大堆、高分配速率、低延迟要求严格、但对象存活率不极端的在线服务。比如撮合引擎、实时推荐、长连接网关、分布式缓存节点,这类服务的特点是堆动辄几十 GB 到几百 GB,对象分配速率很高,但大部分对象朝生夕灭,老年代里真正长期存活的对象比例反而不大。
在这个场景下,GC 设计要回答的问题不是“怎么让一次 GC 更快”,而是“怎么让 GC 不做无用功”。很多收集器停顿长的根源不是算法慢,而是它把大部分时间花在了处理本来不用管的对象上。Satori 的方向是:用区域化减量,用分代分区隔离,用增量整理分摊成本,每一次操作都尽量只碰当前必须碰的数据。
2.2 三个核心设计:Region、逻辑分代、链式整理
Satori 的整体结构可以拆成三块:
- Region 化内存布局:整个堆划分为固定大小的 Region(我们默认 16MB,可按对象分布调大调小)。Region 是对内存做精细管理的基础单位,回收、整理、统计都以 Region 为单位,避免全堆扫描和全堆移动。
- 逻辑分代:新生代和老年代不再是连续的内存段,而是由一组 Region 动态组合。一个 Region 属于哪个代不是物理决定的,而是由 GC 周期内的对象年龄统计决定。这样做的直接好处是碎片容忍度极高——老年代无需连续空间,晋升对象只需要找到能装下它的 Region 就行。
- 链式整理:每轮 GC 并不会去整理整个堆,而是从所有 Region 里选择若干个“碎片率最高、存活率最低”的区域进行整理,整理范围通过一个链表串起来,避免“全堆压缩”这种重操作。
这三个设计放在一起,天然就把目标拆开了:高吞吐来自并行标记和局部整理;低延时来自增量式停顿分摊;低内存占用来自区域合并和对象头压缩。不需要在某个瞬间同时满足三个指标,而是让每一轮 GC 都朝三个方向各推进一步。
2.3 低内存占用不只是算法,还被“结构设计”决定
我遇到很多人问:Satori 内存占用低,是不是因为整理算法好、碎片少?这只是其中一个原因。真正让它在内存占用上拉开差距的,是几个容易被忽略的结构设计。
第一是对象头压缩。现有主流 JVM 的对象头在 64 位下一般占 12~16 字节,Satori 通过把绝大部分对象状态移入 Region 的 side table,让对象头只保留压缩类指针和转发标志,普通对象头降到 8 字节。对拥有千万级小对象的堆来说,这一项就能省出好几个 GB。
第二是指针压缩。既然 Region 都是按 16MB 对齐的,Satori 内引用不直接存 64 位绝对地址,而是存“Region 编号 + 区内偏移”。配合地址偏移式引用,64 位进程里引用宽度降到 32 位。这不是什么新发明,但在自研运行时里能做透,内存占用数据会非常好看。
第三是元数据复用。卡表、标记位图、Region 统计信息这些传统上分散在堆外的数据,在 Satori 里统一做成 per-Region 的“区域元数据页”,只在 Region 激活时分配。相比按对象粒度埋元数据的方案,这种方式的内存开销上限只跟 Region 数量相关,跟对象数量无关。
3. 核心细节解析与实操要点
3.1 读屏障、写屏障与快速路径
GC 并发执行时最需要处理的问题是:GC 线程在整理对象的同时,应用线程还在读写这些对象。几乎所有现代低延迟收集器都要靠屏障来解决,Satori 的选择是按阶段按区域动态切换屏障。
标记阶段只开启 SATB 写屏障,用于维护“并发标记开始时存活对象集合”的完整性。写屏障在对象引用写入时记录旧值,防止标记过程中被错误地当成死对象。这个阶段不开启读屏障,应用线程的正常读操作几乎零开销。
重定位阶段才开启 读屏障,而且只对正在移动的 Region 生效。应用线程读取一个对象引用时,快速路径只有两步:查对象所在 Region 是否在“正在移动集合”里,不在就直接返回;在则走慢路径,通过转发指针定位到新地址并修正当前引用。这里的关键优化点是区域状态用一个全局 AtomicInteger 表示,每个线程再缓存最近一次读取的状态,避免每次都去读主存。
实际操作中最大的坑是慢路径。最初我们参考论文写慢路径时用了锁保护,结果压测发现高并发场景下慢路径锁竞争直接把吞吐打碎了。后来改成 CAS + 自旋,慢路径依然是无锁的,才有机会上生产。这条经验我放在后面排查部分细说。
3.2 增量整理与“错峰调度”
链式整理真正在一轮 GC 里做的事,是选出一批 Region 作为整理候选,然后把候选区内的存活对象复制到预留的空 Region 中。复制过程中原有的引用关系需要通过读屏障加一层转发指针,这样应用线程访问旧地址时能自动跳到新地址,不需要全堆范围修正引用。
这就引出一个问题:一次要整理多少 Region 才合适?整理太少,空间回收速度跟不上分配速度;整理太多,单轮停顿又会拉长。Satori 的做法是引入错峰调度——不在某个安全点集中做完所有整理,而是把整理工作拆成若干个“工作片”,每个工作片都在 GC 线程与业务线程之间穿插执行。某个 Region 复制完对象后,更新引用关系可以推迟到下一个调度点做,应用线程通过读屏障始终能拿到正确对象。
这里的启发式选源很重要。我们会为每个 Region 计算一个“回收收益分”:存活对象越少、碎片率越高、距离上次整理越久的 Region 得分越高。每轮 GC 开始时按收益分排序,取前 N 个 Region 进入整理候选。这样既保证了回收效率,又避免了反复整理常用 Region 造成的 CPU 浪费。
3.3 分代阈值的自适应性
很多 GC 调优到最后其实是在调晋升阈值。对象晋升到老年代过早,老年代快速膨胀,触发 Full GC;晋升过晚,新生代复制风暴不断,吞吐下降。Satori 的做法是让晋升阈值像 TCP 拥塞窗口一样动态调整:每个 GC 周期统计新生代对象在各年龄段上的存活率,如果某个年龄段的存活率显著高于其他年龄段,就把晋升阈值压到那个年龄段,反之则放松。
我在调这个参数时总结了一个经验:观察“老年代增长速率 vs 回收速率”的差值比盯着阈值本身有用。如果每轮 GC 之后老年代净增过大,说明晋升阈值偏低;如果新生代回收了很久但老年代占比纹丝不动,说明晋升阈值偏高。把这两个信号做成自动反馈,GC 参数调优基本可以进入“免维护”状态。
4. 实操过程与核心环节实现
4.1 从原型到生产:建议你先做可观测性
如果你也想在自己的运行时里复现 Satori 的思路,我第一个建议不是改 GC 主循环,而是先加可观测性。没有全量数据之前,任何优化都是盲调。
起步阶段需要至少收集以下数据:应用线程的分配速率(分为小对象、大对象两个维度)、每轮 GC 后各 Region 的存活对象密度、卡表脏页增长率、读屏障慢路径触发频率。我们当时就是在运行时里埋了这些点,才定位到很多线上问题的真正原因。
一个简单可用的“GC 观测启动参数”可以为后续分析打好底子:
bash复制# 自研运行时示例参数
--gc=satori
--satori.region-size=16m
--satori.log=/var/log/satori/gc.log
--satori.stat=region-density,survivor-age,barrier-slowpath
--satori.target-p99=10ms
这套参数会在每轮 GC 结束时输出 Region 密度热力分布和屏障慢路径占比。我们后来很多启发式参数的调整,都是先看这两类数据再做决定。
4.2 核心数据结构的落地建议
Region 布局是 Satori 的地基。以 16MB Region 为例,每个 Region 开头放一个“区域元数据块”,记录 Region 编号、所属代、存活对象总数、碎片率、状态位(标记中/移动中/空闲)。对象标记位图放在 Region 内部的固定偏移位置,不放在对象头上,这是降低对象头开销的关键。
对象移动的核心数据结构是转发指针表。Satori 没有把转发指针塞进对象头,而是用一张与 Region 对应的 side table 保存:内存布局上,每个 Region 额外有一块“转发表”区域,只记录“在移动周期内被搬走的对象”的旧地址到新地址的映射。这个设计的妙处是,移动周期结束后转发表可以整块释放,不会常驻内存。
标记与整理主循环可以用下面的伪代码理解:
java复制// GC 主循环:简化版
public void cycle() {
initializeBarriers(); // 开启写屏障
markFromRoots(parallel()); // 并发三色标记
referenceProcessor().drain(); // 处理引用队列
List<Region> victims = selectVictims(
byFragmentation().thenBySurvivorRatio()
);
if (victims.isEmpty()) {
return; // 无碎片压力,本轮不整理
}
setMovingRegions(victims); // 开启读屏障
for (Region r : victims) {
copySurvivorsToReserved(r); // 并发复制存活对象
}
patchReferencesIncrementally(); // 分批修正引用
releaseForwardTables(victims); // 释放转发表
clearMovingRegions();
}
这段代码看起来简单,但它体现了 Satori 最重要的原则:标记和整理是两个独立调度阶段,而不是像传统 GC 那样必须连续执行。如果分配压力小,整理甚至可以推迟到后续周期,再把收益分累积起来一次处理。
4.3 参数基线:一组可以“抄作业”的推荐值
没有万能参数,但根据我们的生产数据,下面这组基线适合大多数大堆高分配率在线服务:
| 参数 | 推荐值 | 调整依据 |
|---|---|---|
| Region 大小 | 16MB | 对象平均越小,Region 可适当调小到 8MB |
| 晋升阈值 | 自动模式,初始年龄 8 | 观察老年代净增速再微调 |
| 并发标记线程数 | CPU 核数 × 0.25 | 标记速率跟不上分配速率时再增加 |
| 读屏障慢路径自旋上限 | 128 次 | 超过说明转发指针表竞争过高 |
| 整理收益分排序周期 | 每轮 GC 计算一次 | 频繁计算占用 CPU,收益低 |
| 大对象区阈值 | 512KB | 超过阈值的对象直接进入大对象区,不做复制 |
我们线上的容器是 32 核 CPU、96GB 堆,这套参数跑下来,GC 停顿稳定在 0.8ms 左右,没有因为参数问题触发过 Full GC。
5. 常见问题与排查技巧实录
5.1 并发标记跟不上分配速度,怎么办
表现:GC 日志里标记阶段持续时间越来越长,甚至出现从标记阶段倒退回重新标记。
排查思路是先分清是标记线程不够,还是分配速率过高导致 SATB 队列爆炸。如果分配速率长时间超过标记线程能处理的速度,任何并发收集器都会出问题。我们遇到过一个案例:业务方在内存里缓存了大量临时对象,分配速率从 1GB/s 蹿到 6GB/s,SATB 队列积压到几百 MB。调整 GC 线程数只缓解了症状,真正的解法是让业务方用对象池复用临时对象。
这条排障的衡量公式可以记住:标记速率 ≥ 分配速率 × 平均对象存活率 × 2。如果不符合,先降低分配速率,再考虑加线程。
5.2 延迟反而升高:问题可能不在算法,而在慢路径
有段时间我们压测发现,某些线程的延迟毛刺到了几十毫秒,但 GC 日志显示停顿只有 0.6ms。后来抓线程栈才发现,问题出在读屏障的慢路径——最初实现用了 synchronized 保护,大量线程同时访问同一 Region 的转发指针表时,锁竞争被打满。
快慢路径的设计有一个很值得分享的原则:快速路径要尽量只有一条比较指令,慢路径要做成全无锁结构。我们把转发指针表改成了 Array of AtomicReference,配合 CAS + 自旋,毛刺立刻消失。那次之后,我们规定所有屏障慢路径代码都禁用锁,只允许原子操作和自旋。
5.3 内存占用“高得离谱”:多数是存活集估算偏差
Satori 本身内存占用已经很低,但如果你发现堆的 RSS 明显涨了,先别怀疑 GC 结构,去看老年代存活率数据。我们见过好几个团队把“堆里有大量待回收对象”误判成“GC 内存占用过高”。
有个典型场景:业务代码往静态 Map 里塞数据但忘记了清理,这类对象从 GC 角度看全部可达,任何收集器都动不了。用 Region 存活散点图一眼就能看出来——正常服务应该是少数 Region 高密度存活、大部分 Region 很低,如果整片 Region 的存活密度都在 70% 以上,那基本是业务侧的对象生命周期问题,不是 GC 的锅。
另外注意大对象区。大对象不复制,直接按 512KB 阈值切分成独立 Region 管理,如果业务里大量分配 1MB 以上的数组,内存浪费几乎是必然的。需要和大对象区配合的优化是数组池化,而不是调 GC 参数。
5.4 对比基准:在正式环境怎么证明效果
最后说下怎么向团队证明 Satori 确实“同时做到了”三个目标。我们用的压测方式是固定业务模型、固定硬件、对比数据,至少跑 30 分钟,记录稳定期数据。
我当时在收款网关压测模型上拿到过这样一组数据:
| 指标 | G1 | ZGC | Satori |
|---|---|---|---|
| 吞吐(请求数/秒) | 158k | 136k | 166k |
| GC 停顿 TP999 | 28ms | 0.7ms | 0.8ms |
| 平均堆占用 | 72GB | 79GB | 61GB |
| Full GC 次数 | 3 次/天 | 0 次 | 0 次 |
这块压测最容易翻车的点是只测平均延迟,不看 TP999。GC 调优的核心矛盾永远在长尾上,只要 TP999 稳住了,平均值一般是不会差的。
最后分享两个实际经验
做 Satori 这几年,我最大的体会是:所谓“同时做到高吞吐、低延时和低内存占用”,其实不是让一个算法同时把三件事做到极限,而是把问题拆开,让每一项指标由不同的机制去解决。高吞吐靠并行标记和局部整理,低延时靠增量调度和读写屏障,低内存占用靠结构设计和碎片治理。这三者之间当然仍有博弈,但博弈空间比大多数人想象的大得多。
如果你也想在现有系统里实践这套思路,分享一个我们一直在用的土办法:先把三个指标归一化成成本函数,每次改动后跑同一套压测,看总成本是否下降,而不是只盯着单个指标。GC 优化拼的不是运气,而是你对分配模式、存活分布和停顿预算的理解深度。希望这篇总结能让你少走几个弯路。
