内存事件日志:追踪 arena 增长
SuperJ 通过一个 bump arena 分配 — 对象从不被单独释放;作用域 arena {} 块被批量丢弃。要留意的失效模式不是缺少 free,而是一个无界的活集合:一个不断增长的 cache 或 list,让 arena 的保留字节永远攀升。内存事件日志记录 arena 随时间的增长,让你找到泄漏、归因到源码位置,甚至在崩溃后做 OOM 事后剖析。
30 秒版
# 1. 用 --mem-track 编译(武装 arena 仪表)
superj compile myapp.sj --sdk-path $SJ_HOME/sdk --link --mem-track --output myapp
# 2. 以 SJ_MEM_LOG 指向一个路径运行(运行时武装日志)
SJ_MEM_LOG=tmp/myapp.mlog ./myapp
# 3. 读日志
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 域活字节、时间戳 |
两个分配域
SuperJ 有两个堆:
- Arena 域 — 所有
new对象。逐事件覆盖(CREATE/EXPAND/DROP)。 - Malloc 域 — String 与 map 直接用
malloc(热)。由折入每个SNAPSHOT记录的聚合计数器覆盖(仅活字节,非逐调用)。
日志与 superj memlog 报告都诚实标注了这个作用域。
读日志:superj memlog
superj memlog <path> # 一次性回放
superj memlog --tail <path> # 实时 tail 一个运行中的进程
没有路径默认 — 你显式传入日志文件。(运行时在 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. 每 arena
== per-arena ==
arena 1: final=18568 dropped=0 site=<unknown> 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 大的那个。
5. 泄漏判定 + 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(可能泄漏)。malloc 域行显示最后一次快照的 String/map 活字节。作用域部分诚实说明覆盖范围。
实时 tail
superj memlog --tail tmp/myapp.mlog
轮询日志的 writeCursor 并在被追踪进程写入时流式传输新记录。在约 10 秒静默期后终止(进程已退出)。依赖运行时先写记录后增游标的顺序,因此半写槽永不被读取。
实例
示例程序 $SJ_HOME/demo/memlog_demo.sj 模拟一个带两个作用域 arena 的请求循环:
# 用 --mem-track 编译
superj compile $SJ_HOME/demo/memlog_demo.sj --sdk-path $SJ_HOME/sdk --link --mem-track \
--output tmp/memlog_demo
# 运行 — 日志写到 SJ_MEM_LOG(未设时为 tmp/sj_mem_<pid>.mlog)
SJ_MEM_LOG=tmp/demo.mlog ./tmp/memlog_demo
# 读日志
superj memlog tmp/demo.mlog
输出(节选):
processed 5000 requests, sum=122500
== per-arena ==
arena 1: final=18568 dropped=0 site=<unknown> 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(); // 当前全局保留(arena 域)
long h = System.memHighWaterBytes(); // 进程生命周期内的高水位
两者在每个构建上都存活 — 它们是 arena chunk malloc/free 路径上的普通计数器,不属于 --mem-track / SJ_MEM_LOG 设施(后者只门控 mmap 事件日志)。它们在冷路径上花费两次加法,无需特殊构建。适用于自监控(例如每 N 请求记录保留)以及断言作用域内存已回来 — 跨一个打开 arena {} 块的调用的差额应为 0:
long before = System.memReservedBytes();
handleRequest(req);
long leaked = System.memReservedBytes() - before; // 期望 0
它们报告保留 chunk 容量(按 chunk 大小步进移动,非逐对象)且仅 arena 域。String body 由 malloc 分配且从不回收,因此字符串构建泄漏会让 RSS 增长而这些计数器保持平坦。
Debug 构建:逐类型直方图
对于对象级归因 — 哪些类型拥有活集合 — 一个独立的 debug 门添加一个逐类型活字节直方图。这会触碰热分配路径,因此被限制在 debug 构建,绝不能用于生产。debug 构建是一项高级特性 — 设置说明见 debug-memory 指南。
退出时直方图打印到 stderr:
== memory histogram (debug build) ==
type live_bytes live_count alloc_total
ContentType 1152 18 18
ByteArrayBuilder 1152 36 36
MsgType 168 7 7
PrintStream 32 2 2
debug 构建还在 arena 块被 drop 时用 0xDE 毒化被释放的 arena 内存,因此 drop 后的 use-after-free 读取会崩溃或读到毒化模式,而非静默读到过时对象。
线格式
日志文件是一个带版本号的 64 字节头,后跟固定 32 字节小端记录,可选地后跟一个尾部 site-table 部分。格式是稳定的(版本 1);回放工具在读取前校验魔数(SJML与版本。