Home · ← SuperJ Manual EN|中文

Memory Management Best Practices

SuperJ has no garbage collector. new allocates from an arena; the default arena is the global one, which lives until the process exits. That gives you allocation at bump-pointer speed and zero pause time — and one obligation in exchange: there is no such thing as a short-lived object unless you make it one. A new in a hot path is not "pressure the collector will absorb"; it is a leak with a rate.

This page is the practice: how to make memory usage flat — the ladder of allocation strategies, the arena and stack tools, the scratch-buffer cookbook, and the built-in log that proves your process is behaving.

Contents

  1. The key insight: single-threaded is a license
  2. 1.1 What counts as a leak
  3. The allocation ladder
  4. Leveraging arenas
  5. Scratch buffers: the reuse cookbook
  6. 4.1 When you cannot presize: System.resize
  7. Stack allocation with local
  8. Watching it: the memory-event log
  9. The review checklist

1. The key insight: single-threaded is a license

SuperJ programs are strictly single-threaded (you scale with processes, not threads — see Concurrency). For memory this is not a limitation; it is a license that removes an entire category of cost that multi-threaded code pays:

Everything below is a technique for exploiting this license: the goal is a process whose memory footprint is flat no matter how many requests, queries, or iterations it serves.

1.1 What counts as a leak

Worth defining before the techniques, because the definition you bring from another language will send you after the wrong bug:

A memory leak is memory the program keeps alive that its author did not intend to keep alive.

"Keeps alive" rather than "uses": allocating a lot on purpose is not a leak, and memory you have stopped using is still held until its arena drops.

This excludes several things that look alarming and are not leaks — the global arena never shrinking (by design), a 200 MB table loaded once at startup, a presized buffer running at 1% utilisation. It includes several that look small and are — sixteen bytes retained per request, a buffer abandoned on every doubling step, a chunk ramp left doubled by one outsized allocation.

Because it is a statement about intent, no tool can decide it for you, and SuperJ deliberately ships no leak detector. What it ships instead is a way to declare intent (local, strict arena, @NoRetain), a warning wherever intent cannot be proven (W_UNPROVEN_ESCAPE and friends, each naming the site), and measurement of what is actually held so you can compare it against what you meant (§6).

There is one mechanism, not a list

A reference that outlives its usefulness while living in the global arena.

That is the whole surface, because the global arena is the only domain that never gives memory back. Everything else either cannot leak or reduces to it:

the value…what happenscan it leak?
dies in its frame or iterationstack-allocated automatically — no local neededno
dies in an arena {} blockreclaimed on every exit pathno
escapes a blockplaced somewhere longer-lived — a placement failureyes, and W_UNPROVEN_ESCAPE says so by default
is kept on purposepolicy — you hold the referenceyes, and --warn-retained can name it, though only you can decide it

Since #4735 the compiler can name the line for the last row: --warn-retained reports W_RETAINED for an allocation it has proved reaches a static-rooted location — directly, or through a callee that stores it there. It is opt-in because the fact it reports is not a verdict: the compiler can see that the value is kept forever, and cannot see whether you meant it to be. On the compiler's own sources it finds 41 such places and every one is deliberate. What it removes is the searching, which is the expensive half. Discharge a row with @NoWarn on the member when the retention is the point.

The warning fires where retention has a rate — the same allocation repeated every call, which is what makes it a leak. So it deliberately stays silent where the value is just as retained but nothing repeats:

the allocation sits in…why it stays silent
a constructor or a static initializerruns once per object / once per process
the program entry point (static void main(String[]))runs once — an ordinary method that merely shares the name main has a rate like any other
a throw operandhappens at most once on the path that throws
a local-claimed bandyou already own that band — a violated claim is an error, not a warning
a loop variable rebound each iteration and aliased nowhere elseholds one object at a time, not a growing set
a value the analysis never witnessed reaching the staticthe warning names only proved facts, never assumptions

The natural first experiment — storing a new into a static from main — is therefore silent by design. To see the warning, put the allocation in a method that runs per request:

class SessionCache {
    static Session keep;

    // runs per request -- the shape W_RETAINED looks for
    static void handle(int id) {
        keep = new Session(id);    // <-- reported
    }

