首页 · ← SuperJ 手册 中文|EN

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

两个分配域

SuperJ 有两个堆:

日志与 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 是那个仍 LIVEfinal 大的那个。

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 — 它会显示 LIVEfinal 很大,全局高水位也不会回来。那就是泄漏信号。

进程内查询计数器

两个 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与版本。

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