Garbage Is Bad — The Consequences Just Differ

Creating garbage is bad in every language. What differs is what happens next. In Java, the GC collects it — eventually, with a pause. In SuperJ, it accumulates — no pause, but a slow march toward OOM. Both need to be eliminated. The zero-garbage skills are not the same in the two languages.

Creating garbage is bad in every language. What differs is what happens next. In Java, the garbage collector will eventually reclaim it — but you don't control when, you don't control how long the pause lasts, and you don't control whether the freed bytes return to the OS. In SuperJ, there is no collector, so garbage accumulates — less performance impact per object (no sweep, no pause), but a slow march toward OOM over the life of a long-running process. Both are bad. Both need to be eliminated. The difference is that the skills for eliminating garbage are not the same in the two languages: patterns that are garbage-free in SuperJ (stack-allocated, arena-reclaimed) would create garbage in Java, and patterns that Java's GC handles silently (dropping a reference, clearing a cache) are leaks in SuperJ. This post walks each garbage-creating pattern, shows what Java does with it, shows what SuperJ does with it, and names the zero-garbage skill each language needs.

Every claim below is backed by a test that was inverted, run, and confirmed red before the post was written.

The shared premise: garbage is always bad

Let's start with the thing both languages agree on: creating short-lived objects in a hot loop is a bug, regardless of whether anything collects them. The Java high-performance community has known this for twenty years — the zero-GC Java shops (exchanges, trading systems, LMAX) write code where the steady state allocates nothing, not because the GC is slow but because any allocation that becomes garbage is work the runtime does for no reason. The GC collecting your garbage is better than it leaking, but neither is as good as never creating it.

So the question is never "does this leak or does the GC handle it?" The question is "does this create garbage at all, and if not, why not?" The answer is different in Java and SuperJ because the two languages have different zero-garbage skills — different mechanisms that make an allocation never become garbage in the first place. Get those skills wrong and you create garbage in a language where you didn't need to, or you leak in a language where you thought the GC would save you.

The zero-garbage skills, side by side

Zero-garbage skillJavaSuperJ
Stack-allocate a short-lived objectJIT escape analysis (runtime, best-effort, no keyword)local keyword + compile-time escape analysis (interprocedural, on by default)
Batch-reclaim a group of allocationstry-with-resources + a custom pool (manual)arena {} block — O(1) drop at the closing brace, on every exit path
Avoid per-element boxing in collectionsHand-specialized collections (fastutil, Eclipse Collections — third-party)Specialized collections are the standard SDK (Int2ObjMap, not HashMap<Integer, …>)
Reuse a single object across iterationsObject pool (manual, concurrency-safe if shared)ObjectPool<T> + Reusable (single-threaded, no sync) or reset() on the object
Let the runtime reclaim what you dropThe GC — eventually, with a pauseNothing. Dropped references on the global arena stay until process exit.

The last row is the one that inverts. In Java, the GC is the safety net — if you forget every other skill, the collector catches you, slowly, with a pause. In SuperJ, there is no safety net. The first four skills are not optional optimizations; they are the only thing between you and a leak. And the patterns that create garbage are not the same in the two languages — because the skills are not the same.

Let's walk each pattern.

Pattern 1 — the scoped scratch buffer

The workhorse of zero-allocation code: a method allocates a temporary buffer, does some work, returns a primitive result. The buffer should die with the call.

In Java: the new goes to the heap. If the JIT's escape analysis can prove the buffer doesn't escape the method, it may scalar-replace or stack-allocate it — but this is a runtime optimization, best-effort, with no keyword to force it, and it depends on the JIT's inlining decisions which you don't control. If the JIT can't prove it (the buffer is passed to a callee, stored in a field, returned), the buffer becomes garbage. The GC collects it on the next young-gen sweep — a sub-millisecond pause, but a pause, and the RSS grows by one buffer's worth until the sweep runs.

In SuperJ: the new inside an arena {} block goes to the arena, and the arena is dropped at the closing brace — on every exit path, including return, break, throw. This is a compile-time decision, not a runtime one. The buffer never becomes garbage; it is reclaimed in O(1) when the block ends.

// scopedArenaReclaimsOnReturn
static int makeBoundedScratch() {
    arena scratch {
        int[] a = new int[64];
        for (int i = 0; i < 64; i = i + 1) { a[i] = i; }
        return a[42];   // primitive copied by value — does not escape
    }
}