    public static void main(String[] args) {
        handle(args.length);
    }
}

class Session {
    int id;
    Session(int id) { this.id = id; }
}
R.sj:6:27: warning[W_RETAINED]: Session (line 6) is kept for the life of the process: it
reaches a static-rooted location, and the global arena never gives memory back (SPEC §7.2).
Nothing evicts it, so if this method runs per request it is a leak with a rate. Bound the
container, scope the value in an 'arena' block, or suppress with @NoWarn if the retention
is the point.

A new in a loop is not, by itself, a leak. If the value dies in the iteration the compiler hoists it to the stack and the loop allocates nothing at all: measured, 200,000 iterations of new Node(i) and 20,000 of new byte[1024] both move memTouchedBytes() by 0, and the IR shows an alloca hoisted out of the loop rather than an allocation inside it. Store the same value into a static and it costs 3.2 MB. That is why the syntactic warning (W_ALLOC_IN_LOOP) is opt-in rather than on by default — on this compiler it is mostly false positives, and #3739 had to exempt three legitimate shapes one at a time. The real question is never "is there a new in this loop" but "can the analysis prove the value dies here" — see §2 of the zero-garbage guidelines for the measured 128× gap between the two cases.

One shape breaks the hoisting promise, and it looks exactly like the innocent ones: a wrapper whose constructor allocates its buffer. for (…) { ByteArray b = new ByteArray(64); … } stack-promotes the wrapper — yet it leaks ~130 bytes per iteration, because the constructor's new byte[64] outlives the constructor call and is never freed: the wrapper dies on the stack, the buffer it owns is orphaned. This is true of any wrapper-owns-a-buffer type — ByteArray, ArrayList, StringBuilder — and of user classes with the same shape (class Buf { byte[] b; … }), not just SDK classes. Inside an arena {} block the same construction is free (the buffer is reclaimed with the block), so the fix is either that block or hoisting the wrapper outside the loop and reusing it. The compiler reports this by default (W_CTOR_BUFFER_IN_LOOP, #4738), on the wrapper's new line, naming the constructor's buffer as the cost. It is on by default where W_ALLOC_IN_LOOP is opt-in because it is not the same kind of claim: the wrapper's stack promotion is a proof that the wrapper dies, and the constructor's summary is a proof that its buffer does not. Silence it with --no-warn-ctor-buffer-in-loop, or @NoWarn on the member when the per-iteration construction is intended.

One shape it deliberately stays quiet on: a constructor that wraps a buffer it was handed rather than allocating one. new ByteArray(raw) costs nothing per iteration, and saying otherwise on the idiomatic zero-copy view is what would make the warning unusable.

So there is exactly one leak the language permits without telling you: you kept a reference deliberately, and kept keeping it. A cache and a leak are the same code; only your intent distinguishes them, which is where this section began.

Allocator overhead is a footnote rather than a category: abandoned growth buffers and ramp over-reservation both held bytes nobody asked for, and both are ~1.00× since #3846/#3857.

2. The allocation ladder

