Runtime Environment Variables
A compiled SuperJ binary carries no configuration file and no command-line runtime flags — it is a plain native executable, and everything it takes from its environment it takes through these variables. They are read by the runtime, not the compiler: they configure a program you have already built, which is the point. A deployed binary can be re-tuned without a rebuild, and the defaults are chosen so that setting none of them is a supportable production configuration.
Compiler flags (--mem-track, --mem-debug, -O3) are a different thing and are documented in build-system.md. Where a variable only does something on a build made with a particular flag, the table says so.
The whole list
| Variable | Default | What it does |
|---|---|---|
SJ_ARENA_MAX | 80% of the process's memory budget | Hard ceiling on live arena bytes. A breach prints the arena's books and exits 12. |
SJ_ARENA_INITIAL | 2 MiB | Size of the global arena's first chunk. |
SJ_ARENA_SOLE_MIN | 1 MiB | Allocations at or above this get a chunk of their own. |
SUPERJ_STACKTRACE | capture, do not print | Exception stack-trace capture and reporting. |
SJ_MEM_LOG | off | Path for the memory-event log. --mem-track builds only. |
SJ_MEM_SNAPSHOT_EVERY | 1024 events | Snapshot cadence in that log. --mem-track builds only. |
SJ_MEM_HIST_EVERY | off | Emit a memory report every N bytes allocated. --mem-debug builds only. |
SJ_INFLATE_MAX | the max array size | Cap on decompressed output, against zip bombs. |
SJ_NANOTIME | TSC where usable | Force System.nanoTime() off the TSC fast path. |
SUPERJ_GPU | auto-detect | Select the GPU backend. |
Size syntax. The four byte-valued arena variables all take the same form: a non-negative integer, an optional k/m/g suffix (case-insensitive), and an optional trailing b. So 128m, 128M, 128mb and 134217728 are the same number. Trailing garbage is rejected rather than partially parsed, so 8zz is a bad value, not 8.
Every one of them is read once, at first use, and cached. Changing the environment of a running process has no effect.
Memory
SJ_ARENA_MAX
A hard ceiling on live arena bytes — the same number System.memReservedBytes() reports and System.memCeilingBytes() reads back.
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
| value | ceiling |
|---|---|
| unset | 80% of the process's memory budget |
512m | 512 MiB |
0 | none |
unparseable (8zz) | the default — a typo must not disarm the protection |
| budget undetectable | none |
Why there is a default at all. SuperJ has no garbage collector and no OutOfMemoryError, so an unbounded live set has no termination condition of its own: the process grows until the host thrashes and the OOM killer takes it — no signal, no diagnostic, no exit code, and a host whose page cache has been evicted by one service. Waiting for malloc to fail does not help, because on Linux overcommit hands back a pointer the kernel cannot honour. And a ceiling nobody sets protects nobody: the operator who has never heard of this variable is exactly the one whose process takes the host down. So the knob is the override, not the switch that turns the protection on.
The budget is the host's RAM, or the cgroup limit where one applies, whichever is smaller — a container with a 512 MiB limit on a 64 GiB host gets 410 MiB, not 51 GiB.
The cgroup read follows the chain, not just the root. /proc/self/cgroup on an ordinary Linux box reads 0::/user.slice/user-1000.slice/session-93267.scope, and a container is nested just as deep, so the effective limit is the smallest memory.max from that leaf up to the root — a cap on a parent slice binds exactly as hard as one on the leaf. cgroup v1's memory.limit_in_bytes is the fallback; max and v1's near-LONG_MAX sentinel both mean unlimited and leave the host figure standing.
The 20% headroom is for everything that is not the arena: the malloc domain (String bodies, maps), the stack, the binary's own pages.
On a breach, the process writes its books to stderr and exits 12 (ENOMEM, so a supervisor can tell it from a plain failure). The same summary prints if malloc does fail:
==== 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)
The ceiling line says where the number came from — SJ_ARENA_MAX, or 80% of <budget> — because the commonest reader of this summary is someone who did not set it.
Read reserved against touched first: a large gap means the chunk ladder overshot, a small one means the live set really is that big, which is the leak. The breaching arena contributes no slack by construction (we arrive exactly when its active chunk is full), so a single-arena process reads 0 there.
The summary says how much, not where. On a --mem-debug build the per-type and per-site reports print here too, ordered by growth, so the top row names the line (memory-tools.md):
== allocation sites (debug build, SJ_MEM_DEBUG) — out of memory ==
site global_bytes glob_cnt ...
Grow.sj:7 3316470 195740 ...
Self-monitoring. System.memCeilingBytes() returns the effective ceiling (0 = none), so a service can shed load before it dies rather than after:
long ceiling = System.memCeilingBytes();
if (ceiling > 0 && System.memReservedBytes() > ceiling / 10 * 9) {
// 90% of the way there — stop accepting work
}
SJ_ARENA_INITIAL
Size of the global arena's first chunk. It starts at 2 MiB and doubles on each expansion up to a 1 GiB cap; a process that already knows it needs multiple GB can skip the ramp's startup doublings.
SJ_ARENA_INITIAL=128m ./myapp
SJ_ARENA_INITIAL=1g ./myapp # the cap; larger clamps to it
This is not a pre-fault knob. Chunks are plain malloc and are never pre-touched, so a larger initial chunk removes the startup ramp but does not eliminate page-fault spikes mid-request — RSS tracks touched, not reserved. A bad value falls back to the default.
SJ_ARENA_SOLE_MIN
An allocation at or above this size gets a chunk of its own rather than a slice of the current one, so a single outsized request cannot poison the chunk-growth ramp. Without it, one 10 MB array left the ramp doubled and the next allocation of any size reserved a 20 MB chunk — 3.0× reserved against live on a block doing nothing else.
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
Raising it trades ramp hygiene for fewer chunks; lowering it does the reverse. A bad value falls back to the 1 MiB default rather than disabling the floor.
Diagnostics
SUPERJ_STACKTRACE
Three states, because capture is not free:
| value | capture | printed in the unhandled-exception report |
|---|---|---|
| unset | yes | no |
1 | yes | yes |
0 | no — getStackTrace() answers empty | no |
Measured: 200,000 throw/catch cycles cost 0.42s with capture and 0.015s without — ~2.1 µs per throw against ~0.075 µs. Exceptions mark bugs rather than control flow, so capture-by-default is right; a program that throws in a loop can say 0 without giving up the SDK.
It is not printed by default because a trace's frame count varies with optimization level, so it could never appear in a golden test's compared stderr. printStackTrace(), which you call deliberately, ignores the flag.
SJ_MEM_LOG — --mem-track builds only
Path for the memory-event log; setting it arms the log. Unset means no log even on a --mem-track build, which is what makes the instrumentation free to leave in a shipped binary. See 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 builds only
How many arena events between SNAPSHOT records in that log (default 1024). Lower it for a short run that would otherwise produce too few snapshots for superj memlog to compute a growth slope — the symptom is insufficient snapshots for slope on a program whose raw CREATE/EXPAND timeline obviously leaks. A non-positive or malformed value falls back to the default.
SJ_MEM_HIST_EVERY — --mem-debug builds only
Emit a full memory report (per-type and per-site tables) every N bytes of global allocation, so a long run produces a growth series without the program being edited. Reports carry a d_global column measured against the previous one and order by it, so the row that is climbing sits above a large-but-flat one.
SJ_MEM_HIST_EVERY=$((64*1024*1024)) ./myapp # a report every 64 MB allocated
Off unless set. The same report is available on demand from the program via System.memReport(label).
Other
SJ_INFLATE_MAX
Maximum decompressed output size, in bytes, for sj.util.Compression. Defaults to the maximum array size. Lower it when inflating untrusted input — a zip bomb is a small input whose output is the attack. Values above the default, or unparseable ones, are ignored.
SJ_NANOTIME
System.nanoTime() reads the TSC where the CPU reports an invariant one, and falls back to clock_gettime otherwise. Set this to a value beginning with o (as in off) to force the fallback — useful when you suspect TSC calibration rather than your own code.
SUPERJ_GPU
Selects the sj.gpu backend when GpuDevice.open() is called without an explicit one. Unset means auto-detect. Passing a backend name in code is a hard error if unavailable; this variable is the deployment-time equivalent.
See also
- memory-management.md — the allocation model these knobs tune.
- memory-tools.md — which instrument answers which question.
- memlog.md — the event log,
--mem-track, andsuperj memlog. - build-system.md — compiler flags, which are a separate surface.