运行时环境变量
编译出的 SuperJ 二进制不带任何配置文件,也没有任何命令行运行时 flag — 它就是一个普通的原生可执行文件,它从环境中拿到的每一样东西都经由这些变量。它们由运行时读取,而不是编译器:它们配置的是你已经构建好的程序,这正是意义所在。已部署的二进制可以不重新构建就重新调优,而这些默认值的选择使得一个全都不设的环境也是一种可支撑的生产配置。
编译器 flag(--mem-track、--mem-debug、-O3)是另一回事,记录在 build-system.md。当某个变量只在带特定 flag 的构建上起作用时,表里会注明。
完整列表
| 变量 | 默认值 | 作用 |
|---|---|---|
SJ_ARENA_MAX | 进程内存预算的 80% | 存活 arena 字节的硬上限。越界时打印 arena 的账本并以退出码 12 退出。 |
SJ_ARENA_INITIAL | 2 MiB | 全局 arena 第一个 chunk 的大小。 |
SJ_ARENA_SOLE_MIN | 1 MiB | 达到或超过此大小的分配会得到一个自己的 chunk。 |
SUPERJ_STACKTRACE | 捕获,不打印 | 异常栈跟踪的捕获与报告。 |
SJ_MEM_LOG | 关闭 | 内存事件日志的路径。仅 --mem-track 构建。 |
SJ_MEM_SNAPSHOT_EVERY | 1024 个事件 | 该日志中快照的节奏。仅 --mem-track 构建。 |
SJ_MEM_HIST_EVERY | 关闭 | 每分配 N 字节输出一份内存报告。仅 --mem-debug 构建。 |
SJ_INFLATE_MAX | 最大数组尺寸 | 解压输出上限,防 zip 炸弹。 |
SJ_NANOTIME | 可用时用 TSC | 强制 System.nanoTime() 离开 TSC 快速路径。 |
SUPERJ_GPU | 自动检测 | 选择 GPU 后端。 |
尺寸语法。 四个以字节为值的 arena 变量都接受同一形式:一个非负整数、一个可选的 k/m/g 后缀(大小写不敏感),以及一个可选的结尾 b。所以 128m、128M、128mb 和 134217728 是同一个数。结尾的垃圾字符会被拒绝,而不是被部分解析,因此 8zz 是坏值,不是 8。
每一个变量都在首次使用时读取一次并缓存。改变运行中进程的环境没有任何效果。
内存
SJ_ARENA_MAX
存活 arena 字节的硬上限 — 与 System.memReservedBytes() 报告的、System.memCeilingBytes() 读回的是同一个数。
SJ_ARENA_MAX=512m ./myapp # fail at 512 MiB
SJ_ARENA_MAX=8g ./myapp
SJ_ARENA_MAX=0 ./myapp # no ceiling — run to the host's limit
| 取值 | 上限 |
|---|---|
| 未设置 | 进程内存预算的 80% |
512m | 512 MiB |
0 | 无 |
无法解析(8zz) | 默认值 — 一个笔误绝不能解除保护 |
| 预算无法检测 | 无 |
为什么干脆有默认值。 SuperJ 没有垃圾回收器,也没有 OutOfMemoryError,所以一个无界的存活集没有属于它自己的终止条件:进程一直长,直到宿主机开始颠簸、OOM killer 收走它 — 没有信号、没有诊断、没有退出码,而宿主的 page cache 已经被一个服务挤光。等 malloc 失败也没用,因为在 Linux 上 overcommit 会交出一个内核根本无法兑现的指针。而一个没人设置的上限保护不了任何人:从没听说过这个变量的运维,恰恰就是进程会拖垮整台宿主的那个人。所以这个旋钮是覆盖,而不是打开保护的开关。
预算取宿主 RAM 与cgroup 限额(若存在)中的较小者 — 在 64 GiB 宿主上一个限 512 MiB 的容器得到的是 410 MiB,不是 51 GiB。
cgroup 的读取沿着整条链走,而不只看根。普通 Linux 机器上 /proc/self/cgroup 读到 0::/user.slice/user-1000.slice/session-93267.scope,而容器嵌套得同样深,所以有效限额是从该叶子到根之间最小的 memory.max — 父 slice 上的限额与叶子上的同样有效。cgroup v1 的 memory.limit_in_bytes 是回退方案;max 与 v1 接近 LONG_MAX 的哨兵值都表示无限制,此时以宿主数字为准。
20% 的余量留给 arena 之外的一切:malloc 域(String body、map)、栈、二进制自身的页。
越界时,进程把自己的账本写到 stderr 并以12 退出(ENOMEM,让监管进程能把它与普通失败区分开)。如果 malloc 真的失败,同样的摘要也会打印:
==== SuperJ: out of memory — shutting down (#3209) ====
cause : SJ_ARENA_MAX ceiling would be exceeded by this chunk
request : 8388608 bytes (one arena chunk)
ceiling : 8388608 bytes (SJ_ARENA_MAX)
arena reserved : 7340048 bytes (live, all arenas)
arena touched : 7340048 bytes (100.0% of reserved; 0 bytes of active-chunk slack)
high water : 7340048 bytes
chunks : 4 live, 4 allocated over the run
arenas created : 1
malloc domain : 0 bytes in 0 live allocation(s)
ceiling 一行说明这个数从哪里来 — SJ_ARENA_MAX,还是 80% of <budget> — 因为这份摘要最常见的读者正是没有设置它的那个人。
先拿 reserved 对照 touched 读:差距大说明 chunk 阶梯冲过了头;差距小则说明存活集真有这么大,那就是泄漏。越界的那个 arena 在构造上不贡献任何余量(我们恰好在它的活跃 chunk 满了的时候到达),所以单 arena 进程在那里读到 0。
摘要告诉你多少,不告诉你哪里。在 --mem-debug 构建上,按类型与按 site 的报告也会在这里打印,按增长排序,所以最上面一行就是那行代码(memory-tools.md):
== allocation sites (debug build, SJ_MEM_DEBUG) — out of memory ==
site global_bytes glob_cnt ...
Grow.sj:7 3316470 195740 ...
自我监控。 System.memCeilingBytes() 返回有效上限(0 = 无),因此服务可以在死掉之前减载,而不是之后:
long ceiling = System.memCeilingBytes();
if (ceiling > 0 && System.memReservedBytes() > ceiling / 10 * 9) {
// 90% of the way there — stop accepting work
}
SJ_ARENA_INITIAL
全局 arena第一个 chunk 的大小。它从 2 MiB 起步,每次扩展翻倍,上限 1 GiB;一个已经知道自己需要多个 GB 的进程可以跳过爬坡阶段的启动翻倍。
SJ_ARENA_INITIAL=128m ./myapp
SJ_ARENA_INITIAL=1g ./myapp # the cap; larger clamps to it
这不是预触页旋钮。chunk 就是普通的 malloc,从不预先触碰,所以更大的初始 chunk 只是省掉启动爬坡,并不能消除请求中途的缺页尖峰 — RSS 跟随的是touched,不是reserved。坏值回退到默认值。
SJ_ARENA_SOLE_MIN
达到或超过此大小的分配会得到一个自己的 chunk,而不是从当前 chunk 切一片,这样一个超大的单次请求就无法毒化 chunk 增长爬坡。没有它之前,一个 10 MB 数组让爬坡翻了一倍,接着任意大小的下一次分配就预留了一个 20 MB chunk — 在一个别无他事的块上,保留对存活 3.0 倍。
SJ_ARENA_SOLE_MIN=4m ./myapp # only >= 4 MiB gets its own chunk
SJ_ARENA_SOLE_MIN=256k ./myapp # more allocations get their own chunk
调高它以更少的 chunk 换取爬坡的整洁;调低则相反。坏值回退到 1 MiB 默认值,而不是取消这个下限。
诊断
SUPERJ_STACKTRACE
三个状态,因为捕获不是免费的:
| 取值 | 捕获 | 打印进未处理异常报告 |
|---|---|---|
| 未设置 | 是 | 否 |
1 | 是 | 是 |
0 | 否 — getStackTrace() 返回空 | 否 |
实测:200,000 次 throw/catch 循环,带捕获 0.42s,不带 0.015s — 每次 throw 约 2.1 µs 对约 0.075 µs。异常标记的是 bug 而非控制流,所以默认捕获是对的;在循环里抛异常的程序可以设 0,不必放弃 SDK。
它默认不打印,因为栈跟踪的帧数随优化级别变化,永远无法出现在做比较的 golden 测试 stderr 里。printStackTrace() 是你刻意调用的,不受该 flag 影响。
SJ_MEM_LOG — 仅 --mem-track 构建
内存事件日志的路径;设置它即武装日志。不设置意味着即使在 --mem-track 构建上也没有日志 — 正因如此,这份插桩才可以放心留在发布的二进制里。见 memlog.md。
superj compile myapp.sj --sdk-path $SJ_HOME/sdk --link --mem-track --output myapp
SJ_MEM_LOG=tmp/myapp.mlog ./myapp
superj memlog tmp/myapp.mlog
SJ_MEM_SNAPSHOT_EVERY — 仅 --mem-track 构建
该日志中隔多少个 arena 事件记一条 SNAPSHOT(默认 1024)。对短促运行、本会产生快照不足以让 superj memlog 算出增长斜率的场景,把它调低 — 症状是在一个原始 CREATE/EXPAND 时间线明显泄漏的程序上出现 insufficient snapshots for slope。非正数或畸形值回退到默认值。
SJ_MEM_HIST_EVERY — 仅 --mem-debug 构建
每 N 字节全局分配输出一份完整内存报告(按类型与按 site 表),让长跑不修改程序就产生增长序列。每份报告带一个相对上一份测量的 d_global 列,并按它排序,所以正在攀升的那一行会排在"大而平"的上面。
SJ_MEM_HIST_EVERY=$((64*1024*1024)) ./myapp # a report every 64 MB allocated
不设置即关闭。同一份报告也可在程序内按需获取:System.memReport(label)。
其他
SJ_INFLATE_MAX
sj.util.Compression 解压输出的最大尺寸,单位字节。默认为最大数组尺寸。解压不受信任的输入时把它调低 — zip 炸弹是一个输出才是攻击本身的小输入。高于默认值的取值或无法解析的取值一律忽略。
SJ_NANOTIME
System.nanoTime() 在 CPU 报告不变 TSC 时读 TSC,否则回退到 clock_gettime。把它设成以 o 开头的值(如 off)即强制回退 — 当你怀疑是 TSC 校准而不是自己的代码时有用。
SUPERJ_GPU
当 GpuDevice.open() 被调用而未显式指定后端时,选择 sj.gpu 后端。未设置即自动检测。在代码里传入后端名,在不可用时是硬错误;这个变量是部署时的等价物。
另见
- memory-management.md — 这些旋钮所调的分配模型。
- memory-tools.md — 哪个仪表回答哪个问题。
- memlog.md — 事件日志、
--mem-track与superj memlog。 - build-system.md — 编译器 flag,那是另一个层面。