一个大 Page 里可以包含很多个小 Region,而 GC 可以围绕这些 Region 做更细粒度的管理决策。
这里 Region 很重要,因为在 Satori 里,Region 不只是物理切分方式,它还是:
分配单位
线程本地所有权单位
Gen 0 局部回收单位
移动和整理的规划单位
空闲内存归还时的处理单位
很多 GC 虽然也会把堆切成很多小块,但这些小块更多只是方便调度。Satori 不一样,Region 本身就是它的核心抽象。
Satori 在 Page 和 Region 周围维护了不少紧凑的元数据。Page 里会有卡表、Region 映射和更粗粒度的卡组信息;Region 里则会有位图,记录对象的关键状态,例如是否已标记、是否已逃逸、是否被固定。这些元数据决定了 Satori 后面能不能高效地做线程本地回收、逃逸跟踪和局部压缩整理。
把 Gen 0 变成本地问题
Satori 最关键的想法可以用一句话概括:如果一批新对象几乎都只在线程内部短暂存在,那为什么回收它们时一定要把全世界都叫停?
Satori 在分配小对象时,仍然有每线程的快速分配上下文,所以正常情况下分配非常直接。但它比传统路径多做了一层非常关键的设计:线程不是随便向全局堆索要空间,而是尽量在自己手里的 Region 里分配。
如果当前 Region 还有空间,事情很简单,继续往里放对象。如果当前 Region 快没空间了,Satori 的第一反应也不是立刻说“这块用完了,交给全局 GC,再去拿一块新的”。它会先判断这个 Region 还适不适合在线程内部自己清理一下。
这背后的前提是现实程序里非常常见的一种模式:
对象是刚创建的
对象生命周期很短
对象主要只在当前线程里流转
如果一块 Region 满足这些条件,那么它的回收就没有必要上升为全局问题。当前线程可以在有限的范围内自行完成这块 Region 的 Gen 0 回收。这就是 Satori 的 thread-local Gen 0。
普通 Gen 0 回收虽然也是在回收新对象,但通常仍然需要站在整个进程角度来想问题:所有线程现在是什么状态,老对象里哪里可能指向新对象,哪些卡表需要扫描,哪些全局结构需要同步。
Satori 的 thread-local Gen 0 则把问题收缩成:
当前线程自己的栈上还拿着哪些对象
当前 Region 里哪些对象已经和外界产生关系
正是因为它把范围限制得很小,局部回收才变得现实。
逃逸跟踪
Satori 用逃逸跟踪来判断一个 Region 还能不能继续保持 thread-local 特性。
一个对象一开始可能只在线程内部使用,但之后完全可能逃逸到别处。例如:
放进全局缓存
挂到别的线程也能访问到的对象上
丢进任务队列,之后由其他线程继续处理
一旦发生这种事,这个对象就不再是纯线程本地的了,它已经和外界建立了联系。这就是逃逸。
线程本地回收成立的前提是这个 Region 里的大部分对象真的主要属于当前线程。但如果对象不断逃逸出去,那这个 Region 的线程局部性就会越来越弱。继续强行按线程本地方式处理,只会越来越不划算。
所以 Satori 的做法不是假装线程本地永远成立,而是显式跟踪逃逸。一旦某个对象逃逸,Satori 不只会记住这个对象本身,还会沿着这个对象在当前 Region 里的引用,把仍然因此对外可达的对象也一并纳入考虑。
但如果只是少量对象逃逸,线程本地回收仍然很值得做,因为整体上这个 Region 依然主要由当前线程使用。真正的问题是逃逸越来越多时怎么办。
Satori 在这里设了一个很现实的阈值:当逃逸量大到一定程度时,就不再把这个 Region 当成 thread-local Gen 0,而是把它转入更全局的代际管理。
这个阈值的意义很明确:
如果只要一发生逃逸就立刻放弃 thread-local Gen 0,那么很多本来仍然很划算的场景也会失去收益
如果无论逃逸多少都坚持 thread-local Gen 0,那么局部回收又会变得越来越不划算
Satori 选择的是在这两者之间取一个平衡点。
线程本地回收
理解了 thread-local Region 和逃逸跟踪以后,就可以看线程本地回收本身了。
它之所以有机会快,不是因为它做的事情更少,而是因为它处理的范围更小、要看的根更少。
1. 判断回收的必要性
假设一个网络请求在线程 A 上处理。这个请求会创建很多短命对象,例如请求上下文、路由匹配结果、解析数据时的中间结果、若干临时字符串和列表。
在 Satori 里,这些对象很可能先进入线程 A 当前持有的某个 Region。
如果请求结束后,这些对象都没有被放进全局缓存,也没有被交给别的线程,那么当这个 Region 空间变紧时,线程 A 完全可以先做一次局部回收,把这批短命垃圾清掉,然后继续在原 Region 里分配。
如果请求处理中有一部分对象被放进了全局缓存,或者被封装成任务交给线程池里的另一个线程,那这些对象就发生了逃逸。
如果只有少量对象这样做,Satori 仍然可以维持这个 Region 的线程本地特性,因为整体上它依然主要由线程 A 使用。但如果这种共享越来越多,最后逃逸量超过阈值,这个 Region 就不再适合继续走 thread-local Gen 0 的路线。它会退出私有状态,转入更全局的 GC 流程。
这就是 Satori 的基本策略:能在线程本地解决,就尽量在线程本地解决;一旦局部性不再成立,就及时退回全局路径。
全局 GC