@Test
static void scopedArenaReclaimsOnReturn() {
    long base = System.memReservedBytes();
    int sum = 0;
    for (int i = 0; i < 100; i = i + 1) { sum = sum + makeBoundedScratch(); }
    long after = System.memReservedBytes();
    Asserts.assertTrue("primitiveReturnedNoLeak", after == base);
    Asserts.assertTrue("didWork", sum == 100 * 42);
}

System.memReservedBytes() is the always-on arena-chunk counter (#3108). The delta across 100 calls is exactly 0. The arena is dropped on every return, every time. This is the skill Java doesn't have: a lexical scope that guarantees reclamation, at compile time, with no runtime help.

The Java contrast: the same code in Java allocates 100 buffers on the heap. If the JIT escape analysis is aggressive enough, some of them are stack-allocated and never become garbage. If not — and "not" is the common case for buffers that cross a call boundary — all 100 become garbage, and the GC collects them in a young-gen pause. The Java engineer's zero-garbage skill here is to write the code so the JIT can prove non-escape (keep the buffer local, don't pass it to unknown callees). The SuperJ engineer's skill is to open an arena {} block. Different skills, same goal.

The same loop, in a loop

A close cousin: the arena block inside a loop, dropped every iteration. 1,000 iterations reserve the same as 1:

// scopedArenaInLoopReclaimsEachIteration
@Test
static void scopedArenaInLoopReclaimsEachIteration() {
    int n = 16;
    long base = System.memReservedBytes();
    int sum = 0;
    for (int k = 0; k < 1000; k = k + 1) {
        arena tmp {
            int[] b = new int[n];
            for (int i = 0; i < n; i = i + 1) { b[i] = i; sum = sum + b[i]; }
        }
    }
    long after = System.memReservedBytes();
    Asserts.assertTrue("loopReclaims", after == base);
    Asserts.assertTrue("didWork", sum == 1000 * (n * (n - 1) / 2));
}

In Java: 1,000 buffers on the heap, 1,000 garbage objects, one young-gen sweep. In SuperJ: 0 — the arena is dropped every iteration. This is the workhorse of zero-allocation request handling: open an arena per request, do all the request's work in it, drop it on the way out.

Pattern 2 — the iterator loop

The idiom: iterate a map with iterator() in a hot loop. This is where the skills diverge most sharply between the two languages.

Int2ObjMap contracts = new Int2ObjMap(64);
for (int i = 0; i < 32; i = i + 1) { contracts.put(i, "contract-" + i); }
for (int round = 0; round < 1000000; round = round + 1) {
    Int2ObjMapIterator it = contracts.iterator();
    while (it.hasNext()) { sum = sum + it.nextKey(); String v = (String) it.nextValue(); }
}

What Java does: garbage, collected, with a pause

Each iterator() call allocates a new iterator object on the heap. 1,000,000 iterations = 1,000,000 iterator objects. Every one is garbage the moment its loop ends. The GC will collect them in a young-gen sweep — sub-millisecond per sweep, but the sweeps happen on the GC's schedule, not yours, and they pause the thread. The garbage-creation tax itself (TLAB bumps, header writes, zeroing) is paid before the collector even runs.

The Java zero-garbage skill: the JIT may escape-analyze the iterator and stack-allocate it, if it can prove the iterator doesn't escape the loop frame. But iterator() is a method call returning a heap object; the JIT's escape analysis is intraprocedural and best-effort, and for a returned new it usually can't prove non-escape. So in practice, the 1M iterators become garbage, and the GC handles it, slowly, with pauses. The Java engineer's skill is to reuse a single iterator (if the API allows reset()) or avoid the iterator (index-based iteration).

What SuperJ does: garbage, NOT collected, a real leak

In SuperJ there is no GC safety net. Each iterator() call allocates the iterator via calloc(1, 32) — libc heap, not the arena, because the new is inside iterator()'s body where the arena pointer is null (the arena {} block is lexical, not interprocedural — it only captures new expressions textually inside the block, not inside a callee's body). SuperJ has no free. The 1,000,000 iterators accumulate as 32 MB of libc-heap allocations that nothing tracks and nothing reclaims.

Scaling to 1M iterations and measuring RSS:

RSS
baseline (iterator() per round) — 1,000,000 × calloc(1, 32)38.6 MB

