首页 · ← SuperJ 手册 中文|EN

内存工具:查清你的程序到底攥着什么

一门没有 GC 的语言让内存问题能以垃圾回收语言做不到的方式得到回答 — arena 要么把字节还了,要么没还,中间没有一个回收器来跟你争辩。本页就是回答这些问题的一组仪表,按你应当动用它们的顺序排列。

如果你要的是模型 — arena、local、作用域规则 — 请先读 内存管理最佳实践。如果你要的正是事件日志,内存事件日志是它的完整指南。本页讲的是诊断:有个东西在增长,它是什么,为什么。


决定一切的那个区别

SuperJ 没有垃圾回收器,而且全局 arena 从不释放。所以一个进程的增长有两种截然不同的方式,它们从外表看一模一样,修法却相反:

1. 保留型泄漏。 有东西一直持有引用:一个从不淘汰的 cache、一个只追加的列表、一个不断累积的 static。内存是可达的。最终你会耗尽。这就是你在 Java 里早已熟悉的那种泄漏。

2. 没有作用域的垃圾。 没有任何东西指向这块内存 — 工作完成的瞬间它就不可达了 — 但也没有任何东西释放它,因为它分配在全局 arena 里,而全局 arena 的寿命是整个进程。垃圾回收器会回收它;SuperJ 不会。

第二种在 Java 里不存在,所以是人们最容易漏掉的那种。而在服务器里,它通常也是更大的一半。一个请求或一条连接会分配临时量;如果没有作用域随请求结束而关闭,这些临时量的每一个都成了永久量。

System.memAudit() 是唯一能把两者区分开的工具。其他所有工具告诉你是什么在分配;这个工具告诉你它为什么还在那里。


第 1 步 — 它真的在增长吗?

warmUp();                                   // let one-time costs happen first
long before = System.memTouchedBytes();
for (int i = 0; i < 500; i++) {
    doOneRequest();
}
long grew = System.memTouchedBytes() - before;
System.out.println("grew " + grew + " bytes over 500 requests");

memTouchedBytes() 精确到字节且零成本 — 它在每个构建上都可用,不只是 debug 构建。

为此请用 memTouchedBytes(),而不是 memReservedBytes()。 保留字节统计的是 arena chunk 预留,所以这个数按 chunk 大小的台阶跳动。一个每请求泄漏一点点的东西会隐形在当前 chunk 未用完的余量里 — 而且因为 chunk 大小会翻倍,进程跑得越久,这个盲区越大。实测一个每轮 1 KB 的泄漏,在计数器动之前,精确停在起始值上长达 965 轮,将近 1 MB 的真实泄漏。SDK 自己的一个泄漏就曾两次突破以保留字节为界的测试。

>

memReservedBytes() 用于另一个问题 — 那个 arena {} 块把内存还回来了吗? — 在那个场景里,跨作用域调用的差值必须为 0,而 chunk 粒度无关紧要。

>

