内存事件日志:追踪 arena 增长
SuperJ 通过一个 bump arena 分配 — 对象从不被单独释放;作用域 arena {} 块被批量丢弃。要留意的失效模式不是缺少 free,而是一个无界的活集合:一个不断增长的 cache 或 list,让 arena 的保留字节永远攀升。内存事件日志记录 arena 随时间的增长,让你找到泄漏、归因到源码位置,甚至在崩溃后做 OOM 事后剖析。
30 秒版
# 1. Compile with --mem-track (arms the arena instrumentation)
superj compile myapp.sj --sdk-path $SJ_HOME/sdk --link --mem-track --output myapp
# 2. Run with SJ_MEM_LOG pointing at a path (arms the log at runtime)
SJ_MEM_LOG=tmp/myapp.mlog ./myapp
# 3. Read the log
superj memlog tmp/myapp.mlog
就这样。日志是一个二进制文件(SJML 格式),通过 mmap 写入 — 无每个事件的 write() 系统调用、无格式化、事件路径上无分配。它熬过 SIGKILL(内核刷出 mmap 的页)。
工作原理
该设施有三层启用,因此默认构建不付代价:
| 构建 | SJ_MEM_LOG | 行为 | 代价 |
|---|---|---|---|
默认(无 --mem-track) | — | 编译掉;System.memReservedBytes() 返回 0 | 零 |
--mem-track | 未设 | 武装关闭;不打开日志 | ~零(每事件 1 个 bool) |
--mem-track | 已设 | 日志激活 | 几十 ns × 稀有事件 |
--mem-track 武装运行时中的 arena 仪表。SJ_MEM_LOG 在运行时武装日志。两层设计让你无需重新构建即可按部署切换追踪。
记录什么
仅chunk 粒度事件 — 逐对象分配快路径从不触碰。每几 KB 加一个 chunk,因此整个运行中有数千事件,而非每秒数百万。
| 事件 | 何时 | 携带 |
|---|---|---|
ARENA_CREATE | arena {} 块入口 / 全局 arena 初始化 | arena id、初始字节、创建点、时间戳 |
ARENA_EXPAND | 加一个新 chunk(非第一个) | arena id、chunk 字节、新活保留总量、时间戳 |
ARENA_DROP | arena {} 块退出 | arena id、释放字节、新活保留总量、时间戳 |
SNAPSHOT | 每 1024 事件 + 退出时 | 全局保留、malloc 域活字节、时间戳 |
分配域
只有一个默认域:全局 arena(SPEC §7.2)。new 对象、数组与 String body 都一样是 arena 内存(#3527、#3528),并按事件覆盖(CREATE/EXPAND/DROP)。
SNAPSHOT 记录中的 malloc-live 数字仍然存在,仍然报告 malloc 域持有什么 — 但随着 String 移出,它不再涵盖字符串增长。要看字符串增长,请读 arena 增长。
读日志:superj memlog
superj memlog <path> # one-shot replay
superj memlog --tail <path> # live-tail a running process
没有路径默认 — 你显式传入日志文件。(运行时在 SJ_MEM_LOG 未设时默认 tmp/sj_mem_<pid>.mlog,但 superj memlog 需要显式参数。)
回放报告有六个部分:
1. 头部
== memory-event log ==
path: tmp/demo.mlog
version: 1 recordSize: 32
pid: 25583 startTsNs: 456213013850041
writeCursor: 13 / capacity: 131072
file bytes: 4194368
writeCursor 是已写记录数;capacity 是当前映射能装多少槽。如果文件小于 header + writeCursor * 32,日志被截断(例如内核刷出前的 SIGKILL)— 工具读它有的,并注明截断。
2. 事件
== events ==
ARENA_CREATE: 4
ARENA_EXPAND: 5
ARENA_DROP: 3
SNAPSHOT: 1
total chunks: 9
live chunks: 9
每种事件类型的计数。live chunks 是日志结尾时仍存活(尚未 drop)的 arena chunk 数。
3. 全局
== global ==
final reserved: 18568 bytes
high-water: 50864 bytes
timespan: 350778 ns
结尾时的全局保留总量(所有 arena 求和),以及整个运行的高水位。高水位是进程曾持有的峰值内存。
4. 按 site — 每个 arena 块究竟值不值?
== per-site (arena blocks) ==
demo.sj:5 entries=1 reclaimed=983040 bytes mean=983040 max=983040 share=31%
demo.sj:11 entries=1 reclaimed=65536 bytes mean=65536 max=65536 share=2%
(share = this block's reclaim as a fraction of the 3080192-byte high-water)
每个 arena 块一行,对该块每次运行求和 — 不像下文的每 arena 部分,后者按每次进入打印一行,因此循环内的块会产生数千行,什么也回答不了。entries 是该块被进入的次数,reclaimed 是它的 arena 一共归还的字节,share 则是它占进程高水位的多大比例。
当你想知道一个块值不值得存在时,读这一部分,因为编译器无法告诉你。W_ARENA_NO_SAVINGS 只在块内每个分配都逃逸时才告警,因此一个回收几百字节、却让数 MB 逃逸的块是沉默的 — 而它需要知道的大小在编译期确实不可见:当逃逸的值是一个构造函数会分配缓冲区的对象,或一个长度是变量的数组时。
上面两行是同一个程序以两种方式分配同样的 819 KB:
arena a { for (int i = 0; i < 200; i++) { byte[] b = new byte[4096]; use(b); } }
class Payload { byte[] data; Payload(int n) { this.data = new byte[n]; } }
arena b { for (int i = 0; i < 200; i++) { Payload p = new Payload(4096); use(p); } }
块 b 只回收一个 chunk,而不是 983040 字节。这份记录早于 #3801;现在作用域是环境式(ambient)的,可证明的 callee 的分配也会加入块。下面的形态对 CARVED-OUT 的 callee 仍然成立 — 未注解的 native 正是这种 callee:只有写在花括号内的 new 才是该块 arena 的候选,因此 b 持有 200 个 Payload 头,而每个 byte[4096] — 在构造函数内分配 — 属于全局 arena,永不释放。没有任何告警,因为 Payload 对象确实死在块内;它们的 payload 从未进入块。
刻意为之,这一部分只报告数字而不下评判:低 share 完全可能没问题(块的存在可能只是为了作用域,而非节省),而且为此尝试过的每个阈值在两个方向上都错过。
5. 每 arena(默认封顶)
存活的 arena 完整打印 — 它们才是仍持有内存的 — 而 drop 的封顶为 20 行(#4743),并有一行说明隐去了多少。没有封顶时,每个请求进入一次的块会每个请求产生一行:对一个进入 100,000 次的块,17 ms 的运行会给出 6.9 MB 的报告,而判定在第 100,053 行。上文的按 site 部分是同一份数据的可读形式;superj memlog --all-arenas <log> 恢复每一行。
Arena 1 永远是进程的global arena,并且如此标注。它没有创建点,因为它不是任何人写出的块 — 在那一行打印 <unknown> 会被读成解析失败,而全局 arena 泄漏恰恰会把你带到那一行。
原本的每 arena 说明
== per-arena ==
arena 1: final=18568 dropped=0 site=(global arena — no creation site; it is not a block) LIVE
arena 2: final=28200 dropped=28200 site=memlog_demo.sj:15 dropped
arena 3: final=4096 dropped=4096 site=memlog_demo.sj:21 dropped
arena 4: final=16392 dropped=16392 site=memlog_demo.sj:29 dropped
每个 arena 的最终保留(若仍存活)或 drop 时保留、drop 时释放的字节,以及创建点(声明 arena {} 块的 file:line)。arena 1 是全局 arena(总是存活、无 site)。
这是归因的关键部分:如果保留持续攀升,负责的 arena 是那个仍 LIVE 且 final 大的那个。
6. 泄漏判定 + 作用域
判定最先打印,紧跟在头部之后,因为它是你打开这个文件时想问的问题(#4743)。当仍持有内存的 arena 只有全局 arena 时,它还会说明这份日志无法把增长进一步归因,并指向 --mem-debug(#4739/#4741),后者可以。
malloc 域部分只在非空时打印:String body 是 arena 内存(#3528),因此在普通程序上 malloc 域无话可说。
== leak verdict ==
insufficient snapshots for slope — replay the CREATE/EXPAND/DROP timeline above
== malloc domain ==
last snapshot malloc-live: 4200 bytes (Strings/maps)
== scope ==
arena domain: per-event (CREATE/EXPAND/DROP).
malloc domain (Strings/maps): aggregate in SNAPSHOT.
creation-site attribution: available (3 sites)
泄漏判定比较首次快照与结尾的保留:正差额意味着 GROWING(可能泄漏);负差额打印 shrinking (-N bytes since first snapshot) — no leak(保留下降 — 例如本次运行的保留 chunk 高点出现在首次快照时或之前,而非结尾);恰好为零的差额打印 steady — no leak signal。作用域部分诚实说明覆盖范围。
该输出中的 (Strings/maps) 标签是过时的,这里按工具实际打印的样子复现。String body 在 #3528 移入了全局 arena,因此它们计入 arena 域,而非 malloc 域 — 增长的字符串数量体现在上文的 arena 数字中,而非 malloc-live。这个标签是报告器上的外观瑕疵,不是测量错误。
实时 tail
superj memlog --tail tmp/myapp.mlog
轮询日志的 writeCursor 并在被追踪进程写入时流式传输新记录。在约 10 秒静默期后终止(进程已退出)。依赖运行时先写记录后增游标的顺序,因此半写槽永不被读取。
实例
示例程序 $SJ_HOME/demo/memlog_demo.sj 模拟一个带两个作用域 arena 的请求循环:
# Compile with --mem-track
superj compile $SJ_HOME/demo/memlog_demo.sj --sdk-path $SJ_HOME/sdk --link --mem-track \
--output tmp/memlog_demo
# Run — the log is written to SJ_MEM_LOG (or tmp/sj_mem_<pid>.mlog if unset)
SJ_MEM_LOG=tmp/demo.mlog ./tmp/memlog_demo
# Read the log
superj memlog tmp/demo.mlog
输出(节选):
processed 5000 requests, sum=122500
== per-arena ==
arena 1: final=18568 dropped=0 site=(global arena — no creation site) LIVE
arena 2: final=28200 dropped=28200 site=memlog_demo.sj:15 dropped
arena 3: final=4096 dropped=4096 site=memlog_demo.sj:21 dropped
arena 4: final=16392 dropped=16392 site=memlog_demo.sj:29 dropped
Arena 1 是全局 arena(启动对象、总是存活)。Arena 2 是第 15 行的 requestBatch 块(28 KB 请求结果 + scratch)。Arena 3 是第 21 行的嵌套 scratch 块。Arena 4 是第 29 行的 cache 块。三个作用域 arena 都被 drop(chunk 被释放),因此最终保留回到全局 arena 的 18568 字节。
如果 arena 4(cache)没有被 drop — 比如你忘了 arena {} 块而把 keys 分配进了全局 arena — 它会显示 LIVE 且 final 很大,全局高水位也不会回来。那就是泄漏信号。
进程内查询计数器
两个 System 内建给你活保留/高水位,无需读日志文件:
long r = System.memReservedBytes(); // current global reserved (arena domain)
long h = System.memHighWaterBytes(); // high-water mark over the process lifetime
两者在每个构建上都存活 — 它们是 arena chunk malloc/free 路径上的普通计数器,不属于 --mem-track / SJ_MEM_LOG 设施(后者只门控 mmap 事件日志)。它们在冷路径上花费两次加法,无需特殊构建。适用于自监控(例如每 N 请求记录保留)以及断言作用域内存已回来 — 跨一个打开 arena {} 块的调用的差额应为 0:
long before = System.memReservedBytes();
handleRequest(req);
long leaked = System.memReservedBytes() - before; // want 0
它们报告保留 chunk 容量(按 chunk 大小步进移动,非逐对象)且仅 arena 域。
String body 是 arena 内存,字符串构建泄漏确实会移动这些计数器 — 实测:在任何块之外,一个 100 万次迭代的 "item-" + i + "-suffix" 循环使 memReservedBytes 移动 130 MB。
SPEC §7.4 要求在 arena 块内构建且死在其中的字符串随块一起回收(#3101),事实也如此:同一循环放在块内时,memReservedBytes 移动 0(#3664、#3712)。
这些计数器仍会显示增长的,是一个 callee 构建并交回的字符串 — sb.toString()、Integer.toString(i),或你自己的返回 "v" + i 的辅助函数。arena 归位按词法确定,因此块无法认领写在另一帧里的分配(#3640)。如果一个形似块的循环在这里增长,先向下找一帧的字符串生产者,再怀疑计数器。
Debug 构建:逐类型直方图
对于对象级归因 — 哪些类型拥有活集合 — 改用 --mem-debug 而非 --mem-track 编译。它武装 --mem-track 所做的一切并加上直方图,因此一个旗标覆盖整个泄漏追查:日志说明 arena 何时增长,直方图说明里面有什么。
superj compile myapp.sj --sdk-path $SJ_HOME/sdk --link --mem-debug --output myapp
SJ_MEM_LOG=myapp.mlog ./myapp # histogram on stderr at exit; log as usual
superj run --mem-debug # or from inside a project
这会给每个分配插桩,因此它是 debug 构建,绝不能用于生产。除此之外无需任何东西 — 不需要手工编译的 runtime 对象,也不需要手写的链接行。
退出时直方图打印到 stderr:
== memory histogram (debug build, SJ_MEM_DEBUG) ==
type global_bytes glob_cnt block_bytes blk_cnt stack_cnt
Kept 48000 2000 0 0 0
Tmp 0 0 480000 20000 0
Churn 0 0 0 0 20000
TOTAL 48000 480000
== end histogram (3 types; global_bytes is RETAINED for the process, block_bytes is churn
reclaimed on block exit) ==
看列,别看行大小(#4740)。这里没有任何东西会被递减 — arena 释放的是 chunk,不是对象 — 因此单一的"活字节"数字会是累计值,而在一个反复进出 arena {} 块的程序上,它最大的行会是早已归还的内存。这一拆分让每一列按构造即为真:
| 列 | 含义 | 数字大是坏事吗? |
|---|---|---|
global_bytes | 进程生命周期的 arena 从不归还内存,因此累计就是活 | 是 — 这是泄漏候选列 |
block_bytes | 作用域块每次退出都释放,因此这些是 churn | 否;这是块在正常工作 |
stack_cnt | 入口块的 alloca 完全不花 arena 字节 | 否;这是逃逸分析在正常工作 |
在上面的例子中,Tmp 与 Churn 各分配 20,000 次,两者都不是泄漏;而 Kept 分配 2,000 次,是唯一让进程增长的行。单一数字无法把它们区分开。
global_bytes 是完整的:每个全局分配都经过 sj_galloc*,它在此报告。block_bytes 是下界 — 它统计的是 callee 在武装块之下分配的东西,既不包括词法上写在块内的分配(codegen 把那些直接生成为 sj_arena_malloc),也不包括 String body。
哪一行?按 site 部分
同一次运行还会打印第二张表,以源码行为键,按保留字节排序,因此第一行就是要看的地方(#4739):
== allocation sites (debug build, SJ_MEM_DEBUG) ==
site global_bytes glob_cnt block_bytes blk_cnt stack_cnt
Sites.sj:6 1056000 4000 0 0 0
Sites.sj:17 128000 4000 0 0 0
Sites.sj:18 8000 500 0 0 0
Sites.sj:21 0 0 820000 100 0
TOTAL (attributed) 1196040 820000
coverage: 1196040 of 1219981 retained bytes attributed to a source line (98%)
这里的第 6 行是一个构造函数的 new byte[256];第 17 行是调用它的 new Session(i)。那一 MB 在第 6 行上,而类型表无法告诉你这一点 — 两行本会显示为 Session 与 <raw/array>。
在你信任某一行之前,有两件事要知道:
- 先读 coverage 行。site id 由调用者发出,因此在预构建 SDK 归档内进行的分配根本不发调用,甚至无法显示为未归因。上文的逐类型表是分母。
Stringbody 是主要的未覆盖类别 —sj_str_alloc从 C 分配,没有 site 可报 — 因此字符串 churn 泄漏会出现在逐类型表和余量中,而不是这里的某一行。 - 这些列的含义与上文相同:大的
block_bytes配上为 0 的global_bytes(第 21 行)是块在正常工作,不是泄漏。
增长,而非总量:System.memReport(label)
泄漏是一个斜率。总量显示不出斜率 — 启动时一次性分配的 4 MB 缓冲区在任何行排序中都永远排在 480 KB 泄漏之上,而泄漏才是那个 bug。在你怀疑的工作前后调用 memReport,读 d_global 列 — 它度量自上一次报告以来的驻留量(#4741):
warmUp();
System.memReport("baseline");
for (int i = 0; i < 20000; i++) handle(i);
System.memReport("after 20k");
== allocation sites (debug build, SJ_MEM_DEBUG) — after 20k ==
site global_bytes glob_cnt block_bytes blk_cnt stack_cnt d_global
Growth.sj:14 480000 20000 0 0 0 480000
Growth.sj:3 4000008 1 0 0 0 0
(ordered by d_global — growth since the previous report — not by total)
第 3 行持有的字节是它的八倍,却不是问题所在;第 14 行才是。一旦基线存在,表就按增长排序,因此最顶行就是答案。
每次报告都会重新设基线,因此连续调用读起来就是"两次之间增长了什么",无需管理任何状态。对于不想动手改的长运行,设置 SJ_MEM_HIST_EVERY=<bytes>,运行时就会每 N 字节全局分配发出同一组数据。
memReport 在每个非 --mem-debug 构建上都是空操作,与 System.mem* 家族的其余成员一样,因此调用可以留在发布的源码里。
debug 构建还会在 arena 块被 drop 时用 0xDE 毒化被释放的 arena 内存,因此 drop 后再读会崩溃,或读到毒化模式,而非静默读到过时对象。
线格式
日志文件是一个带版本号的 64 字节头,后跟固定 32 字节小端记录,可选地后跟一个尾部 site-table 部分。格式是稳定的(版本 1);回放工具在读取前校验魔数(SJML)与版本。