Note that memReservedBytes (the arena counter) stays at 2 MB the whole time — because the counter is blind to libc calloc. It counts arena chunks only. When you suspect a leak in SuperJ, measure RSS (/usr/bin/time -l), not just memReservedBytes. The counter is honest about what it measures; the trap is assuming it measures everything.

The five zero-garbage skills for this pattern (all RSS-verified)

The leak is fixable. Five approaches, each verified by compiling, running 1M iterations, and measuring RSS. Every reduction is real, not theorised.

Skill 1 — reuse the iterator (reset()). Allocate one iterator, reset it each round. The zero-garbage skill here is the same in both languages: don't allocate per iteration. Requires a public reset() (added to Int2ObjMapIterator in this session).

Int2ObjMapIterator it = contracts.iterator();   // one calloc, once
for (int round = 0; round < 1000000; round = round + 1) {
    it.reset();
    while (it.hasNext()) { sum = sum + it.nextKey(); String v = (String) it.nextValue(); }
}

Skill 2 — stack-allocate with local + inline new. This is a skill SuperJ has and Java doesn't — not as a keyword. The new must be lexically in the caller's frame (not hidden inside iterator()) so escape analysis can see it. The IR shows alloca [32 x i8] — a stack slot, reused each iteration. Works with --sdk-path (archive) after fix #3510, which corrected a missing declare in the stack-promotion codegen path.

for (int round = 0; round < 1000000; round = round + 1) {
    local Int2ObjMapIterator it = new Int2ObjMapIterator(contracts);   // `new` is HERE
    while (it.hasNext()) { … }
}

Skill 3 — avoid the iterator; iterate by index. Zero per-round allocation. Same skill in both languages: if the allocation is the problem, don't allocate. Requires knowing the key domain or exposing a public forEach(BiConsumer) that iterates internally.

for (int k = 0; k < 1000; k = k + 1) {
    if (contracts.containsKey(k)) { sum = sum + k; String v = (String) contracts.get(k); }
}

Skill 4 — pool the iterator (ObjectPool<Iterator>). A fixed pool of reusable iterators; borrow and return each round. Needs implements Reusable on the iterator (added in this session). The same skill works in Java (with synchronization for thread safety).

ObjectPool<Int2ObjMapIterator> pool = new ObjectPool<Int2ObjMapIterator>(8, new IterCreator(contracts));
for (int round = 0; round < 1000000; round = round + 1) {
    Int2ObjMapIterator it = pool.use();
    while (it.hasNext()) { … }
    pool.offer(it);
}

Skill 5 — arena-reclaim with arena {} + inline new. Put the new lexically in the block so the arena captures it. The iterator goes to sj_arena_malloc and is reclaimed on block exit. Requires bypassing iterator() and calling the constructor directly. This is a skill SuperJ has and Java doesn't — Java has no arena {} block.

for (int round = 0; round < 1000000; round = round + 1) {
    arena scratch {
        Int2ObjMapIterator it = new Int2ObjMapIterator(contracts);   // `new` is HERE, in the block
        while (it.hasNext()) { … }
    }   // reclaimed here
}

The evidence table

All five, measured at 1M iterations over a 32-entry map:

#SolutionRSSvs baselineIR allocationCatches the leak?
baseline (iterator() per round)38.6 MB0calloc(1, 32) per iter(the leak)
1reuse (reset())6.3 MB−32 MBone calloc, reused
2local + inline new (--sdk-path)6.3 MB−32 MBalloca [32 x i8]
3index (containsKey/get)6.4 MB−32 MBzero per-round alloc
4ObjectPool<Iterator>6.3 MB−32 MBfixed pool, borrow/return
5arena {} + inline new6.3 MB−32 MBsj_arena_malloc, reclaimed on exit

All five reduce RSS from 38 MB to ~6.3 MB. The 6.3 MB is the SDK runtime's baseline; the 32 MB of leaked iterators is gone in every solution.

The skill comparison for this pattern

SkillJavaSuperJ
reuse (reset())works (if API allows it)works (added reset() to the iterator)
stack-allocateJIT may do it (best-effort, no keyword)local keyword forces it (compile-time, verified by IR)
avoid the iteratorindex-based loopindex-based loop (same skill)
poolThreadLocal + pool (needs sync if shared)ObjectPool<T> (single-threaded, no sync)
arena-reclaimnot available (no arena {})arena {} + inline new (SuperJ-only skill)