When you are about to write new, try these in order:

  1. Don't allocate. Most allocations are avoidable outright: return a primitive instead of a wrapper; fill a caller-owned array instead of returning a fresh one; read two longs out of a mapped file instead of materializing an index object; precompute strings once instead of concatenating per call.
  2. local — stack-allocate a small, provably non-escaping temporary (§5). Freed on scope exit, costs nothing. Two sharp caveats below.
  3. Reuse a long-lived scratch buffer — for anything sized by the data, this is the answer (§4). Allocate once, reuse across every call; grow on demand, never shrink. But not for a buffer you WRITE every iteration of a hot loop — a store to a fixed global address is an aliasing barrier and costs 9× on x86 (#3356). The rung is for buffers sized by the data; a fixed-size staging array for a Simd.* call belongs on rung 2 (local), and a per-iteration accumulator belongs on rung 1 (scalars). Scratch that is filled once and read per iteration — lookup tables, precomputed coefficients — is exactly what this rung is for and is unaffected. Details in §4.
  4. An arena block — for a batch of scoped intermediates you want dropped together, in O(1). Escape is placed, not rejected: a value that outlives the block is allocated somewhere longer-lived instead, so the block reclaims what dies in it — and not the rest, which the compiler warns on: a promotion the analysis could not prove necessary warns (W_UNPROVEN_ESCAPE, naming the call that broke the proof — #3714, SPEC §7.4.3), while a witnessed escape stays quiet because its return or store is visible in the source. Write strict arena to make the block a claim the compiler verifies and rejects if false (§3). When a buffer genuinely cannot be presized — you are reading input whose length you learn as you go — System.resize (§4.1) grows one without abandoning the old copy on every step. It is rung 3 with the presizing removed, not a new rung, and it carries a contract: the array you passed in is dead afterwards.
  5. The default heaps (what you get with no arena block) — only for things that genuinely live as long as the process: precomputed tables, the scratch buffers themselves, long-lived indexes and caches. "The global arena" is the usual shorthand, and since #3528 it is simply accurate — every default allocation is arena memory. It was once three domains, only one of which is that arena — see §2.1 below.

Real numbers from a database project built on SuperJ, all of which were one innocuous-looking new in a loop: a materialized column per query — 22.8 GB peak RSS; a sorted table copy per call — 1.3 GB per call; a path string per column per partition — unbounded growth with query count. Every one was fixed by moving one rung up the ladder.

2.1 The default is one domain

Rung 5 is usually called "the global arena", and that is a useful simplification until you try to measure it. Outside any arena {} block, which allocator you get depends on what you allocate:

what you writeallocatorfreed?seen by System.memReservedBytes()
new Obj(...)sj_galloc -> the global arenaneveryes, but only when a new chunk is reserved
new T[n]sj_galloc → the global arenaneveryes, but only when a new chunk is reserved
a String body ("a" + n, substring, …)sj_str_alloc -> the global arenaneveryes, but only when a new chunk is reserved

Measured from emitted IR in all three compile modes (standalone, prebuilt-archive, whole-program source) — the split is the same in each.

All three are equally permanent, so the ladder's advice does not change. All three are equally observable — every default allocation is arena memory:

So a flat counter is still not proof that nothing is leaking — just a much better signal than it was. Build with --mem-track when you are chasing growth (§6) for per-site attribution.

Tuning the global arena's initial size. The global arena starts at 2 MB and doubles on each expansion up to a 1 GB cap. For a process that already knows it needs multiple GB, the ramp's startup doublings are noise: set the SJ_ARENA_INITIAL environment variable to start at the process's real budget.

SJ_ARENA_INITIAL=128m  ./myapp    # 128 MiB initial chunk
SJ_ARENA_INITIAL=1g    ./myapp    # 1 GiB (the cap; larger clamps to it)
SJ_ARENA_INITIAL=67108864 ./myapp # 64 MiB, no suffix

An allocation at or above 1 MiB is given a chunk of its own rather than a slice of the current one, which keeps a single outsized request out of the chunk-growth ramp. Without that, 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 moves that floor:

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.

The value is bytes, with an optional k/m/g suffix (case-insensitive) and an optional trailing b. It is read once at first use of the global arena; a bad value falls back to the default. This is not a pre-fault knob: chunks are never pre-touched, so a larger initial chunk removes the startup ramp but does not eliminate page-fault spikes mid-request. Pre-faulting is a separate option (not yet shipped) with a different cost (startup time + committed memory).

Bounding the live set: SJ_ARENA_MAX. There is 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 runaway service. Waiting for malloc to fail does not help on Linux, where overcommit hands back a pointer the kernel cannot honour.

SJ_ARENA_MAX is a hard ceiling on live arena bytes — the same number System.memReservedBytes() reports and System.memCeilingBytes() reads back. It is on by default: a ceiling nobody sets protects nobody, and 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.

./myapp                       # ceiling = 80% of the process's memory budget
SJ_ARENA_MAX=512m ./myapp     # fail at 512 MiB instead
SJ_ARENA_MAX=0    ./myapp     # no ceiling — run to the host's limit
valueceiling
unset80% of the process's memory budget
512m512 MiB
0none (the explicit opt-out)
unparseable (8zz)the default — a typo must not disarm the protection
budget undetectablenone

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. Both cgroup generations are read. The 20% headroom is for everything that is not the arena — the malloc domain (String bodies, maps), the stack, the binary's own pages.

Same size syntax as SJ_ARENA_INITIAL (bytes, optional k/m/g, optional trailing b), read once at first use.

On a breach the process prints its books to stderr and exits 12 (ENOMEM, so a supervisor can tell it from a plain failure). The same summary prints when 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)

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 here exactly when its active chunk is full), so on a single-arena process the gap reads 0.

