首页 · ← SuperJ 手册 中文|EN

内存管理最佳实践

SuperJ 没有垃圾回收器。new 从一个 arena 分配;默认 arena 是全局的,它存活到进程退出。这给你以 bump 指针速度分配和零停顿 — 作为交换有一项义务:除非你自己动手,否则不存在短生命周期对象。 热路径上的一个 new 不是"回收器会吸收的一点压力";它是一个带速率的泄漏。

本页讲实践:如何让内存使用平坦 — 分配策略阶梯、arena 与栈工具、scratch 缓冲区手册,以及证明你的进程行为良好的内建日志。

目录

  1. 关键洞察:单线程是一种许可
  2. 分配阶梯
  3. 利用 arena
  4. Scratch 缓冲区:复用手册
  5. local 做栈分配
  6. 观察它:内存事件日志
  7. 审查清单

1. 关键洞察:单线程是一种许可

SuperJ 程序是严格单线程的(你用进程而非线程扩展 — 见 并发)。对内存而言这不是限制;它是一种许可,移除了多线程代码付出的一整类成本:

下面的一切都是利用这种许可的技巧:目标是让一个进程的内存足迹平坦,无论它服务多少请求、查询或迭代。

2. 分配阶梯

当你准备写 new 时,按以下顺序尝试:

  1. 不分配。 大多数分配可以避免:返回原语而非包装器;填充调用者拥有的数组而非返回新数组;从映射文件读两个 long 而非物化一个索引对象;字符串预计算一次而非每次拼接。
  2. local — 栈分配一个小的、可证明不逃逸的临时量(§5)。作用域退出时释放,零成本。下面两个尖锐注意事项。
  3. 复用一个长寿 scratch 缓冲区 — 对任何由数据定尺寸的东西,这是答案(§4)。分配一次,跨每次调用复用;按需增长,永不收缩。
  4. 一个 arena — 用于一批你想一起、以 O(1) 丢弃的作用域中间量,逃逸在编译时检查(§3)。
  5. 默认堆(无 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" + nsubstring、…)malloc从不

从所有三种编译模式(独立、预构建归档、全程序源码)发出的 IR 测量 — 三个模式中划分相同。

三者同样永久,因此阶梯建议不变。变的是你能观察什么:

所以一个平坦计数器并不证明没有泄漏。当你追增长时用 --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 一次性释放,无扫描,无停顿

让它成为被检查的工具的原因:

当中间量真的是一批共享一个生命周期 — 一次解析、一个请求、一个查询计划 — 时用一个 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 已经是寄存器)。两个注意事项,每一个都能静默地让你失去全部收益:

保持小 — 一个 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(你的或任何人的),这些问题能抓住几乎一切:

然后测量而非假设:比较两个迭代次数的峰值 RSS,不平坦时读 memlog。

SuperJ — manual · generated from memory-management.md at pack time · Powered by superJ — this site is served by superj_web 中文|EN