The last two rows are the skills that differ. Java has the GC as a fallback, so the iterator pattern "works" even if it creates garbage — you pay pauses. SuperJ has no fallback, so the same pattern leaks — you pay OOM. But SuperJ gives you two skills Java doesn't have (local and arena {}) that eliminate the garbage before it exists, if you know to use them.

Pattern 3 — the dynamic array growing on the heap

A list that grows by doubling its backing array. Each resize allocates a bigger buffer and copies. The old buffer is dead.

In Java: the old backing arrays become garbage. The GC collects them on the next sweep. The RSS grows by the final array's size plus whatever the GC hasn't swept yet, then settles. The cost is bounded by the final capacity, not by the number of operations — a list that grows to 200k and is read a million times costs 2× its final size, not 2× a million.

In SuperJ: the old backing arrays go to the global arena (or libc calloc), which never shrinks. They stay for process lifetime. But — and this is the key — the cost is still bounded by the final capacity, not by the number of operations. A second pass that doesn't add items does not grow the arena at all.

// growingArrayStaleBuffersAreBounded
@Test
static void growingArrayStaleBuffersAreBounded() {
    ArrayList warmup = new ArrayList();
    for (int i = 0; i < 50000; i = i + 1) { warmup.add("warm-" + i); }
    long base = System.memReservedBytes();

    ArrayList list = new ArrayList();
    for (int i = 0; i < 200000; i = i + 1) { list.add("item-" + i); }
    long afterOne = System.memReservedBytes();
    for (int i = 0; i < 200000; i = i + 1) { String s = (String) list.get(i); }
    long afterTwo = System.memReservedBytes();
    Asserts.assertTrue("growthOnFirstPass", afterOne > base);
    Asserts.assertTrue("steadyStateAfter", afterTwo == afterOne);
}

The first pass grows the arena (the backing array resizes through its doubling steps). The second pass, the same size, does not grow the arena at all. The stale buffers are still there, but the live set plateaus.

The skill comparison:

SkillJavaSuperJ
pre-size the arraynew ArrayList<>(expectedSize)new ArrayList(expectedCapacity) — same skill
accept the stale buffersGC collects them; RSS settlesarena keeps them; RSS plateaus (bounded)
scope the arraynot available (no arena {})arena {} block — the whole array reclaims on exit

In both languages, the garbage (the old backing arrays) is a real cost. In Java the GC reclaims it; in SuperJ it stays. But in both, the cost is bounded by the final size, not by the number of operations — so a dynamic array is "garbage" but not "a leak," in either language. The zero-garbage skill is the same: pre-size if you can; scope it if you can't.

Pattern 4 — the unbounded cache

A map that accumulates entries across calls and is never evicted. This is the one pattern that is a bug in both languages, for different reasons.

In Java: the entries are reachable (the map holds them), so the GC will not collect them. The heap grows without bound. cache.clear() makes them unreachable, and the GC reclaims them on the next sweep. The fix is a policy: LRU, TTL, bounded size. The GC is the mechanism that makes the policy work.

In SuperJ: the entries go to the global arena, which never shrinks. cache.clear() empties the map's logical contents, but the backing arrays stay in the arena forever. The bytes do not come back. The fix is the same policy (LRU, TTL, bounded size) — but the mechanism is different: you must scope the cache inside an arena {} block if you want the bytes back, or accept that the arena keeps the stale buffers.

// unboundedCacheGrowsWithoutBound
@Test
static void unboundedCacheGrowsWithoutBound() {
    long base = System.memReservedBytes();
    Int2ObjMap cache100k = new Int2ObjMap(64);
    for (int i = 0; i < 100000; i = i + 1) { cache100k.put(i, "v" + i); }
    long after100k = System.memReservedBytes();

    Int2ObjMap cache200k = new Int2ObjMap(64);
    for (int i = 0; i < 200000; i = i + 1) { cache200k.put(i, "w" + i); }
    long after200k = System.memReservedBytes();
    Asserts.assertTrue("cache100kGrew", after100k > base);
    Asserts.assertTrue("cache200kBigger", after200k > after100k);
}

A 200k-entry cache reserves more memory than a 100k-entry cache. That is the whole assertion, and it is the whole bug — in both languages. There is no mechanism in SuperJ that will reclaim either cache; both live on the global arena, dropped at process exit and not before. In Java, cache.clear() would let the GC reclaim the entries; in SuperJ, cache.clear() reclaims logical liveness but not bytes.