The summary says how much, not where. Build with --mem-debug and the per-type and per-site reports print here too, ordered so the growing line is the top row (§6):

== allocation sites (debug build, SJ_MEM_DEBUG) — out of memory ==
  site                     global_bytes   glob_cnt  ...
  Grow.sj:7                     3316470     195740  ...

That is the whole point of a ceiling below the host's: you get a summary and a stack of counters instead of a dmesg line.

Asking before you die. System.memCeilingBytes() returns the effective ceiling (0 when there is none), so a service can shed load rather than discover the limit by hitting it:

long ceiling = System.memCeilingBytes();
if (ceiling > 0 && System.memReservedBytes() > ceiling / 10 * 9) {
    // 90% of the way there — stop accepting work
}

Every runtime variable is listed in runtime-environment.md.

Build-time knobs (when compiling the runtime from source): the initial size (-DSJ_ARENA_GLOBAL_INITIAL=…) and the chunk cap (-DSJ_ARENA_CHUNK_CAP=$((128*1024*1024))) are both #ifndef-guarded, so a source build can set either without forking the runtime. The SJ_ARENA_INITIAL env var overrides the initial size at runtime for a deployed binary.

Inside an arena {} block all three converge on that block's arena — objects, arrays and strings alike — and all of it is reclaimed on drop. That is why block allocations move the counter and default ones do not.

3. Leveraging arenas

An arena block gives a batch of allocations a shared, scoped lifetime:

int result;
arena temp {
    Buffer buf = new Buffer(1 << 20);   // allocates from temp
    Index idx  = new Index(buf);        // so does this
    result = idx.lookup(key);           // primitives escape freely
}   // temp dropped here — O(1), all chunks freed at once, no scan, no pause

What makes it the checked tool:

