Zero GC Was Always the Goal
Memory management vs C++/Rust/Java — and why the fastest Java in production barely uses the collector
There is a dirty secret at the top of the Java performance world: the fastest Java in production barely uses the garbage collector at all. The exchanges, the trading systems, the low-latency shops — they write Java where the steady state allocates nothing. Object pools, preallocated ring buffers, flyweights over ByteBuffers, primitive arrays everywhere, -Xmx tuned so the young generation never fills during trading hours. The GC is not their memory manager; it is a liability they engineer around. Java's whole promise — "don't think about memory" — is exactly the promise its fastest users cannot afford to accept.
SuperJ takes that observation to its conclusion. If zero-GC is where high-performance Java ends up anyway, make it the starting point — and make it easy.
The four ways to manage memory
Every systems language is an answer to one question: who proves that memory is used correctly?
C++ trusts you. Lifetimes are yours to reason about; RAII helps, smart pointers help more, and yet use-after-free and double-free remain the two most reliably shipped bugs in the industry's most performance-critical codebases. The performance ceiling is unmatched. So is the blast radius of being wrong, because being wrong is silent — the pointer still points somewhere.
Rust makes you prove it. The borrow checker turns lifetime reasoning into a type system, and one entire class of bug becomes impossible. The price is paid per line, forever: lifetime annotations, borrow-graph puzzles, reference-counted escape hatches when the checker can't see what you can. It is the right trade when a single memory bug is catastrophic — browsers, kernels, parsers for hostile input. For a server that parses its own wire format behind a load balancer, it is a lot of proof for a threat model you mostly don't have.
Java defers it. The collector will figure it out at runtime. And it does — brilliantly, for the software Java was designed for. But deferral has a cost curve: allocation is cheap until the collection isn't, and the tail latency lives exactly where a high-performance system can't hide it. Which is why fast Java converges on the paradox above: a garbage-collected language, written so that there is no garbage.
SuperJ decides it by structure. There is no collector. new allocates from an arena; the default arena lives as long as the process; a scoped arena {} block gives a batch of allocations one shared lifetime, dropped in O(1) at the closing brace — and the compiler rejects, at compile time, any reference that would escape the block. Small fixed-size temporaries go on the stack with local. Nothing is traced, nothing is scanned, nothing pauses.
The claim is not that this is safer than Rust or more powerful than C++. The claim is that it is simpler than all three for the software it targets — and that simplicity is a performance feature, because the memory strategy you can actually see is the one you can actually optimize.
One thread changes everything
Here is the piece that makes the whole design click, and it's the piece that looks most like a limitation on the brochure: SuperJ is strictly single-threaded. You scale with processes, not threads.
For memory management, that is not a constraint. It is a license.
Consider the humble scratch buffer — the workhorse of every zero-allocation codebase in every language:
static double[] scratch = null;
static double[] ensureScratch(int n) {
if (scratch == null || scratch.length < n) scratch = new double[n];
return scratch;
}
In multithreaded Java, this innocent pattern is a bug. So you reach for ThreadLocal (per-thread copies, per-access lookup cost), or a lock (contention), or an object pool with a concurrent free list (a small science project), or you make everything immutable and allocate anyway (hello again, GC). An enormous fraction of the complexity in high-performance Java is not the algorithm — it is the machinery for sharing mutable scratch state safely across threads.
Single-threaded, the pattern is just… correct. One buffer per purpose per process. Every call site shares it. No locks, no atomics, no thread-locals, no defensive copies. A table writer in a database built on SuperJ pushes an 83-column table through one int buffer, one long buffer, and one double buffer — because column c is fully written to disk before column c+1 overwrites the scratch. The invariant that makes it sound is an ordering contract, not a lock: "consume before the next call." You write that sentence in a comment at the buffer's definition, and it is enforced by the only thread there is.
That is the design's center of gravity: maximize reuse, because reuse is finally safe. The rest is technique.
The allocation ladder
The discipline fits in five rungs, tried in order:
- Don't allocate. Return a primitive. Fill a caller-owned array. Precompute the string once.
local— put a small, fixed-size temporary on the stack. Freed at scope exit, costs nothing.- Reuse a scratch buffer — for anything sized by the data. Allocate once, grow on demand, never shrink.
- An
arena {}block — for a batch of intermediates with one lifetime. O(1) drop, escape-checked by the compiler. - The global arena — for what genuinely lives forever: tables, caches, the scratch buffers themselves.
Notice what's absent: a free, a destructor, a lifetime annotation, a pool implementation. The ladder is a set of decisions, not mechanisms — and each decision is visible in the source. When a reviewer (human or otherwise) reads new double[n] inside a per-row loop, no global analysis is needed to know it's wrong. That's rung 1 through 3 territory, and the fix is mechanical.
The numbers, because this isn't theory
A columnar database was built on SuperJ this month, by an AI agent, hitting each of these lessons in sequence. The before/after ledger:
| The innocent line | Cost |
|---|---|
| A materialized column per query | 22.8 GB peak RSS |
| A sorted copy of a table per write call | 1.3 GB per call |
A path String concatenated per column per partition | unbounded growth per query |
| A "stack" buffer whose size came from a parameter | 520 MB where 6.4 MB was expected |
That last one deserves its own paragraph, because it is the sharpest edge in the language today: local only stack-allocates when the size is a compile-time constant. Write local int[] buf = new int[4096] and you get a stack buffer; write local int[] buf = new int[n] and the local is silently ignored — an ordinary heap allocation, no warning, correct results, and a leak that scales with your loop count. Measured: the same 20,000-iteration loop peaks at 6.4 MB with a literal and 520 MB with a parameter. Since real buffers are usually sized from data, the useful case is the broken one — which is exactly why rung 3 of the ladder exists, and why the documentation now says so in bold.
Every one of those failures was fixed by moving one rung up the ladder. The result, in each case, was not "less memory." It was flat memory — a process whose peak RSS is identical at 3 iterations and at 200. Flat is the goal. Flat means the thousandth query costs what the first one did. Flat is also the pass condition in review: if peak RSS grows with iteration count, something in the loop still allocates, full stop.
Debugging without a heap profiler
"No GC" traditionally also means "no tooling" — C++ engineers live in Valgrind and sanitizers precisely because the language gives them nothing. SuperJ ships the instrument in the box:
superj compile app.sj --sdk-path "$SJ_HOME/sdk" --link --mem-track --output app
SJ_MEM_LOG=app.mlog ./app
superj memlog app.mlog
The memory-event log records every arena create, expand, and drop — with the source location that caused it and a timestamp, written through an mmap'd binary log that survives a SIGKILL. When a process grows, the log names the allocation site. When a process OOMs, the log is the post-mortem. The default build pays zero cost; an instrumented build with logging disarmed pays approximately one boolean per event, so it can ship to production and be switched on per run.
The debugging loop this enables is almost embarrassingly simple: run the workload at two iteration counts, compare peak RSS, and if it isn't flat, read the log — it points at the line. Compare that to interpreting a GC log ("is this growth live or just floating?") or to a week in a heap dump. Deterministic allocation makes memory bugs reproducible, and reproducible bugs are the kind that stay fixed.
Why this matters for agents, not just people
There is a second audience for whom this design turns out to be nearly optimal, and it wasn't in the original sales pitch: AI agents writing systems code.
An agent working in C++ must carry a global invariant in its head — who owns this pointer? — across every edit, and the failure mode for getting it wrong is silent corruption three functions away. An agent working in Rust must satisfy a prover whose error messages assume a mental model that has to be rebuilt from scratch in every context window. An agent working in Java hits a wall of implicitness: the allocation is fine, the collector will handle it — until the p99 says otherwise, and nothing in the source text points at why.
SuperJ's memory model is legible in the source text. Every rule that matters is local and mechanical:
- A
newinside a loop is a flag, visible in a diff. - An escape from an
arenablock is a compile error, not a code-review catch. - The scratch-buffer contract is a comment at the definition site.
- The pass condition is a number: flat RSS across iteration counts.
- The failure attribution is automated: the memlog names the site.
Those five properties make the discipline checkable by an agent in a loop — write, run at two iteration counts, diff peak RSS, read the log, fix the named line. No global reasoning, no prover dialogue, no tribal knowledge. The database project above was built precisely this way: the agent generated the memory failures in the table, and the same agent, given the ladder and the log, fixed every one of them. The lessons are now written down in the manual — for the next human, and for the next agent, who start where this one finished.
The bet, restated
High-performance Java already proved that the JVM's best trick is optional: its fastest users write allocation-free code by hand, against the grain of the language, with tooling designed for a different world. SuperJ's bet is that the grain should run the other way — that for servers, pipelines, databases, and network systems:
- zero-GC should be the default idiom, not an expert countermeasure;
- one thread per process is the simplification that makes aggressive reuse safe rather than heroic;
- arenas plus a ladder of habits cover what destructors, borrow checkers, and collectors were each invented to cover — at a fraction of the cognitive price;
- and determinism plus a built-in event log make the remaining bugs the boring, reproducible, fixable kind.
You give up the collector's convenience and the borrow checker's certificate. You get Java's ergonomics at C's steady-state memory profile, with a discipline simple enough to teach in a page — to a junior engineer or to a language model.
Flat is the goal. The rest is a ladder.