Zero GC Was Always the Goal
内存管理 vs C++/Rust/Java — 以及为什么生产中最快的 Java 几乎不使用回收器
Java 性能世界的顶端有一个肮脏的秘密:生产中最快的 Java 几乎根本不用垃圾回收器。那些交易所、那些交易系统、那些低延迟团队 — 他们写的 Java 在稳态下不分配任何东西。对象池、预分配的环形缓冲区、套在 ByteBuffer 上的享元、到处都是的原始类型数组、把 -Xmx 调到年轻代在交易时段永远不会填满。GC 不是他们的内存管理器;它是一种他们要设法绕过的负担。Java 的全部承诺 — "不用想内存" — 恰恰是它最快的用户负担不起的承诺。
SuperJ 把这个观察推到了它的结论。如果 zero-GC 本来就是高性能 Java 的终点,那就让它成为起点 — 并且让它变得容易。
管理内存的四种方式
每一门系统语言都是对同一个问题的回答:谁来证明内存被正确使用?
C++ 信任你。 生命周期由你来推理;RAII 有帮助,智能指针帮助更大,然而 use-after-free 和 double-free 仍然是这个产业里最关键性能的代码库中最稳定地被交付的两个 bug。性能天花板无可匹敌。错的爆炸半径也一样,因为错是静默的 — 指针仍然指向某个地方。
Rust 让你证明它。 借用检查器把生命周期推理变成一个类型系统,整整一类 bug 变得不可能。代价是按行、永远地付出的:生命周期标注、借用图的谜题、当检查器看不见你能看见的东西时的引用计数逃生舱。当单个内存 bug 是灾难性的时候,这是正确的权衡 — 浏览器、内核、处理恶意输入的解析器。对于一个在负载均衡器后面解析自有线协议格式的服务器来说,它是为一种你多半没有的威胁模型付出的大量证明。
Java 推迟它。 回收器会在运行时把它搞定。而它确实做到了 — 出色地,对 Java 被设计来做的那些软件。但推迟有一条成本曲线:分配很便宜,直到回收不再便宜,而尾延迟恰好落在一个高性能系统藏不住的地方。这正解释了为什么快的 Java 会收敛到上面那个悖论:一门垃圾回收的语言,写得让它没有垃圾。
SuperJ 由结构来决定它。 没有回收器。new 从 arena 分配;默认 arena 和进程活得一样久;一个带作用域的 arena {} 块给一批分配一个共享的生命周期,在闭合花括号处以 O(1) 释放 — 而编译器在编译时拒绝任何会逃逸出该块的引用。小的固定大小的临时对象用 local 放在栈上。没有东西被追踪,没有东西被扫描,没有东西会暂停。
这里的断言不是说这比 Rust 更安全,或比 C++ 更强大。断言是说它对它所瞄准的软件来说比这三者都更简单 — 而这种简单本身就是一个性能特性,因为你能真正看见的内存策略才是你真正能优化的内存策略。
一个线程改变了一切
这是让整个设计咔嗒落位的那块,也是那块在宣传册上看起来最像限制的东西:SuperJ 是严格单线程的。 你用进程扩展,而不是线程。
对内存管理来说,那不是约束。那是许可。
想想那个不起眼的 scratch 缓冲区 — 每个零分配代码库、每种语言里的主力:
static double[] scratch = null;
static double[] ensureScratch(int n) {
if (scratch == null || scratch.length < n) scratch = new double[n];
return scratch;
}
在多线程 Java 里,这个无辜的模式是一个 bug。于是你伸手去够 ThreadLocal(每线程一份拷贝、每次访问一次查找开销),或者一把锁(竞争),或者一个带并发 free list 的对象池(一个小型科研项目),或者你把一切都做成不可变然后照样分配(你好,GC)。高性能 Java 里相当大一部分复杂度不是算法 — 而是那套在多线程间安全共享可变 scratch 状态的机器。
单线程下,这个模式就是… 正确的。每个用途每进程一个缓冲区。每个调用点共享它。没有锁,没有原子操作,没有线程本地变量,没有防御性拷贝。一个基于 SuperJ 构建的数据库里的表写入器把一张 83 列的表推过一个 int 缓冲区、一个 long 缓冲区、一个 double 缓冲区 — 因为列 c 在列 c+1 覆写 scratch 之前已经完全写到了磁盘。让这一切成立的不变式是一个顺序契约,不是一把锁:"在下一次调用之前消费完。"你在缓冲区定义处的一句话注释里写下它,而它由那里唯一存在的那一个线程来执行。
那就是这个设计的重心:最大化复用,因为复用终于安全了。 其余都是技巧。
分配阶梯
这套纪律装得进五个横档,按顺序尝试:
- 不要分配。 返回一个原始类型。填充调用者拥有的数组。把字符串预先算好一次。
local— 把一个小的、固定大小的临时对象放栈上。作用域退出时释放,零成本。- 复用一个 scratch 缓冲区 — 用于任何大小由数据决定的东西。分配一次,按需增长,永远不收缩。
- 一个
arena {}块 — 用于一批只有一个生命周期的中间对象。O(1) 释放,由编译器做逃逸检查。 - 全局 arena — 用于真正永远活着的东西:表、缓存、scratch 缓冲区本身。
注意缺了什么:一个 free、一个析构函数、一个生命周期标注、一个池的实现。这个阶梯是一组决策,不是机制 — 而每一个决策都在源码里可见。当一个审查者(人或其它什么)在一个按行的循环里读到 new double[n],不需要全局分析就知道它错了。那是横档 1 到 3 的地盘,而修法是机械的。
数字,因为这不是理论
这个月有一个列式数据库用 SuperJ 构建,由一个 AI agent 逐个撞上这里每一课。前后对照的账本:
| 那行无辜的代码 | 代价 |
|---|---|
| 每次查询物化一整列 | 22.8 GB 峰值 RSS |
| 每次写调用对表做一份排序拷贝 | 每次调用 1.3 GB |
每列每分区拼接一个路径 String | 每次查询无界增长 |
| 一个大小来自参数的"栈"缓冲区 | 期望 6.4 MB 实际 520 MB |
最后一条值得单独一段,因为它是这门语言今天最锋利的边:local 只有在大小是编译期常量时才栈分配。写 local int[] buf = new int[4096] 你得到一个栈缓冲区;写 local int[] buf = new int[n] 而 local 被静默忽略 — 一次普通的堆分配,没有警告,结果正确,外加一个随你循环次数增长的泄漏。实测:同一个跑 20,000 次的循环用字面量峰值 6.4 MB,用参数 520 MB。由于真实缓冲区的大小通常来自数据,真正有用的那个情形恰好是坏掉的那个 — 这恰恰是阶梯第 3 级存在的原因,也是为什么文档现在用粗体这么写了。
这些失败里的每一个都靠往上挪一个横档修好了。结果,在每种情形下,都不是"更少的内存"。而是平坦的内存 — 一个进程,它的峰值 RSS 在 3 次迭代和 200 次迭代下完全相同。平坦是目标。平坦意味着第一千次查询的成本和第一次一样。平坦也是审查中的通过条件:如果峰值 RSS 随迭代次数增长,循环里就还有东西在分配,句号。
没有堆分析器也能调试
"没有 GC"传统上也意味着"没有工具" — C++ 工程师生活在 Valgrind 和 sanitizers 里,恰恰因为这门语言什么都不给他们。SuperJ 把仪器装在盒子里:
superj compile app.sj --sdk-path "$SJ_HOME/sdk" --link --mem-track --output app
SJ_MEM_LOG=app.mlog ./app
superj memlog app.mlog
内存事件日志记录每一次 arena 创建、扩张、释放 — 带引发它的源码位置和一个时间戳,通过一个 mmap 的二进制日志写入,能在 SIGKILL 后存活。当进程增长,日志点名分配点。当进程 OOM,日志就是验尸报告。默认构建零成本;一个关闭了日志的插桩构建每次事件大约付出一个布尔值的代价,所以它可以随生产交付,按需在每次运行时打开。
这带来的调试循环简单得几乎令人尴尬:在两个迭代次数下跑工作负载,比较峰值 RSS,如果不平坦就读日志 — 它指向那一行。把它和解读 GC 日志("这个增长是存活的还只是浮动的?")或者和在一周里翻一份堆 dump 比一比。确定性的分配让内存 bug 可复现,而可复现的 bug 才是那种修了就不再回来的 bug。
为什么这对 agent 重要,不只是对人
还有第二类受众,这套设计对他们而言近乎最优,而它不在最初的推销词里:写系统代码的 AI agent。
一个在 C++ 里工作的 agent 必须在脑子里拎着一个全局不变式 — 谁拥有这个指针? — 跨越每一次编辑,而弄错的失败模式是三个函数之外的静默腐败。一个在 Rust 里工作的 agent 必须满足一个证明器,它的错误消息假定一种心智模型,而在每一个上下文窗口里都得从头重建。一个在 Java 里工作的 agent 撞上一堵隐式的墙:分配没问题,回收器会处理 — 直到 p99 说不然,而源码文本里没有任何东西指向原因。
SuperJ 的内存模型是在源码文本里可读的。每一条要紧的规则都是局部且机械的:
- 循环里一个
new是一面旗子,在 diff 里可见。 - 从
arena块里逃逸是一个编译错误,不是代码审查的捕获项。 - scratch 缓冲区的契约是定义处的一句注释。
- 通过条件是一个数字:跨迭代次数的平坦 RSS。
- 失败归因是自动的:memlog 点名那个位置。
这五条性质让这套纪律可由 agent 在循环中检查 — 写、在两个迭代次数下跑、diff 峰值 RSS、读日志、修被点名的行。没有全局推理,没有和证明器的对话,没有部落知识。上面那个数据库项目恰恰就是这么构建的:agent 生成了表里的那些内存失败,而同一个 agent,被给了阶梯和日志,修好了其中的每一个。这些教训现在写进了手册 — 给下一个人类,也给下一个 agent,他们从这个 agent 停下的地方起步。
重述这个赌注
高性能 Java 已经证明了 JVM 最拿手的那个戏法是可选的:它最快的用户手工写出无分配代码,逆着语言的纹理,用的是为另一个世界设计的工具。SuperJ 的赌注是纹理应该反过来走 — 对服务器、流水线、数据库、和网络系统:
- zero-GC 应该是默认的惯用法,而不是专家的反制手段;
- 每进程一个线程 是那个让激进复用变得安全而非英勇的简化;
- arena 加上一套习惯的阶梯 覆盖了析构函数、借用检查器、和回收器各自被发明来覆盖的东西 — 只用一小部分认知代价;
- 而确定性加上内建的事件日志让剩下的 bug 变成那种无聊的、可复现的、能修好的。
你放弃了回收器的便利和借用检查器的证书。你得到了 Java 的工效学和 C 的稳态内存画像,配上一套简单到一页纸就能教会的纪律 — 教给一个初级工程师,或一个语言模型。
平坦是目标。其余都是一道阶梯。