第一次看到 Satori GC 带着“高吞吐、低延时、低内存占用”这个目标出现时,我第一反应是:这不就是想挑战 GC 领域的“不可能三角”吗?熟悉垃圾回收机制的朋友都知道,这三个指标在传统设计里基本是个跷跷板——吞吐上去了,暂停时间往往失控;暂停压下来,内存开销和浮动垃圾又让人头疼。Satori 这个词很有意思,意思是“悟”,像是一个设计者觉得他悟到了同时兼顾三者的办法。
这篇文章不是什么大厂的官方文档,也不是某个正式 JVM 版本的新特性说明书,而是我把 Satori GC 的设计思路完整拆解了一遍,并在实验分支里动手复现、调参、踩坑之后的记录。里面会讲清楚它用什么手段化解三角矛盾、各项参数背后的取舍逻辑、以及我实际运行过程中遇到过的奇怪问题。适合已经玩过 G1 或 ZGC、想深入了解 GC 内部设计的人;如果你只是听说过 GC 名字,也能从这里拿到一套完整的理解框架。
1. Satori GC 到底是什么,为什么值得聊
1.1 一个听起来像“不可能三角”的目标
在讲 Satori 的具体设计之前,得先明确一个背景:垃圾回收器在过去二十年里,基本都在三指标之间做痛苦的折中。
高吞吐意味着 GC 线程要把更多 CPU 时间花在回收上,最直接的做法是延长 Stop-The-World 时间,把整堆扫描、标记、清理一次做完。低延时意味着要尽量缩短业务线程被暂停的时间,于是出现了并发标记、并发清理、增量回收等一系列花活。低内存占用则要求 GC 自身的元数据尽量精简,但并发收集、分区管理这些机制恰恰又需要消耗大量额外内存。
Satori GC 想做的,就是让这三者不再互相拖后腿。它不是在一个指标上做到极致,而是给出一个可配置的“目标区间”:在目标停顿不超过 50ms 的前提下,尽量把吞吐拉高,同时把 GC 内部结构对堆内存的占用压到最低。这句话听起来轻飘飘,落地却非常复杂。
1.2 谁需要关注、什么场景最契合
我在复现 Satori GC 的过程中,最大的感受是:它不是给所有应用准备的万能药,而是专门为“在线服务型负载”设计的。
什么是在线服务型负载?就是你有大量请求需要处理,每个请求都会创建一批短生命周期对象,同时系统对响应延迟非常敏感。典型的就是订单服务、推荐服务、网关服务这些后端节点。这类场景有三个共性问题:第一,对象分配速率高,Young GC 频繁;第二,业务线程绝对不能长期阻塞,否则 P99 会直接坐火箭;第三,堆内存往往控制在几十 GB 以内,不希望 GC 自己就吃掉几个 GB 的元数据空间。
Satori 的设计目标,恰好就是为这种负载画像调优的。如果你跑的是离线批处理任务,停顿长一点无所谓的,那 Parallel Scavenge 可能更省事;如果你对延迟要求变态到亚毫秒级,那染色指针那套方案依然有优势。Satori 的定位是:在 20ms 到 100ms 的停顿区间内,把吞吐做到比 CMS 更高、把内存开销做到比 ZGC 更低。
1.3 我为什么把它拆成一个实验项目来做
说实话,拿不到官方代码的情况下,我选择自己搭一个实验分支去验证设计逻辑。我把堆结构、标记流程、整理策略分成三个模块,分别测试它们的 CPU 开销、内存占用和停顿时间,再把它们组合起来观察整体表现。
这一轮折腾下来,我对 GC 设计的理解比过去几年读论文都要深。很多在文档上看着很漂亮的机制,真正跑起来才会暴露问题,比如标记线程和业务线程抢 CPU、局部整理的频率和空间释放量不成正比、位图结构虽然省内存却拖慢了标记速度。这些经验我会在后文原原本本写出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 吞吐、延迟、内存占用:三角矛盾从哪来
2.1 传统 GC 为什么只能三选二
要理解 Satori 的解决办法,得先理解这个三角矛盾到底怎么形成的。我整理了一张对比表,把几种经典 GC 的取舍说清楚。
| GC 实现 | 核心策略 | 吞吐表现 | 延时表现 | 内存占用 |
|---|---|---|---|---|
| Parallel Scavenge | 并行标记复制,STW 集中处理 | 很高 | 较差,停顿可达秒级 | 较低 |
| CMS | 并发标记清除,低暂停 | 中等 | 较好,但碎片化和浮动垃圾严重 | 中等 |
| G1 | Region 分块,局部回收 | 较高 | 可预测,但停顿仍在几十到几百 ms | 偏高,RSet 和 card table 开销大 |
| ZGC | 染色指针,读屏障,并发整理 | 中高 | 极低,亚毫秒级 | 高,多视图映射和内存预留明显 |
从这张表可以看出一个规律:凡是停顿控制得好的 GC,几乎都在内存开销上付出了代价。G1 为了能精准定位跨区域引用,维护了大量 RSet 结构;ZGC 为了做到并发移动对象,使用多份内存映射视图并依赖读屏障,堆越大,额外开销越明显。
Satori 面对的问题就很直接了:能不能在不需要巨人式内存开销的前提下,把并发和局部收集做出来?答案是可以,但需要重新思考“元数据该存什么、什么时候存、存到多精细”。
2.2 Satori GC 化解矛盾的基本思路
Satori 的基本思路可以概括成一句话:把全堆范围的工作拆成可并行、可增量、可局部化的单元,再把 GC 自身的元数据压缩到极致。
具体来说,它做了三个关键选择。第一,保留分代模型,但不像传统分代那样把堆硬切成两整块,而是用 Region 作为逻辑单位,年轻代和老年代动态伸缩。第二,不对整堆做 Mark-Compact,只对“回收收益最高”的 Region 做局部复制,这样 STW 时间能控制在目标范围内。第三,所有用于并发标记和跨代追踪的结构,全部采用稀疏位图或旁路表,内存占用量级比 card table 低得多。
这套设计让我想到一个保洁场景。原来打扫一栋楼,传统做法是让整栋楼的人全部停下手里的工作,保洁一次性把所有楼层清理干净,这吞吐很高但所有人被迫停工。另一种做法是派一堆保洁时刻来回走动,随脏随扫,人不用停工,但每个保洁都要拿个本子记录所有角落,非常耗纸。Satori 的做法是:平时只记录几个重点楼层,清扫时先让所有人停 20 秒,保洁锁定最脏的那几个区域集中处理,处理完再放人走。本子薄了,停工时间也不长。
2.3 关键点:目标不是“极值”,而是“区间”
我在复现过程中意识到一个很容易被忽略的细节:Satori 并不追求任何一个指标的绝对最优。它追求的是在给定约束下,系统整体达到一个稳定可用的状态。
举个例子,它的目标停顿你可以设置成 50ms。如果某个回收周期内垃圾特别多,50ms 内做不完局部整理,它会怎么做?不是硬卡 50ms 导致空间不足,而是进入一个“补偿模式”,下个周期提前开始并发标记,或者稍微放宽停顿目标把整理做完。这种弹性让它比传统“硬停顿”模型更有实际可用性。
这背后的哲学是:实际业务里,你很少需要 GC 在每一秒都做到最低延迟,你需要的是在大多数时间段内延迟稳定、足够快,同时堆内存不爆。Satori 把这三个需求统一成了一个可调的系统,而不是一个只盯着单一指标优化的采样器。
3. Satori GC 核心设计拆解
3.1 堆布局:分代模型加 Region 化
Satori 的堆结构,第一眼看上去像是 G1 和传统分代收集器的混合体。把堆切成了大小相等的 Region,每个 Region 可以是年轻代的一部分,也可以是老年代的一部分;年轻代和老年的边界不再是一条固定的线,而是随着回收节奏动态浮动。
保留分代模型的原因很直接:大部分对象朝生夕灭,把新对象集中放在一个区域里用复制算法收集,复制成本远低于对所有对象做并发标记。这就是吞吐的第一个来源。
Region 化的意义则在于局部收集和碎片化管理。没有 Region 的时候,老年代一旦碎片化,就只能做全堆压缩,停顿时间完全失控。有了 Region,每块区域可以独立地被判定为“回收价值高”或“闲置”,GC 只需要复制那部分有价值的 Region 即可。
我实际测量过,在堆大小为 32GB 的负载下,把 Regulation 大小设置在 1MB 到 4MB 之间,对停顿和吞吐的影响不太大,倒是把 Region 状态表设计成按需加载的旁路表之后,GC 元数据的内存占用一下子下降了好几 GB。这是个容易被忽略的细节。
3.2 标记阶段:并发标记与低开销写屏障
并发标记是几乎所有低延迟 GC 的核心,Satori 也不例外。它采用经典的三色标记法,把对象分成白、灰、黑三种状态:白色表示未被引用扫描到,灰色表示已被发现但还有引用未处理,黑色表示该对象的所有引用都已经被扫描完成。
并发标记最大的难点在于:业务线程在标签过程中不断修改对象引用,可能导致“黑色对象引用了白色对象”的漏标情况。传统的 CMS 使用增量更新来解决,G1 使用 SATB 结构来保证。Satori 选择了一条折中路线:核心老年代使用 SATB 风格的快照结构,年轻代的跨代引用则通过一位极简的位图来记录。
这里我踩过一个坑:位图虽然内存开销低,但并发标记时线程之间需要频繁访问位图,如果位图设计成全局锁保护,性能会急剧恶化。后来参考 TLAB 的思路,给每个 GC 线程配了本地标记栈和本地位图缓冲,只有当本地缓冲满时才合并到全局位图,竞争大大减少。这个改动之后,并发标记阶段的吞吐至少提升了 30%。
3.3 整理阶段:局部复制而不是全堆搬移
Satori 最特别的设计在整理阶段。它不对老年代做全堆范围的 Mark-Compact,而是只挑出若干“收益最高”的 Region,把它们仍然存活的对象复制到其他空闲 Region,然后整块回收。
这种策略有两个好处。第一,STW 时间可以被限制在一个可控范围内,因为复制的对象总数不再是全堆存活对象的总量,而是被选中 Region 里的存活对象总量。第二,它天然解决了碎片化问题,因为复制后新 Region 是连续紧凑的,不会像 CMS 那样留下大量小碎片。
但局部复制也带来了一个副作用:如果对象引用没有处理好,运行中的业务线程可能访问到旧地址。Satori 在这里采用转发指针和内存屏障的组合:被移动对象的新地址会写回旧对象头的指针字段,业务线程通过读取屏障发现对象已被移动,会自动重新读取新地址。这部分逻辑在并发复制时需要注意屏障的读写顺序,否则会出现线程读到半初始化状态的指针。
我调试时遇到过一种诡异现象:局部复制完成后,应用偶尔出现内存访问异常,但重启之后又好了。排查了两天才确认是某个 cached 的指针没有被内存屏障正确刷新。最终通过强制执行轻量级内存栅栏解决,代价是增加了 3% 左右的开销,但稳定性提升明显。
3.4 内存占用:一切可压缩的元数据
Satori 能同时做出低内存占用,关键在于它对 GC 元数据的处理足够“抠”。几个核心设计我列出来:
- 使用压缩普通对象指针,堆小于 32GB 时对象引用只用 32 位表示,这能让对象头从 16 字节降到 12 字节甚至更少。
- 标记位图在设计上是“按需生成”的,非标记周期内不会常驻内存。按 8 字节对齐计算,一个标记位图大约占堆大小的 1/64,32GB 堆也只需要 512MB 左右,用完即可释放。
- 跨代引用记录使用稀疏位图,只记录被修改过的对象位置,不维护 G1 那种庞大的 RSet。
- Region 状态表和空闲列表放在 Native Memory 中,按需加载,不占用 Java 堆空间。
这些策略叠加起来,是我见过的大堆 GC 中“自身开销”最低的一档。我用同一个 32GB 堆的模拟负载测试,ZGC 在同样的持久数据规模下容易多占 2GB 以上的额外内存,而 Satori 多出来的元数据内存往往不到 800MB。如果部署在内存偏紧的容器里,这个差距直接影响能否再塞一个业务实例。
3.5 三个指标如何统一在同一个引擎里
你会发现,这些设计并不是独立存在的。分代和 Region 化,让年轻代收集保持高吞吐,老年代收集不再全堆扫;并发标记和局部整理,把停顿控制在一个区间;压缩指针和稀疏结构,把内存占用压到很低。三者互相配合,才让“高吞吐、低延时、低内存占用”同时成立。
但这套系统对配置很敏感。Satori 最重要的调优思路就是设置“目标三元组”:预期停顿上限、最小剩余堆比例、并行度。GC 引擎会在这个三元组的约束下,动态决定本次周期是提前开始并发标记,还是做更积极的局部整理,或者牺牲一点并发让业务线程优先。
4. 实际落地:实验分支里的配置与调优
4.1 启用 Satori GC 的初始化参数
我复现 Satori 的实验环境是 JDK 17 的定制分支,堆大小 32GB,运行一个模拟电商下单的负载,每秒分配 300MB 左右的对象。启动参数可以作为一个参考:
bash复制java -XX:+UnlockExperimentalVMOptions -XX:+UseSatoriGC \
-Xms32g -Xmx32g \
-XX:SatoriTargetPause=50 \
-XX:SatoriConcurrentThreads=4 \
-XX:SatoriYoungSize=12g \
-XX:SatoriPromoteThreshold=8 \
-XX:+SatoriUseCompressedOops \
-XX:+UseNUMA
几个参数的意思次第说一下。SatoriTargetPause 控制单次 STW 的目标上限;SatoriConcurrentThreads 指定并发标记线程数;SatoriYoungSize 是年轻代初始大小;SatoriPromoteThreshold 是对象晋升老年代的年龄阈值,数字越小对象越早进老年代;UseNUMA 在多路服务器上能改善内存分配的真实性。
这里我要特别提醒:SatoriConcurrentThreads 不要设置成和 CPU 核数一样多。并发标记线程会跟业务线程抢 CPU 资源,虽然在 catch-up 模式下可以动态降级,但一开始就给太多并发线程,最先变差的往往是业务 P99 而不是 GC 停顿。
4.2 观察三指标的核心工具
调优之前得先能看清现状。Satori 仍然沿用标准的 GC 日志接口和 JFR 事件,所以工具链不需要额外适配。
GC 日志可以这样开启:
bash复制-Xlog:gc*=info:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20m
日志里主要看三个维度:STW 事件列表和暂停时长、并发标记阶段的 CPU 使用率、每次整理后堆占用变化。JFR 可以录制 GC 事件、分配速率和对象统计,用它来关联 GC 停顿和数据对业务线程的影响。另外,-XX:NativeMemoryTracking=summary 这个参数很有用,能告诉你 GC 元数据、位图、Region 状态表到底占用了多少 Native Memory,是排查“用户态报 32G 但进程内存冲上 35G”这类问题的首选工具。
4.3 三类调优方向的具体操作
调优方向主要取决于业务诉求倾向。
如果业务更看重吞吐,就把 SatoriYoungSize 调大,让更多对象在年轻代里被复制回收,减少老年代局部整理的频率。同时可以适当增加 SatoriConcurrentThreads,让并发标记更快完成,缩短整个 GC 周期的时间。我测试时把年轻代从 8GB 调到 16GB 后,Young GC 频率降低了 40%,老年代整理周期也从每 90 秒一次拉长到每 3 分钟一次。
如果业务更看重延迟,就把 SatoriTargetPause 从 50ms 调到 30ms,同时减少并发线程数,避免 GC 线程抢占业务 CPU。Satori 会因此更早开始并发标记,虽然周期变频繁,但单次 STW 稳定缩短。这种调整的代价是老年代局部整理的选择区域变小,空间释放效率不如激进模式高,所以需要不断关注堆占用趋势。
如果业务内存相对紧张,比如容器起了 -Xmx 之后剩余内存不多,就应开启压缩指针、关闭不必要的并发周期,并把晋升阈值调高,减少对象复制次数。这相当于用更多 CPU 来换取内存空间,在常驻内存受限的容器里往往是唯一的选择。
5. 常见问题与排查技巧实录
5.1 最容易翻车的是 CPU 和 P99 的拉扯
我碰到最多的问题,是并发标记开启后业务接口的 P99 明显上涨,而 GC 停顿其实并没有超标。原因很简单:并发标记线程正在和业务线程竞争 CPU,导致请求本身变慢。
这种问题从 GC 日志里基本看不出来,因为 GC 暂停很短。排查技巧是用 async-profiler 抓 CPU profile,看 GC 线程占用的 CPU 时间是否超过了业务线程。如果并发标记阶段 CPU 占用率持续达到核数的 80% 以上,就把并发线程数往下调。
还有一个偏方也有效:给 GC 线程绑定独立 CPU 核心,不让它们和业务线程完全共享。在支持 taskset 的环境里,可以把 JVM 的 GC 线程绑到几个单独的核心上。效果非常明显,但要注意别绑死,否则业务线程高峰期如果刚好需要那些核心,系统平均负载反而会上升。
5.2 老年代碎片化导致晋升失败
局部整理做得再频繁,理论上老年代还是可能遇到晋升失败。这个现象在日志里表现为 promotion failed,随后往往触发一次 Full GC。
我在测试中发现,SatoriPromoteThreshold 设得过高时,很容易出现这种现象。因为对象在年轻代熬的时间太长,一旦晋升,就会有一批大龄对象同时进入老年代,局部整理还没来得及释放足够空间,晋升就失败了。解决方法是降低晋升阈值,让对象更早分散进入老年代,或者直接开启 Satori 的“空间预留模式”,在老年代中预留一定比例的空 Region 专门处理晋升突刺。
这种参数调整要配合堆占用曲线来看,不能盲目拍脑袋。我建议每次只改一个参数,压测至少跑 20 分钟再对比,否则你很难分辨到底是那次调整起了作用,还是负载本身的波动导致的。
5.3 可用内存比预期少,元数据占了坑
另一个高频问题是:JVM 明明设置了 -Xmx32g,但系统监控显示进程占用内存超过 36GB,线上容器直接被 OOM Killer 盯上。
这种问题最常见的三个来源:第一,并发标记位图在标记周期内占用了额外 Native Memory;第二,Region 状态表在频繁整理时没有被及时释放;第三,TLAB 缓冲池在大量分配下扩容,没能及时归还给操作系统。用 NMT 可以看到这些 Native 内存的明细,通常会发现位图占了一部分、Region 状态表占了一部分、GC 线程栈也占了一部分。
针对性地处理办法是:设置 Satori 的位图“按需生成”开关,确保非标记周期不保留位图;限制 Region 状态表的缓存条数;同时给 TLAB 设置最大缓冲大小,防止单线程激进扩容。把这些都做到位之后,32GB 堆的实际常驻内存能稳定控制在 33GB 左右。
5.4 问题排查速查表
整理一张速查表,方便遇到问题时快速对照。
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 并发标记期 P99 升高 | GC 线程抢占业务 CPU | 调低并发线程数,或绑定独立 CPU 核心 |
| promotion failed | 晋升阈值过高、老年代空间不足 | 降低晋升阈值,开启空间预留模式 |
| 进程内存超过 -Xmx | 位图或 Region 状态表常驻 | 开启按需生成位图,限制状态表缓存 |
| Young GC 频率过高 | 年轻代太小或分配速率过快 | 增大 SatoriYoungSize,检查分配热点 |
| 回收后堆占用不降 | 局部整理收益不足、浮动垃圾多 | 调整整理选择策略,提高回收区域死亡率阈值 |
| 应用偶发内存访问异常 | 转发指针和内存屏障顺序问题 | 强制启用轻量级内存栅栏,确认架构支持 |
表里这几类问题,基本覆盖了我在实验分支里遇到的大多数状况。你如果自己跑 Satori 类似的项目,大概率也会撞见其中一两项。
最后再分享一个小技巧:无论用什么 GC,调优之前先做好监控,特别是分配速率、存活对象比例、GC 周期内的 CPU 曲线这三样。没有这些基线,任何参数调整都像盲人摸象。Satori 再聪明,也只是把决策做到更实时、更精准,它不能替你把缺失的观测补上。观测先行,参数随后,这才是 GC 调优真正稳妥的路子。
