首页 · ← SuperJ 手册 中文|EN

内存事件日志:追踪 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_CREATEarena {} 块入口 / 全局 arena 初始化arena id、初始字节、创建点、时间戳
ARENA_EXPAND加一个新 chunk(非第一个)arena id、chunk 字节、新活保留总量、时间戳
ARENA_DROParena {} 块退出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>。

在你信任某一行之前,有两件事要知道:

增长,而非总量: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)与版本。

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