首页 · ← SuperJ 手册 中文|EN

内存管理最佳实践

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

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

目录

  1. 关键洞察:单线程是一种许可
  2. 1.1 什么算泄漏
  3. 分配阶梯
  4. 利用 arena
  5. Scratch 缓冲区:复用手册
  6. 4.1 无法预定尺寸时:System.resize
  7. 用 local 做栈分配
  8. 观察它:内存事件日志
  9. 审查清单

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

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

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

1.1 什么算泄漏

值得在技巧之前先定义,因为你从其他语言带来的定义会让你追错 bug:

内存泄漏是程序保持存活、而其作者并无意保持其存活的东西。

是"保持存活"而非"使用":有意的大量分配不是泄漏,而你已停止使用的内存在其 arena 落下之前仍然被持有。

这排除了若干看起来吓人却不是泄漏的东西 — 从不收缩的全局 arena(设计如此)、启动时一次性加载的 200 MB 表、以 1% 利用率运行的预定尺寸缓冲区。它也包含若干看起来很小而确实是泄漏的东西 — 每个请求保留十六字节、每次加倍步骤都抛弃一个缓冲区、被一次超大分配留成加倍状态的 chunk 爬坡。

因为这是关于意图的陈述,没有工具能替你决定,而 SuperJ 刻意不带泄漏检测器。它提供的是一种声明意图的方式(local、strict arena、@NoRetain)、在意图无法被证明之处的告警(W_UNPROVEN_ESCAPE 及其同类,每个都点名位置),以及对实际被持有内容的测量,让你能拿它与你本意的对比(§6)。

只有一个机制,不是一张清单

一个活在全局 arena 中、却比其有用期活得久的引用。

这就是全部表面,因为全局 arena 是唯一从不归还内存的域。其他一切要么不能泄漏,要么可归约到它:

这个值…会发生什么能否泄漏?
死在其帧或迭代中自动栈分配 — 不需要 local否
死在一个 arena {} 块中在每条退出路径上回收否
逃逸出块被放到更长寿的地方 — 一次放置失败能,且 W_UNPROVEN_ESCAPE 默认就告诉你
被有意保留策略 — 你持有引用能,且 --warn-retained 能点名它,但只有你能决定

自 #4735 起,编译器能为最后一行点名具体行:--warn-retained 对一个它已证明到达 static 根位置(直接到达,或经过一个把它存到那里的被调用者)的分配报告 W_RETAINED。它是 opt-in 的,因为它报告的事实不是判决:编译器能看到这个值被永久保留,却看不到你是否本意如此。在编译器自己的源码上它找到 41 处这样的地方,每一处都是刻意的。它消除的是搜寻,那才是昂贵的一半。当保留正是要点时,在成员上加 @NoWarn 消掉这一行。

该告警在保留带有速率的地方触发 — 同一分配每次调用都重复,这正是使它成为泄漏的原因。所以它刻意保持沉默之处,是那些同样被保留却没有任何重复的值:

分配位于…为何保持沉默
构造器或 static 初始化器每个对象运行一次 / 每个进程运行一次
程序入口点(static void main(String[]))只运行一次 — 仅仅共享 main 之名的普通方法有着与其他方法一样的速率
一个 throw 操作数在抛出路径上至多发生一次
一个由 local 认领的范围你已拥有那个范围 — 被违反的声明是错误,不是告警
一个每迭代重新绑定且无其他别名的循环变量一次只持有一个对象,不是一个不断增长的集合
分析从未见证其到达该 static 的值告警只点名被证明的事实,绝不基于假设

最自然的第一个实验 — 从 main 把一个 new 存入 static — 因此按设计是沉默的。要看到该告警,把这个分配放进一个按请求运行的方法:

class SessionCache {
    static Session keep;

    // runs per request -- the shape W_RETAINED looks for
    static void handle(int id) {
        keep = new Session(id);    // <-- reported
    }

    public static void main(String[] args) {
        handle(args.length);
    }
}

class Session {
    int id;
    Session(int id) { this.id = id; }
}
R.sj:6:27: warning[W_RETAINED]: Session (line 6) is kept for the life of the process: it
reaches a static-rooted location, and the global arena never gives memory back (SPEC §7.2).
Nothing evicts it, so if this method runs per request it is a leak with a rate. Bound the
container, scope the value in an 'arena' block, or suppress with @NoWarn if the retention
is the point.