The skill comparison:

SkillJavaSuperJ
bounded eviction policyLRU/TTL — GC reclaims evicted entriesLRU/TTL — arena keeps stale buffers (bounded)
clear to free memorycache.clear() → GC reclaimscache.clear() → logical only, bytes stay
scope the cachenot available (no arena {})arena {} block — whole cache reclaims on exit

This is the pattern where the Java instinct ("I'll clear the cache to free memory") most dangerously inverts. In Java it works. In SuperJ it doesn't — the bytes were never going to come back. The only way to reclaim the bytes is to have allocated the cache inside an arena {} block in the first place. A long-lived cache on the global arena is a leak regardless of whether you clear it.

Pattern 5 — the retaining consumer

A consumer that stashes each value in a list. The cost is the list's growth, not the iteration's.

// retainingConsumerGrowthIsBoundedByListCapacity
@Test
static void retainingConsumerGrowthIsBoundedByListCapacity() {
    Int2ObjMap contracts = new Int2ObjMap(64);
    for (int i = 0; i < 32; i = i + 1) { contracts.put(i, "contract-" + i); }
    RetainingConsumer consumer = new RetainingConsumer();
    for (int round = 0; round < 10; round = round + 1) {
        Int2ObjMapIterator it = contracts.iterator();
        while (it.hasNext()) { it.nextKey(); consumer.accept((String) it.nextValue()); }
    }
    long base = System.memReservedBytes();
    for (int round = 0; round < 10; round = round + 1) {
        Int2ObjMapIterator it = contracts.iterator();
        while (it.hasNext()) { it.nextKey(); consumer.accept((String) it.nextValue()); }
    }
    long after = System.memReservedBytes();
    Asserts.assertTrue("retainedCount", consumer.kept.size() == 20 * 32);
    Asserts.assertTrue("boundedGrowth", after == base);
}

The first 10 rounds (320 inserts) grow the arena — the ArrayList's backing array resizes through its doubling steps. The second 10 rounds (another 320 inserts, same strings) grow it by zero. The backing array is already 320-capacity; the strings are references to objects already in the map.

In Java: the consumer's list grows on the heap. The old backing arrays become garbage, collected by the GC. The live set is bounded by the list's final capacity. In SuperJ: the same — the list grows on the arena, the stale buffers stay, but the live set plateaus.

The skill is the same in both languages: the consumer's retention policy is the memory manager. A consumer that retains without bound is Pattern 4 (the unbounded cache) in disguise. A consumer that retains a bounded list is Pattern 3 (the dynamic array) — bounded, not a leak.

Pattern 6 — the global arena, by design

The last pattern is not garbage and not a leak; it is the contract. Allocations outside any arena {} block go to the global arena, which lives for the process lifetime and never shrinks.

// globalArenaIsMonotone
@Test
static void globalArenaIsMonotone() {
    long b1 = System.memReservedBytes();
    int[] a = new int[256];
    a[0] = 1;
    long b2 = System.memReservedBytes();
    long b3 = System.memReservedBytes();
    Asserts.assertTrue("globalGrew", b2 >= b1);
    Asserts.assertTrue("globalMonotone", b3 >= b2);
}

This test exists to not assert reclamation. A new int[256] outside any arena {} block goes to the global arena and stays. When a goes out of scope, nothing happens — the reference is gone, the memory is still reserved.

In Java: the same new int[256] goes to the heap. When a goes out of scope, the array becomes garbage, and the GC collects it. The Java engineer's instinct is "the reference is gone, so the memory is freed." In SuperJ, that instinct is wrong — the reference is gone, the memory is not freed, and nothing will free it. The bytes stay until process exit.

The skill: scope everything that isn't long-lived. The global arena is for config, connection pools, interned strings — things that should live as long as the process. Everything else goes in an arena {} block. The arena is not smart; the scope is smart. The compiler emits a drop at the closing brace and on every early exit, and that drop is what turns "never shrinks" into "shrinks exactly when the scope ends."

The footgun: local, and the warning that guards it

There is one more skill, deliberately set apart because it is not a leak — it is undefined behaviour the compiler warns you about. local is a SuperJ keyword (SPEC §7.8) that forces an object or array onto the stack instead of the arena:

