内存管理最佳实践
SuperJ 没有垃圾回收器。new 从一个 arena 分配;默认 arena 是全局的,它存活到进程退出。这给你以 bump 指针速度分配和零停顿 — 作为交换有一项义务:除非你自己动手,否则不存在短生命周期对象。 热路径上的一个 new 不是"回收器会吸收的一点压力";它是一个带速率的泄漏。
本页讲实践:如何让内存使用平坦 — 分配策略阶梯、arena 与栈工具、scratch 缓冲区手册,以及证明你的进程行为良好的内建日志。
目录
1. 关键洞察:单线程是一种许可
SuperJ 程序是严格单线程的(你用进程而非线程扩展 — 见 并发)。对内存而言这不是限制;它是一种许可,移除了多线程代码付出的一整类成本:
- 一个
static可变 scratch 缓冲区是安全且正确的。 每个进程每个用途一个缓冲区,被每个调用点共享。无锁、无原子、无 thread-local、无每线程池、无防御性拷贝、无为安全的不可变化。 - 复用可以激进。 一个表写入器可以用一个 int、一个 long 和一个 double scratch 缓冲区推过一张 83 列的表 — 因为列 c 在列 c+1 覆盖它之前已被完全消费。这只有在单线程下才成立,而它确实成立,所以这么做。
- 顺序契约取代锁。 当一个缓冲区跨调用点共享时,不变式是"在下一次调用前消费"。在缓冲区定义处以实际依赖的措辞写下来 — 例如调用者必须把返回的列写入磁盘后再调用。那条注释是承重的。
- 代价转移到另一个 bug 类。 共享缓冲区不会竞争,但它可能过时或过大 — 见 §4 的显式计数规则。
下面的一切都是利用这种许可的技巧:目标是让一个进程的内存足迹平坦,无论它服务多少请求、查询或迭代。
2. 分配阶梯
当你准备写 new 时,按以下顺序尝试:
- 不分配。 大多数分配可以避免:返回原语而非包装器;填充调用者拥有的数组而非返回新数组;从映射文件读两个 long 而非物化一个索引对象;字符串预计算一次而非每次拼接。
local— 栈分配一个小的、可证明不逃逸的临时量(§5)。作用域退出时释放,零成本。下面两个尖锐注意事项。- 复用一个长寿 scratch 缓冲区 — 对任何由数据定尺寸的东西,这是答案(§4)。分配一次,跨每次调用复用;按需增长,永不收缩。
- 一个
arena块 — 用于一批你想一起、以 O(1) 丢弃的作用域中间量,逃逸在编译时检查(§3)。 - 默认堆(无
arena块时的默认)— 仅用于真正与进程同寿的东西:预计算表、scratch 缓冲区自身、长寿索引与 cache。"全局 arena"是通常的简称,但默认其实是三个域,其中只有一个是那个 arena — 见下文 §2.1。
来自一个基于 SuperJ 构建的数据库项目的真实数字,每一个都是循环中一个看起来无害的 new:每次查询一个物化列 — 峰值 22.8 GB RSS;每次调用一个排序的表拷贝 — 每次调用 1.3 GB;每列每分区一个路径字符串 — 随查询数无界增长。每一个都靠在阶梯上爬一档修好了。
2.1 默认是三个域
第 5 档通常叫"全局 arena",在你要测量它之前这是个有用的简化。在任何 arena {} 块之外,你得到哪个分配器取决于你分配什么:
| 你写的 | 分配器 | 是否释放 | 被 System.memReservedBytes() 看到 |
|---|---|---|---|
new Obj(...) | calloc | 从不 | 否 |
new T[n] | sj_galloc → 全局 arena | 从不 | 是,但仅在保留新 chunk 时 |
一个 String body("a" + n、substring、…) | malloc | 从不 | 否 |
从所有三种编译模式(独立、预构建归档、全程序源码)发出的 IR 测量 — 三个模式中划分相同。
三者同样永久,因此阶梯建议不变。变的是你能观察什么:
memReservedBytes()和memHighWaterBytes()是仅有的常开计数器,它们测量arena chunk 保留。一个泄漏对象或字符串的程序会让它们平坦在 0。- 连数组情况也读 0,直到全局 arena 需要一个新 chunk,因为那个数是保留字节,不是已用字节。
所以一个平坦计数器并不证明没有泄漏。当你追增长时用 --mem-track 构建(§6)— 它开启每域计数器,包括 malloc 域,否则它被编译掉。
在一个 arena {} 块内三者都汇聚到该块的 arena — 对象、数组和字符串 — 全部在 drop 时回收。这就是块内分配会动计数器而默认分配不会的原因。
3. 利用 arena
一个 arena 块给一批分配一个共享的、作用域的生命周期:
int result;
arena temp {
Buffer buf = new Buffer(1 << 20); // 从 temp 分配
Index idx = new Index(buf); // 这也是
result = idx.lookup(key); // 原语自由逃逸
} // temp 在这里 drop — O(1),所有 chunk 一次性释放,无扫描,无停顿
让它成为被检查的工具的原因:
- 逃逸是放置,不是 use-after-free。 一个会超越块寿命的作用域对象引用(被返回、存入外层变量或字段)被放置到一个确实超越它的区域 — 若留在帧中则上栈,若离开则上长寿 arena。不被拒绝,不是悬垂指针。逃逸分析在每次构建上运行。以原语或填充存活于块外的缓冲区的方式取出结果仍是保持块内存平坦的好实践 — 只是它不再是正确性要求。
- 放置让你正确,而非小 — 而编译器现在会告诉你。 因为逃逸合法,一个所有东西都逃逸的块干净编译、正确运行并回收一无所有:对一个其值都在作用域内消亡的同样块测得 12.6 倍峰值 RSS(82.9 MB 对 6.6 MB,2.5M 次分配,#3193)。那个形状过去是
E_ARENA_ESCAPE错误,所以不可能错过;现在它是正确代码,所以编译器改为告警 —W_ARENA_NO_SAVINGS,默认开启,--no-warn-arena可静默。它只在块中每个分配都逃逸时触发;作用域十个临时量并逃逸一个结果是预期惯用法,保持安静。要当心的回归是一个今天正确作用域的块,在有人于其中加一个cache.put(...)后变得无用。 - 每个出口都 drop arena — 从结尾掉落、
return、从块内break或continue(带标签或不带)都回收它。一个离开外层arena {}块的带标签break/continue在出去的路上 drop 它,先展开任何中间的try-finally。 - 作用域是词法的,非动态的。 一个
new归属于它被写在其内的 arena。从arena块内调用的方法仍从全局 arena 分配,除非它自己开一个块。两个值得内化的后果: - 你块内的库调用不被它作用域化。 写在
arena块内的helper(x)从全局 arena 分配,因为被调用者可能存储或返回它制造的东西。被作用域化的是块在文本上包含的每个分配 — 包括字符串:写在块内的"a" + b属于该块(#3101),因此不能逃逸出它。在任何块之外,字符串进入从不释放的全局 arena,所以在热路径上预计算它们一次并索引,或声明noalloc让编译器证明你做到了。 - 一个可复用的类不能每次调用都分配并指望调用者作用域化它。 把 API 设计成接收调用者拥有的缓冲区(填充,而非返回),或内部以
reset()池化。
当中间量真的是一批共享一个生命周期 — 一次解析、一个请求、一个查询计划 — 时用一个 arena 块。对于一个每次调用都需要的缓冲区,不要每块重建;把它上移到 scratch(§4)。
4. Scratch 缓冲区:复用手册
平坦内存的主力。分配一次(全局 arena — 第 5 档为第 3 档买单),按需增长,永不收缩,永远复用:
static double[] scratch = null;
static double[] ensureScratch(int n) {
if (scratch == null || scratch.length < n) scratch = new double[n];
return scratch;
}
实践中有意义的模式:
文件读入调用者拥有的缓冲区。 每次调用把整个文件读入一个全新 byte[] 会按文件大小泄漏。把一个 scratch 定到批次中最大文件的大小,然后每次读都复用它:
long maxLen = 0;
for (int i = 0; i < paths.length; i++) {
long sz = new File(paths[i]).length();
if (sz > maxLen) maxLen = sz;
}
byte[] scratch = new byte[(int) maxLen];
for (int i = 0; i < paths.length; i++) {
columns[i] = openColumn(paths[i], scratch); // 填充,从不分配
}
显式计数规则。 一个 scratch 缓冲区只保证 >= n — 它通常在当前长度之后带着上一次调用的数据。任何接收可能过大缓冲区的方法还必须取一个显式计数,并用它,绝不用 buf.length。在你想说"行数"时用 buf.length 会静默读(或写!)上一个调用者的数据 — 这类 bug 曾经发布过一个损坏的数据库。
复用对象用 reset(),不用 new。 一个按请求运行的 builder 或 parser 保留其内部缓冲区并暴露 reset();每次请求构造一个新的是从零重建容量并泄漏旧的。
每个用途一个 scratch,按最大值定尺寸。 不要池化许多小缓冲区;保留一个见过最大输入的。内存由曾处理过的最大项界定,而非由处理过的数量 — 这正是你想要的平坦。
把工作集定到 cache 大小,而非数据大小。 流式处理时,一个小的复用窗口(几十到几百 KB)既是常内存答案又是快的那个 — 生产和消费过程命中同一批 cache 驻留字节。测量窗口大小;不要猜。
不要在 x86 上在热循环内写一个 static scratch。 一个每迭代被读的 static scratch(查找表、预计算系数)正好。一个每迭代被写然后喂给 SIMD 内建的 static scratch 是一个别名屏障:到固定全局地址的存储无法被证明不与你读取的数组别名,因此优化器必须真的把每条 lane 存到内存再加载 — 一趟 scalar-replacement 消除不掉的往返。在一个 20M 行 kernel 上测得:static final double[4] scratch → Simd.dotDouble4 代价是带方法局部 double[4](优化器 SROA 提升到寄存器)的相同循环的 4.5 倍。static 模式正是本页为避免每次调用分配所推荐的模式 — 对一次填充后复用的 scratch,它是正确的。它只在 scratch 作为 SIMD 调用的暂存缓冲区每迭代填充并消费时才咬人。对那个形状,优先用标量累加器(与局部数组一样快,不分配)或在方法内声明暂存数组。这是仅 x86:在 ARM/NEON 上 static 版本是最快的变体,所以在 Apple Silicon 上开发没有信号 — 在目标架构上测量。
5. 用 local 做栈分配
local Point p = new Point(1, 2); // alloca — 作用域退出时释放
local int[] buf = new int[4096]; // 栈 — size 是字面量
local 把一个小临时量放栈上:零 arena 流量,作用域退出时释放。它需要初始化器且不允许用在原语上(一个普通 int 已经是寄存器)。两个注意事项,每一个都能静默地让你失去全部收益:
- size 必须是编译期常量。 带运行时 size,
local被静默忽略 — 你得到一个普通的全局 arena 分配,无告警,正确结果,以及一个随循环数增长的泄漏。测得:一个 4096-int 缓冲区的 20,000 次迭代,字面量 size 峰值 6.4 MB,size 来自参数时 520 MB。因为缓冲区通常由数据定尺寸,你想要的那个情况正是不工作的那个 — 对数据定尺寸的缓冲区,用 scratch(§4)。 local不做逃逸检查。 一个超出其作用域的local引用是未定义行为,与返回 C 栈指针完全一样 — 无编译错误,无立即崩溃,只是后来的损坏。仅当每个用法都能放在一屏内时才用它:不返回、不存字段、不交给可能保留它的被调用者。如果你一眼看不全,用arena块 — 那种形式是被检查的。
保持小 — 一个 local int[1 << 20] 是 4 MB 栈帧。并且要知道在 release 优化级别下编译器已经对标量替换了简单的非逃逸对象:循环中的 new Point(a, b) 无论你是否写 local 通常都是免费的。它在优化器折不掉的形状上才值得 — 在假设它有效果之前先测量。
6. 观察它:内存事件日志
良好内存管理的证明是跨迭代次数的平坦峰值 RSS。SuperJ 自带仪表(完整指南:内存事件日志):
# 1. 以 arena 仪表武装编译
superj compile myapp.sj --sdk-path "$SJ_HOME/sdk" --link --mem-track --output myapp
# 2. 以日志启用运行(二进制、mmap 支撑、熬过 SIGKILL)
SJ_MEM_LOG=myapp.mlog ./myapp
# 3. 读它
superj memlog myapp.mlog
日志记录带源码位置与时间戳的 arena create/expand/drop 事件 — 因此增长是可归因的:你能看到哪个分配点的 arena 在持续扩张,即使在 OOM 事后剖析中。默认构建零开销;不带 SJ_MEM_LOG 的 --mem-track 是武装关闭、近乎免费的,所以你可以把它留在部署构建中并按运行启用日志。
要采纳的验收测试:在低和高迭代次数上跑你的负载并比较峰值内存。平坦是通过条件,不是"小" — 如果峰值随迭代增长,循环中仍有东西在分配,而日志会点名它。
7. 审查清单
读一个 diff(你的或任何人的),这些问题能抓住几乎一切:
- 循环内或按行/按请求/按查询调用的函数里有
new吗?为什么没有提升、local或 scratch? - 一个函数返回一个新分配的数组吗?调用者能拥有它吗?
- 任何运行超过一次的地方构建了
String吗? - 有谁在意思为"元素计数"的地方用了
buf.length吗? - 复用缓冲区的顺序契约("下次调用前消费")写在它的定义处吗?
然后测量而非假设:比较两个迭代次数的峰值 RSS,不平坦时读 memlog。