循环里的一个 new 本身并不是泄漏。 如果该值死于本次迭代,编译器会把它提升到栈上,循环根本不分配:实测,200,000 次迭代的 new Node(i) 与 20,000 次的 new byte[1024] 都让 memTouchedBytes() 移动 0,且 IR 显示的是一个被提升出循环的 alloca,而非循环内的分配。把同一个值存入 static 则花费 3.2 MB。这就是为什么语法性告警(W_ALLOC_IN_LOOP)是 opt-in 而非默认开启 — 在这个编译器上它大多是误报,而且 #3739 不得不逐一豁免三个合法形状。真正的问题从来不是"这个循环里有没有 new",而是"分析能否证明该值死在这里" — 两种情形之间实测 128 倍的差距见 zero-garbage 指南 §2。

有一种形状破坏了这个提升承诺,而且它看起来与无害的那些一模一样:一个构造器自己分配缓冲区的包装器。 for (…) { ByteArray b = new ByteArray(64); … } 会把包装器栈提升 — 然而它每迭代泄漏约 130 字节,因为构造器的 new byte[64] 比构造器调用活得更久且从不被释放:包装器死在栈上,它拥有的缓冲区成了孤儿。这对任何"包装器拥有缓冲区"的类型都成立 — ByteArray、ArrayList、StringBuilder — 对同形状的用户类(class Buf { byte[] b; … })也成立,不只是 SDK 类。在 arena {} 块内同样的构造是免费的(缓冲区随块回收),所以修法是要么用那个块,要么把包装器提升到循环外复用。编译器默认报告这种情况(W_CTOR_BUFFER_IN_LOOP,#4738),报在包装器的 new 行上,点名构造器的缓冲区是代价。它在 W_ALLOC_IN_LOOP 为 opt-in 之处默认开启,因为它不是同一类断言:包装器的栈提升是一个证明,证明包装器会死;构造器的摘要是另一个证明,证明其缓冲区不会死。用 --no-warn-ctor-buffer-in-loop 静默它,或在成员上加 @NoWarn(当每迭代构造正是本意时)。

有一种形状它刻意保持沉默:构造器包装一个交给它的缓冲区而非自己分配一个。new ByteArray(raw) 每迭代零成本,而对惯用的零拷贝视图说三道四正是会让这个告警变得不可用的东西。

所以语言允许而不告诉你的泄漏恰好只有一个:你有意保留了一个引用,并且一直保留着它。 一个 cache 和一个泄漏是同一段代码;区分它们的只有你的意图 — 本节正是从这里开始的。

分配器开销只是脚注而非一个类别:被抛弃的增长缓冲区与爬坡超额保留都持有过没人要的字节,而自 #3846/#3857 起两者都是约 1.00 倍。

2. 分配阶梯

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

  1. 不分配。 大多数分配可以避免:返回原语而非包装器;填充调用者拥有的数组而非返回新数组;从映射文件读两个 long 而非物化一个索引对象;字符串预计算一次而非每次拼接。
  2. local — 栈分配一个小的、可证明不逃逸的临时量(§5)。作用域退出时释放,零成本。下面两个尖锐注意事项。
  3. 复用一个长寿 scratch 缓冲区 — 对任何由数据定尺寸的东西,这是答案(§4)。分配一次,跨每次调用复用;按需增长,永不收缩。但别用在你在热循环里每迭代都要写(WRITE)的缓冲区上 — 到一个固定全局地址的存储是一道路障,在 x86 上代价 9 倍(#3356)。这一档是为由数据定尺寸的缓冲区准备的;给 Simd.* 调用用的固定尺寸暂存数组属于第 2 档(local),每迭代的累加器属于第 1 档(标量)。一次填充之后每迭代被读的 scratch — 查找表、预计算系数 — 正是这一档的用途所在,不受影响。详见 §4。
  4. 一个 arena 块 — 用于一批你想一起、以 O(1) 丢弃的作用域中间量。逃逸是被放置,不是被拒绝:一个比块活得久的值会被分配到更长寿的地方,所以块只回收死于其中的东西 — 而不是其余的,编译器会对后者告警:分析无法证明必要的提升会告警(W_UNPROVEN_ESCAPE,点名破坏证明的那次调用 — #3714,SPEC §7.4.3),而被见证的逃逸保持沉默,因为它的 return 或存储在源码中可见。写 strict arena 把块变成编译器验证、为假即拒绝的声明(§3)。当一个缓冲区真的无法预定尺寸 — 你在读一个边读才知道长度的输入 — System.resize(§4.1)可以增长它而不必每一步都抛弃旧拷贝。它是去掉了预定尺寸的第 3 档,不是一个新档,而且它带一份契约:你传入的数组之后就是死的。
  5. 默认堆(无 arena 块时的默认)— 仅用于真正与进程同寿的东西:预计算表、scratch 缓冲区自身、长寿索引与 cache。"全局 arena"是通常的简称,且自 #3528 起它就是准确的 — 每个默认分配都是 arena 内存。它曾是三个域,其中只有一个是那个 arena — 见下文 §2.1。

来自一个基于 SuperJ 构建的数据库项目的真实数字,每一个都是循环中一个看起来无害的 new:每次查询一个物化列 — 峰值 22.8 GB RSS;每次调用一个排序的表拷贝 — 每次调用 1.3 GB;每列每分区一个路径字符串 — 随查询数无界增长。每一个都靠在阶梯上爬一档修好了。

2.1 默认是一个域

第 5 档通常叫"全局 arena",在你要测量它之前这是个有用的简化。在任何 arena {} 块之外,你得到哪个分配器取决于你分配什么:

你写的分配器是否释放被 System.memReservedBytes() 看到
new Obj(...)sj_galloc -> 全局 arena从不是,但仅在保留新 chunk 时
new T[n]sj_galloc → 全局 arena从不是,但仅在保留新 chunk 时
一个 String body("a" + n、substring、…)sj_str_alloc -> 全局 arena从不是,但仅在保留新 chunk 时

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

三者同样永久,因此阶梯建议不变。三者也同样可观察 — 每个默认分配都是 arena 内存:

所以一个平坦计数器仍然不是没有泄漏的证明 — 只是比过去好得多的信号。追增长时用 --mem-track 构建(§6)可获得每分配点归因。

调整全局 arena 的初始大小。 全局 arena 从 2 MB 起步,每次扩容加倍,直到 1 GB 上限。对一个已经知道自己需要多个 GB 的进程,爬坡的启动加倍是噪音:设置 SJ_ARENA_INITIAL 环境变量,让它以进程的真实预算起步。

SJ_ARENA_INITIAL=128m  ./myapp    # 128 MiB initial chunk
SJ_ARENA_INITIAL=1g    ./myapp    # 1 GiB (the cap; larger clamps to it)
SJ_ARENA_INITIAL=67108864 ./myapp # 64 MiB, no suffix

达到或超过 1 MiB 的分配会得到一个属于自己的 chunk,而不是当前 chunk 中的一片,这让单次超大请求不进入 chunk 增长爬坡。没有它的话,一个 10 MB 数组会把爬坡留成加倍状态,而下一个任意大小的分配会保留一个 20 MB chunk — 在一个没干别的的块上,保留对活跃的比值为 3.0 倍。SJ_ARENA_SOLE_MIN 移动这个门槛:

SJ_ARENA_SOLE_MIN=4m   ./myapp    # only >= 4 MiB gets its own chunk
SJ_ARENA_SOLE_MIN=256k ./myapp    # more allocations get their own chunk

调高它用爬坡整洁换更少的 chunk;调低则相反。坏值回退到 1 MiB 默认,而不是禁用门槛。

值是字节数,带可选的 k/m/g 后缀(大小写不敏感)与可选的结尾 b。它在全局 arena 首次使用时读取一次;坏值回退到默认。这不是预触碰(pre-fault)旋钮:chunk 从不预触碰,所以更大的初始 chunk 消除的是启动爬坡,并不消除请求中途的缺页尖峰。预触碰是另一个选项(尚未发布),代价不同(启动时间 + 提交内存)。

给活跃集合封顶:SJ_ARENA_MAX。 没有垃圾回收器也没有 OutOfMemoryError,所以一个无界的活跃集合没有自己的终止条件:进程一直长,直到宿主机颠簸、OOM killer 接管 — 无信号、无诊断、无退出码,宿主机的 page cache 被一个失控服务清空。等 malloc 失败在 Linux 上也帮不了你,那里 overcommit 会交回一个内核无法兑现的指针。

SJ_ARENA_MAX 是活跃 arena 字节的硬上限 — 与 System.memReservedBytes() 报告、System.memCeilingBytes() 读回的是同一个数。它默认开启:没人设置的上限保护不了任何人,而从没听说过这个变量的运维恰恰就是那个会让进程拖垮宿主机的人。所以这个旋钮是覆盖项,不是打开保护的开关。

./myapp                       # ceiling = 80% of the process's memory budget
SJ_ARENA_MAX=512m ./myapp     # fail at 512 MiB instead
SJ_ARENA_MAX=0    ./myapp     # no ceiling — run to the host's limit
取值上限
未设置进程内存预算的 80%
512m512 MiB
0无(显式退出保护)
无法解析(8zz)默认值 — 一个笔误不能解除保护
预算不可检测无

预算是宿主机 RAM,或cgroup 限制(若存在),取较小者:一台 64 GiB 宿主机上限制 512 MiB 的容器得到 410 MiB,而不是 51 GiB。两代 cgroup 都会读取。20% 的余量是给一切非 arena 的东西的 — malloc 域(String body、map)、栈、二进制自身的页。

尺寸语法与 SJ_ARENA_INITIAL 相同(字节、可选 k/m/g、可选结尾 b),首次使用时读取一次。

一旦突破,进程把它的账本打到 stderr 并以 12 退出(ENOMEM,好让监督者能把它与普通失败区分开)。当 malloc 真的失败时也打印同样的摘要:

==== SuperJ: out of memory — shutting down (#3209) ====
  cause           : SJ_ARENA_MAX ceiling would be exceeded by this chunk
  request         : 8388608 bytes (one arena chunk)
  ceiling         : 8388608 bytes (SJ_ARENA_MAX)
  arena reserved  : 7340048 bytes  (live, all arenas)
  arena touched   : 7340048 bytes  (100.0% of reserved; 0 bytes of active-chunk slack)
  high water      : 7340048 bytes
  chunks          : 4 live, 4 allocated over the run
  arenas created  : 1
  malloc domain   : 0 bytes in 0 live allocation(s)

先把 reserved 对照 touched 读:大差值说明 chunk 爬坡冲过头了,小差值说明活跃集合真的有这么大 — 那就是泄漏。突破上限的 arena 按构造不贡献余量(我们恰在其活动 chunk 满时到达这里),所以在单 arena 进程上差值读作 0。

摘要说的是多少,不是在哪。用 --mem-debug 构建,每类型与每分配点的报告也会在这里打印,排序让增长的那一行在最上(§6):

== allocation sites (debug build, SJ_MEM_DEBUG) — out of memory ==
  site                     global_bytes   glob_cnt  ...
  Grow.sj:7                     3316470     195740  ...

这就是设一个低于宿主机的上限的全部意义:你得到一份摘要和一摞计数器,而不是一行 dmesg。

死前先问。 System.memCeilingBytes() 返回有效上限(没有则为 0),服务因此可以卸掉负载,而不是靠撞上限制来发现它:

long ceiling = System.memCeilingBytes();
if (ceiling > 0 && System.memReservedBytes() > ceiling / 10 * 9) {
    // 90% of the way there — stop accepting work
}

每个运行时变量都列在 runtime-environment.md 中。

构建期旋钮(从源码编译 runtime 时):初始大小(-DSJ_ARENA_GLOBAL_INITIAL=…)与 chunk 上限(-DSJ_ARENA_CHUNK_CAP=$((128*1024*1024)))都是 #ifndef 保护的,源码构建可以设置其一而不必 fork runtime。SJ_ARENA_INITIAL 环境变量在运行时为已部署的二进制覆盖初始大小。

在一个 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 一次性释放,无扫描,无停顿

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

``java strict arena tmp { Buffer b = new Buffer(1024); // dies here — fine cache.put(k, new Entry(r)); // error[E_ARENA_ESCAPE] } ``

它还关闭了同一个缺口的另一半:CARVE-OUT(掏空)。 逃逸是一个进入块又离开块的值;carve-out 是一个从未进入块的值。编译器无法证明"不保留其所分配之物"的调用被解除武装,于是被调用者分配的东西被放到全局 — 块回收接收者,却不回收被调用者挂在它上面的对象:

```java static void fill(Holder h) { h.item = new Item(); } // unprovable callee

arena blk { Holder h = new Holder(); fill(h); use(h.item); } // ^ reclaimed ^ NOT reclaimed, and silent ```

W_ARENA_NO_SAVINGS 看不到这个 — 块确实作用域了某些东西,所以它的全有或全无条件为假 — 而 new Item() 不是从块逃逸,因为它从未在块里。在 strict 下它被报告为 W_ARENA_CARVE_OUT,点名那次调用与那个块。那"我的块回收的比看起来少"就是这条通道,它和 strict 的其余部分出于同样的理由是 opt-in 的。

这是应用到放置上的声明层 — noalloc 声明一个方法不分配,strict arena 声明一个块的值死于其中 — 所以它出于同样的理由是 opt-in 的:region 推断会把它无法证明死于块中的东西统统提升,因此一个 strict 块可能拒绝事实上原本安全的代码。对一个你在乎的块选择加入,就是你接受这笔交易的方式。strict 是上下文相关的,所以一个名为 strict 的变量仍能编译。

在打开它之前有两个限制值得知道。透过链式接收者的存储(head.next.next = new Cell(3))会被拒绝,尽管一层的形式没问题,因为存储那一侧把接收者解析为一个名字。而 superj check 也验证这个声明,所以你在快速循环里就能看到它,而不是等到 build。无论你针对哪个 SDK 构建,判决都相同 — 每个发行版都带着 SDK 的保留摘要,所以把块局部值传进 SDK 由 --sdk-path 与 --sdk-source 判定出同样的结果。

```java static Pt makePt(int v) { Pt p = new Pt(); p.x = v + 1; return p; }

for (int i = 0; i < n; i++) { Pt r = makePt(i); s += r.x; } // 16 B/iter, forever arena blk { for (int i = 0; i < n; i++) { Pt r = makePt(i); s += r.x; } // 0 — dropped with blk } ```

默认情况下没有供第一种形式回退的帧生命周期区域:垃圾回收语言会把它栈分配,而这里 arena 就是回答这个问题的机制。把结果发布出块会提升它,所以块不能回收仍然可达的东西。第 1 档仍然胜过两者 — 返回 int 的 makeX(i) 根本不分配。

帧区域(frame regions)(#5024)给一个丢弃被调用者新鲜结果的方法一个覆盖其整个帧的隐式区域,仿佛它的体被写在一个 arena 块内:上面的第一个循环于是读 0 而无需改源码,一个每帧分配一个对象的 2000 深递归读 0,被发布或返回的值则像在你写的块中一样被精确提升。它与手写块有一处不同,而且这个差异要紧(#5109):分析已证明死于帧中的分配在隐式区域下仍被提升到栈,而在你写的块内它属于块。把东西放进块是块存在的目的,所以写一个块是指令;隐式区域不是指令,它不能让你付出一个没有它本不会有的 alloca。在 #5096 与 #5109 之间它付出了,于是上面的循环 — §1 中承诺循环内 new 让 memTouchedBytes() 移动 0 的那个段落 — 每迭代读到 16 B。"丢弃"是在分析运行之前从源码读出的(#5090):结果被抛弃、被通读一次,或绑定到一个从不返回、存储或传递的局部。一个把它收到的每个新鲜结果都留住的体不会被武装,因为那里的区域回收不了任何东西,却仍要在每次调用上付出一次武装与一次 drop — 用较弱判据("调用了会分配的东西")武装,在编译器自举上实测 +21% 墙钟时间换 −6.3% 峰值 RSS,而上面的规则是 +0.5% 换 −2.4%。

每请求服务器实际写成的形状 — 一个通过调用factory构建其节点图的 parser — 也会被武装(#5093)。那个 parser 把自己的新鲜分配交给一个保留它们的被调用者 — 保存在它交回的对象里,然后调用者把它丢弃。摘要说明了这一点(仅保留到返回值中的参数带着与构造器参数相同的 P 列),所以调用者保持 allocsLocal,整张图被回收。benchmarks/bench_frame_regions.sj 两种方式跑那个循环 — 节点内联构建与节点经 factory 构建 — 在 --no-frame-regions 下都读 1,040 B/请求,默认读 0,峰值保留 534 MB → 2 MB。这个测量就是它自 #5096 起默认开启的原因;--no-frame-regions 是逃生门,而用它构建的编译器逐字节复现默认代。

它的代价,因为它不是免费的。 帧区域把每次迭代的分配一直持有到帧返回,所以长寿帧内的热分配循环比全局 arena 路径在帧内持有更多:在一个被丢弃的迭代器上,200 轮 12,288 B,而旧路径保留 0;200,000 次上 16.77 MB 对 12.58 MB。没有泄漏 — 跨调用边界实测两次,区域净值为零 — 但在这样的帧内取的 memReservedBytes() 读数测量的是区域而非泄漏,如果你要对它断言,这才是该写的。

它的最小情形是一个固定的 256 B,而且它看起来像泄漏时并不是(#5163)。武装区域的方法从其入口就拿到区域,所以构造器的内部缓冲区 — 该方法中的 new StringBuilder() — 落入区域,而区域的第一个 chunk 是 256 B(#5098 为合成区域设的下限)。该方法内的一个 before/after 对于是读作一次 +256,中间跑多少次迭代都一样。方法返回时它就没了:1000 次调用跨起来测得 0 B 保留、0 B 触碰。在 --no-frame-regions 下同一个缓冲区进全局 arena,并留在那里:每次调用 80 B,永远。memReservedBytes() 为那个读 0,因为全局 arena 预保留的 chunk 吸收了它。要断言一个循环不分配,把循环放进一个方法并跨调用测量(examples/test_5163_frame_region_first_chunk.sj)。

按迭代而非按帧封顶需要一个循环体区域,它尚不存在。拒绝武装循环驻留的丢弃不是答案,而且被测量过:它会失去上面的全部收益,因为那个 benchmark 里被武装的帧正是持有请求循环的那些帧。

一个自己武装区域的方法永远得不到隐式区域(#5097),无论它用 Region.enter() / Region.leave() 还是 arena (r) { }。你已经说过那些分配属于哪里,而包在外面的隐式区域与之相悖:那个窗口里你分配的东西不再被 r.close() 回收 — 探针实测每次连接 208 字节变成 16,608,SDK 自己的 TLS 客户端路径上是 225,808。拒绝只为这一个帧付出它的区域,此外无他。

它仍是一个旗标而非默认,这是关于爆炸半径的决策而非缺失的事实:为 profile 呈 factory 形状的程序打开它,并像对待任何其他放置问题一样用 System.memTouchedBytes() 或 --mem-hist 测量。

当中间量真的是一批共享一个生命周期 — 一次解析、一个请求、一个查询计划 — 时用一个 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();每次请求构造一个新的是从零重建容量并泄漏旧的。

提升分配,而不只是数组。 "把缓冲区提升出循环"可以被读成"把数组声明挪到循环上方",而只做到这个毫无收益:

B[] scratch = new B[4];                  // hoisted...
for (int i = 0; i < n; i++) {
    scratch[i & 3] = new B();            // ...but still one allocation per iteration
}

这每迭代泄漏一个对象并保留四个。它也是唯一不能被栈放置的形状,理由值得理解:那些对象最多四个同时存活,且每个都比制造它的迭代活得久,所以没有单一可复用的栈槽。把数组挪进循环内,同样的代码就免费了 — 一次一个对象存活,一个槽,复用 — 这正是未提升版本实测优于提升版本的原因。改为填充对象:

B[] scratch = new B[4];
for (int k = 0; k < 4; k++) scratch[k] = new B();   // once
for (int i = 0; i < n; i++) {
    scratch[i & 3].set(...);             // fills, never allocates -- 0 B/iter
}

如果每迭代分配真的必要,把循环放进一个 arena {} 块(§3):代价于是被块封顶并在其出口回收,而不是被保留到进程结束。

每个用途一个 scratch,按最大值定尺寸。 不要池化许多小缓冲区;保留一个见过最大输入的。内存由曾处理过的最大项界定,而非由处理过的数量 — 这正是你想要的平坦。

把工作集定到 cache 大小,而非数据大小。 流式处理时,一个小的复用窗口(几十到几百 KB)既是常内存答案又是快的那个 — 生产和消费过程命中同一批 cache 驻留字节。测量窗口大小;不要猜。

不要在 x86 上在热循环内写一个 static scratch。 一个每迭代被读的 static scratch(查找表、预计算系数)正好。一个每迭代被写然后喂给 SIMD 内建的 static scratch 是一个别名屏障:到固定全局地址的存储无法被证明不与你读取的数组别名,因此优化器必须真的把每条 lane 存到内存再加载 — 一趟 scalar-replacement 消除不掉的往返。在一个 20M 行 kernel 上测得:static final double[4] scratch → Simd.dotDouble4 的代价是带栈上 local double[4](优化器 SROA 提升到寄存器)的相同循环的 9 倍 — 在 Ryzen 9 9950X3D 上 --simd=avx2 为 108 ms 对 12 ms(#3356)。在 ARM 上 static 形式也更慢,慢 1.26 倍 — 这个量级正是 Apple Silicon 藏起来的,所以在 M 系列机器上开发没有信号。static 模式正是本页为避免每次调用分配所推荐的模式 — 对一次填充后复用的 scratch,它是正确的。它只在 scratch 作为 SIMD 调用的暂存缓冲区每迭代填充并消费时咬人,且只在缓冲区小到足以被标量化时(#3357):4096 元素的 static scratch 实测 1.16 倍,即噪音。对暂存形状,优先用标量累加器(与局部数组一样快,不分配)或在方法内声明暂存数组。在目标架构上测量。

4.1 无法预定尺寸时:System.resize

预定尺寸总是更好,本节不改变这一点。但有些缓冲区的尺寸由你尚未读到的输入决定,而回退方案 — 分配双倍、拷贝、丢掉旧的 — 在这里比在 GC 运行时上更糟:arena 没有比 chunk 更小的可归还单元,所以每个被抛弃的缓冲区都被保留到 arena 寿命结束。靠加倍增长到 N 花费约 2N,永久地。在 32 KB 缓冲区上实测:为 32,768 活跃字节付出 65,632 字节。

System.resize 增长数组而不是替换它:

byte[] buf = new byte[1024];
int used = 0;
while (more()) {
    if (used == buf.length) {
        buf = System.resize(buf, buf.length * 2);   // `buf` above is DEAD
    }
    buf[used] = next();
    used = used + 1;
}

同一个 32 KB 缓冲区现在花费 32,784 字节 — 1.00 倍 — 而 16 MB 的那个从 3.75 倍降到 1.00 倍。它的工作方式是:当该分配是 arena 最近一次分配时就地扩展,否则把它挪进一个属于自己的 chunk,此后每次后续 resize 都只是调整那个 chunk 的尺寸。尺寸无关紧要:从 16 字节起步的缓冲区与 1 MB 的受益相同。

新元素被清零,与 new T[] 会给你的完全一致。 从长度 4 增长到 8 会清零下标 4–7;收缩只是丢弃尾部。这是一份真实契约,不是你碰巧观察到的实现细节 — 代码被允许在增长后依赖 arr[oldLen] == null 而无需显式 null 填充。(它曾被一个真实 bug 破坏,#4065:迁入自有 chunk 的路径可能让最多一个尾部引用槽持有分配器的原始内存而非 null,在一个碰巧交回新页的分配器上不可见,在一个在真实流量下回收脏块的分配器上则是崩溃。已修复 — 在此提及是因为被破坏的正是这份保证。)

使用它之前必须知道的两件事。

参数已死。用返回的引用。 触碰你传入的数组是未定义行为 — 它可能被就地调整了尺寸,也可能被移动且旧存储已释放,而发生的是哪一种程序无法观察。一个幸存的引用是悬垂的,不只是过时的,所以它可能在你的机器上读回正确,在另一台上是已释放内存。这与 local 是同一类成文特权(SPEC §7.8):编译器不检查它。只在你完全拥有数组的地方使用它 — 一个 private 字段,或一个你看得见整个生命周期的局部 — 绝不在你已交给其他任何东西的数组上使用。

它在 arena {} 块内可用,且块仍然回收。 数组的生命周期绑定到调用返回的引用,所以在块内 resize 的缓冲区随块消亡:实测 200 轮 2 MiB resize 保留 0 字节。如果 resize 后的数组逃逸出块,它随之被提升,所以逃逸的引用永远不会指向已回收的内存。(在 #3850 之前这不是真的 — 数组被提升出每个块,同样 200 轮上 419 MB — 所以教你"在块内预定尺寸"的旧笔记描述的是那个缺陷。)

SDK 的可增长容器(StringBuilder、ArrayList、IntArrayList、LongArrayList、DoubleArrayList、FloatArrayList)内部使用它,所以它们得到 1.00 倍的行为而你无需遵守任何契约。ByteArrayBuilder 刻意不用:它的 getBuffer() 把后备数组交给调用者,移动式 resize 会使他们的拷贝失效。

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 是武装关闭、近乎免费的,所以你可以把它留在部署构建中并按运行启用日志。

当日志告诉你内存在增长、而你需要知道里面是什么时,把旗标换成 --mem-debug:它武装同一个日志,外加一个在退出时打印到 stderr 的每类型活跃字节直方图(以及对被 drop 的 arena 内存的 0xDE 下毒,让 use-after-drop 不再读到陈旧但完好的对象)。一个旗标,无额外构建步骤 — 它仪表化每个分配,所以它是 debug 构建,绝不是部署构建。

要采纳的验收测试:在低和高迭代次数上跑你的负载并比较峰值内存。平坦是通过条件,不是"小" — 如果峰值随迭代增长,循环中仍有东西在分配,而日志会点名它。

唯一不需要判断的泄漏:System.memAudit(label)

§1.1 说泄漏是关于意图的陈述,没有工具能替你决定。对仍在被你持有的内存,这是真的。对不再被任何东西指向的内存则不然:全局 arena 中一个无任何东西可达的对象永远不能再被使用、也永远不会被释放,按每一种定义它都是垃圾 — 包括 §1.1 认为过窄的那些定义。

这就是一个有界 cache 在其值被替换而非复用时呈现的形状:

cache.put(key, new Value(i));   // the previous Value is now unreachable, and never freed

活跃集合是常量,所以 memReservedBytes() 看起来平静,类型直方图也分不清被替换的值和活跃的值。在 --mem-debug 构建上,System.memAudit("label") 追踪 arena 并点名它们:

== garbage audit — after 3000 puts ==
  site                garbage_bytes      count
  Audit.sj:18                 94400       2950     <- the replaced values
  Audit.sj:6                 212400       2950     <- their constructors' buffers
  reachable: 42534 bytes in 606 allocations

50 个 key 上的 3,000 次 put 留下 50 个活跃、2,950 个不可达。修法是 §4 的复用惯用法:每个 key 持有一个 Value 并对它 reset()。

它报告;它从不回收。 回收器会让这段代码活下来;点名它让它可修。而且追踪是保守的 — 一个只是碰巧像堆地址的字会让它的目标存活 — 所以审计低报。它点名的每一样东西都确实不可达。

把它变成门禁:superj test --check-growth

泄漏是关于意图的陈述,而 §1.1 说过意图不可机器检验 — cache 和泄漏是同一段代码。但测试通过其存在陈述了意图:再跑一次应当分文不花。 所以 runner 可以把每个 @Test 跑两次,若第二次运行保留了任何 arena 内存则判其失败(#4737):

superj test --check-growth
test OrderBookTest.appliesUpdate ... ok
test SessionTest.handlesRequest ... FAILED
    retained 1024 bytes on a second run (#4737): this test's path keeps memory per
    invocation. Fix the retention, or exempt it with --allow-growth <substring> if
    holding it is the point.

第一次运行吸收那些合法的一次性开销 — 惰性 static 初始化、首次使用的 cache、达到稳态大小的 builder。第二次运行保留的任何东西都是该路径每次调用保留的内存,正是本章通篇讲的带速率泄漏。

它是 opt-in 的:普通 superj test 不变,所以有意持有 cache 的套件继续通过。--allow-growth <substring> 在持有内存正是要点时豁免匹配的测试。该检查用 memTouchedBytes() 而非 memReservedBytes(),因为 reserved 只在占用新 chunk 时移动,小的每次调用泄漏在读数上为零,直到碰巧跨过 chunk 边界。

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