取这个差值要跨整个调用取,而不是在干活的那个方法内部取。一个丢弃被调用者新鲜结果的方法会得到一个隐式帧区域(frame region),在其内部读数会计入该区域存活的第一个 256 B chunk:一个稳定的 +256,一次,返回时被回收(#5163;见帧区域)。

在测试里,这一切你免费得到:

superj test --check-growth

每个 @Test 跑两遍。第一遍吸收合理的一次性成本 — 惰性 static、首次使用的 cache、长到稳态尺寸的 builder — 第二遍必须精确保留 0 字节。一个以持有内存为全部目的的测试可以被豁免:

superj test --check-growth --allow-growth CacheTest

第 2 步 — 增长是线性的吗?

分别在 100、200 和 400 个单位的工作负载下运行,再除以单位数。如果每单位数字恒定,增长就是线性的,进程跑不了无限久 — 它会被 OOM killer 杀掉,问题只是什么时候。如果每单位数字在下降,你看到的是摊销掉的预热,那没问题。

这一步值得显式去做,因为"它每个请求用几 KB"和"它九小时后会死"是同一句话,而只有其中一种说法能让人行动。


第 3 步 — 是什么分配的?

加一个 flag 重新构建:

superj compile app.sj --sdk-path $SJ_HOME/sdk --link --mem-debug --output app
./app

退出时,程序打印两张表:一张按类型,一张按源码行。

按行的表通常才是你要的那张,因为它把一个构造函数里的 new byte[256] 与调用它的 new Session() 区分开 — 按类型的表会把两者都报告为 Session 和 <raw/array>,让你干猜。

要的是速率而不是总量时,用报告把可疑的工作夹起来:

warmUp();
System.memReport("baseline");
for (int i = 0; i < 100000; i++) {
    handle(i);
}
System.memReport("after 100k");

每份报告都会重新设定基线,所以连续两次调用读作"这两点之间长了什么"。一旦存在基线,表就按增长量而不是总量排序 — 这比听起来更重要:一张启动时加载一次的 200 MB 查找表,在任何按总量排序的视图里都会永远压过每个请求保留的那十六个字节。

对一个你不想修改的长跑进程,设置 SJ_MEM_HIST_EVERY=<bytes>,运行时就会自己每 N 字节全局分配吐一份报告。

把 global_bytes 当作泄漏列来读 — 全局 arena 从不归还内存,所以它的累计字节就是它的存活字节。

decision 列:它为什么在那里,而不只是在哪里

最后一列说的是编译器对这个分配做了什么决定,这正是字节数说不清的那一半。它的取值里有三个全是全局流量,而且对应三个不同的问题、三种不同的修法:

decision发生了什么修在哪里
BLOCK放进了外层 arena { } 块,随块一起回收无需修复
GLOBAL当时什么都没开 — 普通的默认域给它一个作用域
PROMOTED写在块内又被移出块外,因为值活得比块久在让它逃逸的那次存储上修,不在分配处
CARVE-OUT外层调用被解除武装:它的被调用者无法承诺不保留自己分配的东西通常是别人方法上的一个注解
STACK栈上提升 — 它根本没花 arena 内存无需修复

带星号的值如 GLOBAL* 表示它是被运行解决的,而不是编译器:sj_galloc 家族在运行时从当时武装的区域里选,所以编译期诚实的答案是"环境决定",星号标出的正是"决定"与"观察"之间的差别。

PROMOTED 是唯一改变你行动的那一个。它告诉你分配本身没问题、问题出在存储上 — 而这在字节被归属到的那一行是看不见的。block_bytes 是某个 arena {} 块已经回收掉的流转量,那是一个块在正常履职。

在信任这张表之前,先看表底部的 coverage: 一行。 site id 由调用方代码发出,所以在预构建 SDK 归档内部发生的分配根本不发 site,甚至不会以"未归属"的形式出现。String body 是最主要的未覆盖种类。

>

如果覆盖率低,改用 --sdk-source 而不是 --sdk-path 重新构建。 那样会用相同的 flag 编译 SDK,它自己的行就带 site id,表也就能归属它们。当那些字节是数组时,这就是死胡同与答案之间的差别:按类型的表把每个数组都叫 <raw/array>,什么也没说。在一次调查中(#4967),归档构建报告 coverage: 320 of 112760 retained bytes attributed to a source line (0%),按类型表显示 <raw/array> 104800 — 而源码构建在一次运行里就把全部字节归属到了三个 SDK 文件的四行上。预期构建要花几分钟而不是几秒。

carved-calls 部分:是哪个调用让块失去了回收

CARVE-OUT 出现在上面的表里只是为了完备,但你几乎不会在那里看到它,而这个原因值得知道,因为它指向那个确实回答问题的部分。carve-out 是调用的属性,不是分配的属性:编译器在一个调用两侧解除环境区域的武装,因为其被调用者无法承诺不保留自己分配的东西,于是字节落在被调用者里,离你本该改的那一行隔着好几帧。

所以报告有第三个部分(#4874),打印在按行表之后:

== carved calls (debug build, SJ_MEM_DEBUG) ==
  a call inside an `arena {}` block whose callee cannot promise it keeps nothing
  it allocates is CARVED OUT (#3805 C1): the ambient region is the global arena
  across it, so what the callee allocates outlives the block. These are the bytes
  the block did not reclaim, charged to the CALL — which is where the fix is.
  carved call                    global_bytes   glob_cnt      calls   per_call
  Session.sj:88                      14400000     100000     100000        144

把它读作:这一行就是你的 arena { } 块没有回收你以为能回收的东西的原因。 per_call 是一次调用的价格,这正是拿来跟你以为已经作用域化的那个块的尺寸作比较的数字。

三种修法,按值得尝试的顺序:

  1. 给被调用者写摘要(summarize)。 carve-out 之所以存在,是因为无法证明被调用者不保留它分配的东西。如果它确实什么都不保留,声明这一点就能移除 carve-out,字节回到块里。W_ARENA_CARVE_OUT(strict arena 内的一个警告)在编译期点名同样的调用 — 同一个事实,提前到运行之前。
  2. 把调用移出块外,如果它分配的东西本来就是要在块外活着的。这样什么都不会变,除了报告不再暗示相反的情况。
  3. 给这项工作自己的区域(region)(doc/regions.md),当生命周期不是词法的时候。

这些与按类型表统计的是同一批字节,而且 — 在被调用者以 --mem-hist 构建的情况下 — 与按行表在被调用者自己名下显示的也是同一批。同一批字节的三种视图。不要把几个部分相加。

这一部分还覆盖在预构建 SDK 归档内部发生的分配,而上面的按行表做不到:它记在分配器头上而不是来自编译期 site id,而一个无法写摘要的 SDK 方法正是 carve-out 最常见的存在理由。


第 4 步 — 它为什么还在那里?

这一步决定修法,却是最常被跳过的一步 — 按行表看起来像个答案,但它只是一半。

System.memAudit("after 200 connections");

memAudit 从程序的根出发追踪全局 arena,报告每一个不再有任何东西指向的分配,按制造它的源码行并且按使它变成孤儿的存储分组,末尾带一个 reachable: 总数。

这是一个恰好 100 个 key 的 cache、在 5,000 次各 put 一个全新 64 字节值的请求之后的样子:

== garbage audit — after 5000 ==
  unreachable allocations in the global arena: nothing points at these, and
  an arena never frees one, so they are held until the process exits.
  site                                      garbage_bytes      count  orphaned at
  <site:0>                                         226279      15929  <unknown>
  Leak.sj:4                                        367200       5100  Leak.sj:4
  TOTAL GARBAGE                                    593479      21029
  reachable: 14542 bytes in 303 allocations
  Conservative: an integer that looks like a heap address keeps an object
  alive, so this UNDER-reports. Everything named above is really unreachable.
== end garbage audit ==

那 reachable 的 14 KB 就是 cache 本身 — 有界、正确、正是设计意图。593 KB 的垃圾是 cache 覆盖掉的每一个旧值。没有编译器警告触发,存活集从不增长,进程照样会死。

orphaned at:该改的那一行

知道字节从哪里来不等于知道该编辑什么。对一个被替换掉的值,分配它的行和丢弃对它最后一个引用的行是不同的两行,而只有第二行可以改。运行时不需要费力推算 — 它亲眼看着那次存储发生 — 所以审计直接把它报出来。

关于这一列,有两件事值得知道:

在两三个规模上运行,盯着 reachable: 那一行。 那一个数字就是全部诊断:

你看到的它的含义该做什么
reachable 随工作负载增长真正的保留型泄漏 — 有东西一直持有引用给它设界、淘汰或池化。--warn-retained(第 5 步)多半已经点名了它
reachable 恒定,垃圾在增长没有任何东西被保留;只是这块内存没有会结束的作用域给它一个 — 见下文

对第二行,修法是作用域化,不是池化:

上面的例子值得多看两眼,因为它是一类任何编译器检查都够不着的泄漏。一个值会被替换的有界 cache 持有完全恒定的存活集,却让 arena 无限增长,而没有任何声明能抓住它的原因是结构性的:逃逸分析问的是一个分配可能去往何处,而这里每个分配都恰好去往它该去的地方。泄漏的是那个被覆盖的值 — 这是赋值的属性,不是任何分配点的属性。(同形状的 1,000-key 版本实测每请求保留 176 字节,没有任何诊断发声。)

它只报告;从不回收。回收器能让那段代码勉强活下来 — 这里的目的是让它可修。

memAudit 刻意保守:任何碰巧长得像堆地址的对齐字都会让它指向的对象存活。所以它少报。它点名的每一样东西都确实不可达,它的失败模式是对泄漏保持沉默,绝不会冤枉一个并不存在的泄漏。


第 5 步 — 问编译器(这一步可以先做)

其中好几个是免费的,而且在编译期运行。只要有一个触发,你就能跳过整个测量循环。

flag能抓到什么
--warn-retained分析证明会到达 static 的分配 — 包括存储隔了两三层被调用者的情况,而这正是一个每请求处理器常见的形状
--warn-unproven-escape因为分析无法证明它留下而被移出 arena { } 块的值,并点名破坏证明的那个调用
--warn-alloc-in-loop循环体内的分配
--warn-string-concat-in-loop循环中 + 造成的字符串流转
--warn-no-reset-in-loop你忘了 reset 的可复用缓冲区
--dump-escape每个分配的位置与置信度:STACK / BLOCK / PROMOTED / GLOBAL

有三个警告默认开启,因为没有人会想到去要求它们:

你还可以让编译器强制执行意图,而不是去猜它:strict arena n { }、local 和 @NoRetain 会在"大概没事"其实有事时变成编译错误。见Claim 层。


第 6 步 — 怀疑 use-after-drop 时

在块被 drop 之后读 arena 内存并不是崩溃 — free() 留下的字节仍然可读,所以程序带着过期数据继续跑,只在某次中间分配复用了那个 chunk 时才失败。小输入通过;某天一个大输入在毫不相干的地方崩溃。

--mem-debug 用 0xDE 填充被 drop 的 chunk,把这种失败变成即时的、一眼可见的失败:

Fatal: SIGSEGV at address 0xdededededededede

如果你在做任何涉及手动生命周期的事 — region、池化对象、持有指针的 native — 在相信它能用之前,先在 --mem-debug 下把测试套件跑一遍。

--ea-check 在存储处而不是读取处抓住这个错误。 --mem-debug 只有在被释放的内存被复用之后又被读取时才显示 use-after-free。--ea-check 包含 --mem-debug 的一切,还检查每一次指针存储、return 和块出口是否违反相关区域的生命周期。第一次把指针放进一个会先被释放的区域的存储,会让程序以退出码 86 停止,点名存储所在行以及区域打开的位置:

superj compile app.sj --sdk-path $SJ_HOME/sdk --link --ea-check --output app && ./app
EA-CHECK: store at app.sj:41 puts a pointer into arena block #7 (opened at app.sj:38) where the
global arena holds it -- the first is freed while the second can still reach it (use-after-free)

它对 superj build、run 和 test 也有效。检查构建更慢(每个函数都保持不内联),所以把它们用于测试运行,别用于生产。在预构建 SDK 内部做出的存储不被检查,除非你用 --sdk-source 编译。设置 SJ_EA_CHECK_SUMMARY=1 可以看到检查了多少次存储。


一个实战例子

SDK 自己的 TLS 服务器路径,在一次内存战役中被追踪的全过程:

做了什么每连接字节
起点464,720
把每个可池化对象逐一池化4,896

进展可观 — 但仍是线性,所以仍然意味着迟早 OOM。按 site 的表指出了哪些行在分配,每一轮池化消掉一行。它从未到零,而且原因并不明显。

memAudit,在三个规模上,全部池化配置:

连接数垃圾总量reachable
100473,319 B509,482 B,201 次分配
200928,721 B509,482 B,201 次分配
4001,839,493 B509,482 B,201 次分配

reachable 精确恒定 — 每个规模上、同样的分配里、同样的字节。没有任何东西在保留任何东西;没有引用泄漏,从来没有过。增长的每一个字节都在连接关闭的瞬间不可达。池化一直在攻击错误的问题,而且永远不可能收工:剩下的都是没有东西可池化进去的瞬态量。

真正的原因是一条连接没有作用域。arena { } 是词法的;一条连接横跨许多 poll() 回调,周围没有块。给它一个 — 一个 region — 同一条路径达到了在全部池化关闭的情况下每连接 1,008 字节,而 reachable 集从 509,482 字节降到 823。

一次 memAudit 调用回答了按 site 的表几周都没回答的问题。


速查表

每个构建都可用需要 --mem-track需要 --mem-debug
System.memTouchedBytes() — 精确到字节,用于断言✓
System.memReservedBytes() — 块把内存还回来了吗?✓
System.memHighWaterBytes() / memCommittedBytes()✓
superj test --check-growth✓
编译器警告、--dump-escape✓(编译期)
superj memlog — 逐 arena、事后剖析、实时跟踪✓
按类型与按行表、System.memReport()✓
System.memAudit() — reachable 与垃圾✓
0xDE use-after-drop 投毒✓

memReport 和 memAudit 在非 debug 构建上是空操作,所以对它们的调用可以留在随产品发布的源码里。


另见

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