local Node n = new Node(3);     // stack-allocated
local int[] arr = new int[10];  // stack-allocated

It is a developer privilege: you're promising the compiler the reference will not outlive its declaring frame. If it does — returned, stored in a field, captured by a retaining call — reading it later is undefined behaviour, like returning a pointer to a C stack variable.

Because escape analysis is on by default (#3300), the compiler warns with W_LOCAL_ESCAPES when it cannot prove a local dies in its frame:

tmp/x.sj:5:26: warning[W_LOCAL_ESCAPES]: cannot prove the value bound to 'local m'
dies in this frame. 'local' puts it on the stack, so if the reference outlives the
frame — returned, stored in a field, or captured by a call that retains it —
reading it later is undefined behaviour (SPEC §7.8). Either drop 'local' and let
it be arena-allocated, or keep the value inside this frame.

In Java: there is no local keyword. The JIT may stack-allocate if it can prove non-escape, but you can't force it, and there's no warning if it can't — the object just goes to the heap and becomes garbage. In SuperJ: local forces the stack allocation, and the compiler tells you when it can't prove safety. The skill is to use local only where you've measured the arena cost and want it gone; the arena is the default because it's safe by construction.

The patterns, one more time

#PatternJavaSuperJZero-garbage skill
1Scoped scratch buffergarbage (GC collects, with pause)reclaimed by arena {} (0 garbage)arena {} block — SuperJ-only
2Iterator loopgarbage (GC collects, with pause)leak (calloc, no free)reuse, local, index, pool, or arena {} + inline new
3Dynamic array growinggarbage (GC collects old buffers)stale buffers stay (bounded by final size)pre-size or scope — same skill
4Unbounded cacheleak (reachable, GC can't help)leak (arena keeps everything)bounded eviction policy — same skill
5Retaining consumergarbage (list grows, GC collects old buffers)stale buffers stay (bounded by list capacity)bounded list — same skill
6Global-scope allocationgarbage (GC collects on drop)stays forever (by design)scope it in arena {} — SuperJ-only
local that escapesn/a (no keyword)undefined behaviour, warneddrop local or prove non-escape

The rule, ported from Java

If you are coming from Java, the single mental shift is this: creating garbage is bad in both languages, but the consequences differ, and so do the skills. In Java, garbage is collected — eventually, with a pause you don't control. In SuperJ, garbage accumulates — no pause, but a slow march toward OOM. Both need to be eliminated. The difference is that the zero-garbage skills are not the same.

What's the same:

  • Don't allocate per iteration — reuse or avoid (Pattern 2, Skills 1 and 3).
  • Don't build unbounded caches — use an eviction policy (Pattern 4).
  • Pre-size dynamic arrays when you know the size (Pattern 3).

What's different — SuperJ gives you skills Java doesn't have:

  • arena {} — batch-reclaim a group of allocations in O(1), at compile time, on every exit path. The workhorse of zero-allocation request handling.
  • local — force stack allocation with a keyword, verified by compile-time escape analysis.
  • Specialized collections as the standard (Int2ObjMap, not HashMap<Integer, …>) — no boxing, because boxing is not an option.

What's different — Java gives you a skill SuperJ doesn't have:

  • The GC as a safety net. If you forget every other skill, the collector catches you — slowly, with a pause, and it reclaims the bytes. In SuperJ, forgetting a skill means a leak. There is no catch. The first four skills are not optional optimizations; they are the only thing between you and OOM.

What inverts dangerously:

  • "The GC will handle it" → it will not. The compiler already decided where the allocation goes. Decide the scope at allocation time.
  • "I'll clear the cache to free memory" → clearing reclaims logical liveness, not bytes. The arena keeps the backing arrays. Scope the cache in arena {} if you want the bytes back.
  • "This loop allocates too much, I should pool" → for short-lived work, you usually don't need a pool: arena {} or local eliminates the garbage before it exists. A pool is for long-lived objects on the global arena.
  • "I'll box the int, the JIT will elide it" → SuperJ has no autoboxing and no JIT. 42 where an Object is expected is a compile error (E_BOXED_PRIMITIVE). Use the specialized collections; they exist because boxing is not an option.

The two points that make SuperJ worth it

Strip everything above to its essence and SuperJ's memory story rests on two claims.

Point 1: SuperJ makes a large class of trivial use cases zero-GC by default — and that solves more than 80% of the problem.

Look back at the patterns. Pattern 1 (scoped scratch buffer) is zero-garbage in SuperJ with arena {} and garbage in Java unless the JIT happens to escape-analyze it. Pattern 6 (scoped arena in a loop) is zero-garbage in SuperJ and 1,000 garbage objects in Java. Pattern 2's solutions 2 and 5 (local stack-alloc and arena {} + inline new) are zero-garbage in SuperJ and have no Java equivalent. The specialized collections (Int2ObjMap instead of HashMap<Integer, …>) are zero-boxing in SuperJ and require third-party libraries (fastutil, Eclipse Collections) in Java.

These are not edge cases. They are the workhorse patterns of every server, parser, and request handler: allocate a scratch buffer, iterate a collection, process a request in a scope, accumulate a result. In Java each of these creates garbage — small garbage, young-gen garbage, "the GC handles it" garbage — but garbage nonetheless, and the high-performance Java shops spend enormous engineering effort eliminating exactly these patterns by hand. In SuperJ they are zero-GC by construction, with no effort beyond using the language's standard features: arena {} for scoped work, local for hot paths, specialized collections for primitive keys.

The 80% number is not a measurement; it is a structural observation. The majority of allocations in a typical program are short-lived, scoped, and non-escaping — exactly the class arena {} and local handle for free. The remaining 20% (unbounded caches, cross-request state, long-lived structures that grow without bound) need a policy in both languages. SuperJ solves the 80% by default; Java requires you to engineer around the GC for the same result.

Point 2: SuperJ's deterministic memory behavior makes garbage creation traceable — Java's GC hides the problem.

In Java, a hot loop that creates 1,000,000 iterator objects per second runs fine in a benchmark. The GC collects them, the young generation turns over, the pauses are sub-millisecond, and the profiler says everything is healthy. The garbage is invisible. You discover the problem in production, six months later, when the load doubles, the old generation fills, a full GC fires during trading hours, and the pause is no longer sub-millisecond. The GC didn't fix the bug; it deferred the symptom until the environment made it expensive.

In SuperJ, the same loop leaks 32 MB into libc heap, the RSS climbs, and you see it the first time you run the program. The leak is deterministic — same input, same RSS growth, same memReservedBytes delta (when you measure the right counter). There is no "the GC will handle it" to hide behind. The garbage is visible immediately, in development, at the scale you're testing at — not deferred to production at a scale you can't test at.

This is the deeper point: the GC is a diagnostic blind spot. A program that creates garbage and relies on the GC to collect it is a program whose memory behavior you cannot observe directly — you can only observe the collector's behavior (pause times, heap usage, allocation rate), which is a proxy for the program's behavior, mediated by a runtime you don't control. A program that creates garbage in SuperJ has no such mediation: the garbage is in the RSS, in the arena counter, in the memory-event log, and it grows monotonically until you fix the code. The determinism is not just about performance (no pauses); it is about observability — you can see the garbage, measure it, and trace it to the line that created it, because nothing is cleaning it up behind your back.

The memReservedBytes counter and the memory-event log (superj memlog) exist precisely for this. They give you a timeline of every arena allocation, attributed to the file:line of the arena {} block that created it. A growing arena is a leak you can point at; a flat arena is reclamation you can prove. Java has no equivalent — the closest is a heap histogram after a heap dump, which is a snapshot, not a timeline, and which requires you to catch the problem before the OOM kills the process.

The trade, stated as two points:

  1. SuperJ makes the trivial cases zero-GC by default (arena {}, local, specialized collections) — the 80% of allocations that are short-lived and scoped, which Java requires manual engineering to eliminate.
  2. SuperJ's determinism makes the non-trivial cases traceable — the 20% that are real leaks show up as RSS growth in development, not as a GC pause in production. The GC in Java doesn't fix garbage-creating code; it hides it until the environment makes it expensive. SuperJ makes it visible so you can fix it before it ships.

Both want the same thing — zero garbage in the hot path. SuperJ gets you there for the 80% by default, and makes the remaining 20% visible enough to fix. Java requires you to engineer the 80% by hand, and hides the 20% behind a collector that makes the symptom someone else's problem — the production team's, at 3am, during a full GC.

The full memory-event-log recipe is in manual/memlog.md.

← all posts Try SuperJ →