``java strict arena tmp { Buffer b = new Buffer(1024); // dies here — fine cache.put(k, new Entry(r)); // error[E_ARENA_ESCAPE] } ``

And it closes the other half of the same gap: the CARVE-OUT. An escape is a value that enters the block and leaves; a carve-out is a value that never enters it. A call the compiler cannot prove keeps nothing it allocates is disarmed, so what the callee allocates is placed globally — the block reclaims the receiver and not the object the callee hung off it:

```java static void fill(Holder h) { h.item = new Item(); } // unprovable callee

arena blk { Holder h = new Holder(); fill(h); use(h.item); } // ^ reclaimed ^ NOT reclaimed, and silent ```

W_ARENA_NO_SAVINGS cannot see this — the block did scope something, so its all-or-nothing condition is false — and the new Item() is not an escape from the block, because it was never in it. Under strict it is reported as W_ARENA_CARVE_OUT, naming the call and the block. That is the channel for "my block reclaims less than it looks like", and it is opt-in for the same reason the rest of strict is.

It is the claim layer applied to placement — noalloc claims a method allocates nothing, strict arena claims a block's values die in it — so it is opt-in for the same reason: region inference promotes whatever it cannot prove dies in the block, so a strict block can reject code that would in fact have been safe. Opting in is how you accept that trade for a block you care about. strict is contextual, so a variable named strict still compiles.

Two limits worth knowing before you switch it on. A store through a chained receiver (head.next.next = new Cell(3)) is rejected even though the one-level form is fine, because the store side resolves the receiver as a name. And superj check verifies the claim too, so you see it in the fast loop rather than at build. The verdict is the same whichever SDK you build against — every edition ships the SDK's retention summaries, so passing a block-local value into the SDK is judged identically by --sdk-path and --sdk-source.

```java static Pt makePt(int v) { Pt p = new Pt(); p.x = v + 1; return p; }

for (int i = 0; i < n; i++) { Pt r = makePt(i); s += r.x; } // 16 B/iter, forever arena blk { for (int i = 0; i < n; i++) { Pt r = makePt(i); s += r.x; } // 0 — dropped with blk } ```

By default there is no frame-lifetime region for the first form to fall back on: a garbage-collected language stack-allocates it, and here arena is the mechanism that answers the question. Publishing the result out of the block promotes it, so the block cannot reclaim something still reachable. Rung 1 still beats both — makeX(i) returning the int allocates nothing at all.

Frame regions (#5024) give a method that drops a callee's fresh result an implicit region for its whole frame, as if its body were written inside an arena block: the first loop above then reads 0 with no source change, a 2,000-deep recursion that allocates one object per frame reads 0, and a published or returned value is promoted out exactly as it is from a block you wrote. It differs from a written block in one way, and the difference matters (#5109): an allocation the analysis has proved dies in the frame is still promoted to the stack under an implicit region, where inside a block you wrote it belongs to the block. Putting things in a block is what the block is for, so writing one is an instruction; an implicit region is not one, and it must not cost you an alloca you would have had without it. Between #5096 and #5109 it did, and the loop above — the paragraph in §1 promising new in a loop moves memTouchedBytes() by 0 — read 16 B an iteration instead. "Drops" is read off the source before the analysis runs (#5090): the result is discarded, read through once, or bound to a local that is never returned, stored or passed on. A body that keeps every fresh result it gets is not armed, because a region there reclaims nothing and still costs an arm and a drop per call — arming on the weaker test ("calls something that allocates") cost +21 % wall time on the compiler's own self-compile for −6.3 % peak RSS, against +0.5 % for −2.4 % with the rule above.

The shape a per-request server is actually written in — a parser that builds its node graph by calling a factory — is armed too (#5093). That parser hands its own fresh allocations to a callee that keeps them — in the object it hands back, which the caller then drops. The summary says so (a parameter retained only into the returned value carries the same P column it carries when it is a constructor argument), so the caller keeps allocsLocal and the whole graph is reclaimed. benchmarks/bench_frame_regions.sj runs that loop both ways — nodes built inline and nodes built through a factory — and both read 1,040 B/request under --no-frame-regions and 0 by default, peak reserved 534 MB → 2 MB. That measurement is why this is on by default as of #5096; --no-frame-regions is the escape hatch, and a compiler built with it reproduces the default generation byte for byte.

What it costs, because it is not free. A frame region holds every iteration's allocation until the frame returns, so a hot allocating loop inside a long-lived frame holds more in-frame than the global-arena path did: on a dropped iterator, 12,288 B over 200 rounds where the old path reserved 0, and 16.77 MB against 12.58 MB over 200,000. Nothing leaks — measured across a call boundary the region is zero net, twice — but a memReservedBytes() reading taken inside such a frame is measuring the region rather than a leak, which is what to write instead if you are asserting on it.

The smallest case of this is a fixed 256 B, and it looks like a leak when it is not (#5163). A method that arms a region gets it from its entry, so a constructor's internal buffer — new StringBuilder() in that method — lands in the region, and the region's first chunk is 256 B (#5098's floor for synthesized regions). A before/after pair inside that method then reads +256 once, however many iterations run between them. It is gone when the method returns: 1000 calls measure 0 B reserved and 0 B touched across them. Under --no-frame-regions the same buffer goes to the global arena, and stays there: 80 B per call, forever. memReservedBytes() reads 0 for that, because the global arena's pre-reserved chunk absorbs it. To assert that a loop allocates nothing, put the loop in a method and measure across the call (examples/test_5163_frame_region_first_chunk.sj).

Bounding it per iteration rather than per frame needs a loop-body region, which does not exist yet. Declining to arm loop-resident drops is not the answer and was measured: it costs the entire win above, because the armed frames in that benchmark are the ones holding the request loop.

A method that arms a region itself never gets an implicit one (#5097), whether it does so with Region.enter() / Region.leave() or with arena (r) { }. You have already said where those allocations belong, and an implicit region wrapped around the outside disagreed: what you allocated in that window stopped being reclaimed by r.close() — measured at 208 bytes per connection becoming 16,608 in a probe, and 225,808 in the SDK's own TLS client path. Declining costs that one frame its region and nothing else.

It is still a flag rather than the default, and that is a decision about blast radius rather than a missing fact: turn it on for a program whose profile is the factory shape and measure with System.memTouchedBytes() or --mem-hist as for any other placement question.

Use an arena block when the intermediates are genuinely a batch with one lifetime — a parse, a request, a query plan. For a buffer that every call needs, don't re-create it per block; move it up to a scratch (§4).

4. Scratch buffers: the reuse cookbook

The workhorse of flat memory. Allocate once (global arena — rung 5 paying for rung 3), grow on demand, never shrink, reuse forever:

static double[] scratch = null;

static double[] ensureScratch(int n) {
    if (scratch == null || scratch.length < n) scratch = new double[n];
    return scratch;
}

The patterns that matter in practice:

File reads into a caller-owned buffer. Reading a whole file into a fresh byte[] per call leaks the file size per call. Size one scratch to the largest file in the batch, then reuse it for every read:

long maxLen = 0;
for (int i = 0; i < paths.length; i++) {
    long sz = new File(paths[i]).length();
    if (sz > maxLen) maxLen = sz;
}
byte[] scratch = new byte[(int) maxLen];
for (int i = 0; i < paths.length; i++) {
    columns[i] = openColumn(paths[i], scratch);   // fills, never allocates
}

The explicit-count rule. A scratch buffer is only guaranteed to be >= n — it usually carries a previous call's data past the current length. Any method handed a possibly-oversized buffer must also take an explicit count, and use it, never buf.length. Using buf.length where you mean "row count" silently reads (or writes!) the previous caller's data — this class of bug has shipped a corrupt database.

Reused objects get reset(), not new. A builder or parser that runs per request keeps its internal buffers and exposes reset(); constructing a fresh one per request rebuilds capacity from zero and leaks the old one.

Hoist the allocation, not just the array. "Hoist the buffer out of the loop" is readable as "move the array declaration above the loop", and that alone buys nothing:

B[] scratch = new B[4];                  // hoisted...
for (int i = 0; i < n; i++) {
    scratch[i & 3] = new B();            // ...but still one allocation per iteration
}

This leaks an object per iteration and keeps four. It is also the one shape that cannot be stack-placed, and for a reason worth understanding: up to four of those objects are alive at once and each outlives the iteration that made it, so there is no single stack slot to reuse. Move the array inside the loop and the same code is free — one object live at a time, one slot, reused — which is why the un-hoisted version measures better than the hoisted one. Fill the objects instead:

B[] scratch = new B[4];
for (int k = 0; k < 4; k++) scratch[k] = new B();   // once
for (int i = 0; i < n; i++) {
    scratch[i & 3].set(...);             // fills, never allocates -- 0 B/iter
}

If the per-iteration allocation is genuinely necessary, put the loop in an arena {} block (§3): the cost is then bounded by the block and reclaimed at its exit, instead of being kept for the life of the process.

One scratch per purpose, sized by the maximum. Don't pool many small buffers; keep one that has seen the largest input. Memory stays bounded by the biggest item ever processed, not by the number processed — which is exactly the flatness you want.

Size working sets to the cache, not to the data. When streaming, a small reused window (tens–hundreds of KB) is both the constant-memory answer and the fast one — the produce and consume passes hit the same cache-resident bytes. Measure the window size; don't guess it.

Don't write a static scratch inside a hot loop on x86. A static scratch that is read per iteration (a lookup table, precomputed coefficients) is exactly right. A static scratch that is written per iteration and then fed to a SIMD intrinsic is an aliasing barrier: the store to a fixed global address can't be proven not to alias the arrays you read, so the optimizer must genuinely store every lane to memory and reload it — a round trip scalar-replacement cannot eliminate. Measured on a 20M-row kernel: static final double[4] scratch → Simd.dotDouble4 cost 9× the identical loop with a stack local double[4], which the optimizer SROA-promotes to registers — 108 ms vs 12 ms on a Ryzen 9 9950X3D with --simd=avx2 (#3356). On ARM the static form is slower too, by 1.26× — the magnitude is what Apple Silicon hides, so developing on an M-series box gives no signal. The static pattern is the one this page recommends for avoiding per-call allocation — and for scratch that is filled once and reused, it is correct. It only bites when the scratch is filled and consumed every iteration as a staging buffer for a SIMD call, and only when the buffer is small enough to scalarise (#3357): a 4096-element static scratch measured 1.16×, i.e. noise. For the staging shape, prefer scalar accumulators (as fast as the local array, allocate nothing) or declare the staging array inside the method. Measure on the target architecture.

4.1 When you cannot presize: System.resize

Presizing is always better and this section does not change that. But some buffers are sized by input you have not read yet, and the fallback — allocate double, copy, drop the old one — is worse here than on a GC runtime: an arena has no unit smaller than a chunk to hand back, so every abandoned buffer is retained for the lifetime of the arena. Growing to N by doubling costs about 2N, permanently. Measured on a 32 KB buffer: 65,632 bytes handed out for 32,768 live.

System.resize grows the array instead of replacing it:

byte[] buf = new byte[1024];
int used = 0;
while (more()) {
    if (used == buf.length) {
        buf = System.resize(buf, buf.length * 2);   // `buf` above is DEAD
    }
    buf[used] = next();
    used = used + 1;
}

The same 32 KB buffer now costs 32,784 bytes — 1.00× — and a 16 MB one drops from 3.75× to 1.00×. It works by extending the allocation where it lies when it is the arena's most recent one, and otherwise moving it into a chunk of its own, after which every later resize just resizes that chunk. Size does not enter into it: a buffer that starts at 16 bytes benefits as much as a megabyte one.

The new elements are zeroed, exactly as new T[] would give you them. Growing from length 4 to 8 zeros indices 4–7; shrinking just discards the tail. This is a real contract, not an implementation detail you happen to observe — code is allowed to depend on arr[oldLen] == null after a grow without an explicit null-fill. (It was violated by a real bug, #4065: the migrate-into-its-own-chunk path could leave up to one trailing reference slot holding raw allocator memory instead of null, invisible on an allocator that happens to hand back fresh pages and a crash on one recycling a dirty block under real traffic. Fixed — mentioned here because the guarantee is the thing that was broken.)

Two things you must know before using it.

The argument is dead. Use the returned reference. It is undefined behaviour to touch the array you passed in — it may have been resized in place, or moved and the old storage freed, and which one happened is not something a program can observe. A surviving reference is dangling, not merely stale, so it may read back correct on your machine and be freed memory on another. This is the same class of documented privilege as local (SPEC §7.8): the compiler does not check it. Use it only where you own the array outright — a private field, or a local whose whole life you can see — and never on an array you have handed to anything else.

It works inside an arena {} block, and the block still reclaims. The array's lifetime is tied to the reference the call returns, so a buffer resized inside a block dies with the block: measured 0 bytes retained over 200 rounds of a 2 MiB resize. If the resized array escapes the block it is promoted along with it, so an escaped reference never points into reclaimed memory. (This was not true before #3850 — the array was promoted out of every block, 419 MB over those same 200 rounds — so older notes telling you to presize inside blocks were describing that defect.)

The SDK's growable containers (StringBuilder, ArrayList, IntArrayList, LongArrayList, DoubleArrayList, FloatArrayList) use it internally, so they get the 1.00× behaviour with no contract for you to honour. ByteArrayBuilder deliberately does not: its getBuffer() hands the backing array to callers, and a moving resize would invalidate their copy.

5. Stack allocation with local

local Point p = new Point(1, 2);     // alloca — freed on scope exit
local int[] buf = new int[4096];     // stack — size is a literal

local puts a small temporary on the stack: zero arena traffic, freed on scope exit. It needs an initializer and is not allowed on primitives (a plain int is already a register). Two caveats, both of which can silently cost you the entire benefit:

Keep them small — a local int[1 << 20] is 4 MB of stack frame. And know that at release optimization levels the compiler already scalar-replaces simple non-escaping objects: new Point(a, b) in a loop is often free whether or not you write local. It earns its keep on shapes the optimizer can't fold — measure before assuming it did anything.

6. Watching it: the memory-event log

The proof of good memory management is a flat peak RSS across iteration counts. SuperJ ships the instrument (full guide: Memory-Event Log):

# 1. Compile with the arena instrumentation armed
superj compile myapp.sj --sdk-path "$SJ_HOME/sdk" --link --mem-track --output myapp

# 2. Run with the log enabled (binary, mmap-backed, survives SIGKILL)
SJ_MEM_LOG=myapp.mlog ./myapp

# 3. Read it
superj memlog myapp.mlog

The log records arena create/expand/drop events with source locations and timestamps — so growth is attributable: you see which allocation site's arena keeps expanding, even in an OOM post-mortem. The default build pays zero cost; --mem-track without SJ_MEM_LOG is armed-off and near-free, so you can leave it in deploy builds and enable logging per run.

When the log has told you that memory is growing and you need what is in it, swap the flag for --mem-debug: it arms the same log plus a per-type live-bytes histogram printed to stderr at exit (and 0xDE poisoning of dropped arena memory, so a use-after-drop stops reading stale-but-intact objects). One flag, no extra build steps — it instruments every allocation, so it is a debug build, never a deploy one.

The acceptance test to adopt: run your workload at a low and a high iteration count and compare peak memory. Flat is the pass condition, not "small" — if peak grows with iterations, something in the loop still allocates, and the log will name it.

The one leak that needs no judgement: System.memAudit(label)

§1.1 says a leak is a statement about intent and no tool can decide it for you. That is true of memory you are still holding. It is not true of memory nothing points at any more: an object in the global arena that nothing reaches can never be used again and will never be freed, which is garbage under every definition — including the ones §1.1 rejects as too narrow.

That is the shape a bounded cache takes when its values are replaced rather than reused:

cache.put(key, new Value(i));   // the previous Value is now unreachable, and never freed

The live set is constant, so memReservedBytes() looks calm and the type histogram cannot tell the replaced values from the live ones. On a --mem-debug build, System.memAudit("label") traces the arena and names them:

== garbage audit — after 3000 puts ==
  site                garbage_bytes      count
  Audit.sj:18                 94400       2950     <- the replaced values
  Audit.sj:6                 212400       2950     <- their constructors' buffers
  reachable: 42534 bytes in 606 allocations

3,000 puts across 50 keys leaves 50 live and 2,950 unreachable. The fix is the reuse idiom of §4: hold one Value per key and reset() it.

It reports; it never reclaims. A collector would make this code survivable; naming it makes it fixable. And the trace is conservative — a word that merely looks like a heap address keeps its target alive — so the audit under-reports. Everything it names is genuinely unreachable.

Make it a gate: superj test --check-growth

A leak is a statement about intent, and §1.1 says intent is not machine-checkable — a cache and a leak are the same code. But a test states the intent by existing: running it again should cost nothing. So the runner can run each @Test twice and fail it if the second run retains any arena memory (#4737):

superj test --check-growth
test OrderBookTest.appliesUpdate ... ok
test SessionTest.handlesRequest ... FAILED
    retained 1024 bytes on a second run (#4737): this test's path keeps memory per
    invocation. Fix the retention, or exempt it with --allow-growth <substring> if
    holding it is the point.

The first run absorbs what is legitimately one-time — lazy static initialisation, a first-use cache, a builder reaching its steady size. Whatever the second run retains is memory that path keeps per invocation, which is the leak-with-a-rate this whole chapter is about.

It is opt-in: a plain superj test is unchanged, so a suite that holds a cache on purpose keeps passing. --allow-growth <substring> exempts matching tests when holding the memory is the point. The check uses memTouchedBytes() rather than memReservedBytes() because reserved moves only when a new chunk is taken, so a small per-call leak reads as zero until it happens to cross a chunk boundary.

7. The review checklist

Reading a diff (yours or anyone's), these questions catch nearly everything:

Then measure rather than assume: compare peak RSS at two iteration counts, and read the memlog when it isn't flat.

SuperJ — manual · generated from memory-management.md at pack time EN|中文