Essay
Lies Compound
The Honesty Principle, Generalized
A rule was stated — if it is an apple, don't call it an orange — and shown through one thirty-year case study of what happens when a language breaks it. Since then the rule has kept paying out, in places that have nothing to do with arrays, and a deeper structure has come into focus. It isn't just that a lie in a type system has a cost. It's that a lie is never done costing: every fiction requires a second fiction to stay standing, the second requires a third, and each one ships with its own tax, its own failure mode, and its own surprise scheduled for someone three abstraction layers away. This post walks the pattern through Java's boxing and garbage collection — and then through the mirror-image mistake we made in SuperJ's own String, because a principle you've only ever applied to other people's code isn't a principle yet.
The shape of a lie
Every case study in this post follows the same four-step shape, so let's name it once:
- The relabel. Something is declared to be what it is not, because the fiction is convenient today.
- The patch. The fiction doesn't actually hold, so a mechanism is added to paper over the place where it tears.
- The tax. The patch isn't free. Everyone pays it, forever, including the programs that never needed the fiction.
- The seam. Years later, a new feature arrives that was designed honestly — and it cannot compose with the old lie. The language now has two models of the same thing and a permanent scar between them.
Java's covariant arrays walked these exact stairs: arrays are objects → covariance → an ArrayStoreException check on every reference-array store → generics that refuse to mix with arrays at all. What wasn't fully appreciated then is how faithfully the same staircase appears everywhere else a language decides that comfort beats candor — including, as you'll see, in our own house.
Case study: a primitive is not an object
An int is a 32-bit two's-complement value that lives in a register. It has no identity — there is no "this 5 as opposed to that 5" — no header, no dispatch table, no lifetime. An object is precisely the opposite. This is not a matter of taste; it's the same category distinction as arrays-vs-objects, and Java's history with it is even more instructive, because Java originally told the truth.
Java 1.0 kept primitives out of the object hierarchy, on purpose, for performance. The seam was honest and visible: you couldn't put an int in a Vector, and everyone knew it. The lie arrived in Java 5 under the name autoboxing: the compiler would silently relabel int as Integer and back, wherever convenient. One keyword's worth of comfort. Now watch the staircase:
The patch. Boxing allocates, and allocating an object for every small integer is absurd — so the JLS mandates a cache: Integer.valueOf must return the same object for values −128 to 127. A second fiction ("small integers have stable identity") props up the first ("integers are objects").
The tax, in three flavors. The cache means == on boxed integers works for 127 and silently fails for 128 — a semantics cliff positioned exactly where testing won't find it, since test data is small and production data isn't. Unboxing means null — a value no int can hold — flows into primitive positions: int x = map.get(k) compiles cheerfully and throws NullPointerException at runtime when the key is missing, an error the honest 1.0 language made unrepresentable. And the allocation cost never left; it just went invisible: Bloch's Effective Java keeps a two-character bug in print — a loop counter typed Long instead of long — that quietly creates two billion objects and makes a one-second loop take an order of magnitude longer. The fix is deleting one letter. The lie is what made one letter cost that much.
The seam. Generics erase to Object, so List<int> is illegal and every generic collection of numbers boxes per element — which is why the Java ecosystem grew an entire shadow world of apologies: IntStream beside Stream<Integer>, specialized IntFunction/ToLongBiFunction interfaces by the dozen, and third-party libraries (fastutil, Trove, Eclipse Collections) whose whole reason to exist is hand-specialized primitive collections — the honest types, maintained outside the language because the language committed to the fiction. Project Valhalla has now spent a decade carefully unwinding it.
SuperJ's answer is the one Java 1.0 already knew, held this time with a straight spine: a primitive is not a reference, anywhere, with no exceptions to memorize. Passing 42 where an Object is expected is a compile error, not a phantom allocation. x.equals(42) doesn't box; it doesn't compile. And the consequence Java outsourced to fastutil is simply owned: the SDK ships the honest specialized types — Int2IntMap, Long2ObjMap, IntArrayList — as the standard collections, not as a third-party confession. If you need a boxed integer you can construct one, visibly, and be held accountable for it at code review. Nothing is hidden because nothing is being covered for.
Case study: a memory bug is not a nuisance to be outrun
This one is subtler, because the fiction is wrapped around a genuine achievement. Tracing GC really does solve a real problem — it is never safe to free this while someone still points at it — and it solves it soundly. The lie isn't the collector. The lie is the marketing that came with it: "memory lifetime is no longer your concern."
But lifetime was never a bookkeeping chore that automation could disappear; it's a design property of your program — who owns this, and when does it die — and a question doesn't stop existing because you've stopped answering it. Route the question to a collector and it doesn't get answered; it gets deferred, and the staircase begins.
The patch(es). A forgotten listener or a static cache keeps an object reachable forever — the collector, honest to its contract, retains your bug indefinitely. GC didn't eliminate the memory leak; it re-implemented it as "unintended retention," undiagnosable at the line that caused it, diagnosable only by heap-dump archaeology weeks later. So the platform grew three species of ghost pointer — WeakReference, SoftReference, PhantomReference — whose entire purpose is to whisper lifetime facts back to a collector you were told didn't need them. Then came finalize(), the promise that cleanup could ride along with collection; it proved so unpredictable it was deprecated-for-removal, replaced by Cleaner and, tellingly, by try-with-resources — which is manual, lexical lifetime management, reinvented inside the GC language, because files and sockets could never actually tolerate the fiction. Read that seam closely: for every resource other than memory, Java quietly admits the honest model — explicit ownership, scoped release — and maintains the lie only where the failure is soft enough to hide.
The tax. Every object carries collector metadata; every pointer store in a modern concurrent collector pays a write barrier (the same shape as the ArrayStoreException toll — a per-operation fee to keep a fiction upright); and somewhere between two innocent statements, the world stops. An entire profession — GC tuning — exists to negotiate with the machinery that was supposed to mean you'd never think about memory again. You think about memory constantly. You've just been stripped of the vocabulary to say anything about it.
SuperJ's answer is to keep lifetime in the source, in the type system's line of sight. Every allocation belongs to an arena. The default is the global arena — program lifetime, safe to store and return, honestly labeled as such. For bounded work you open a lexical arena { } block: allocation inside is a pointer bump, and when the block closes everything in it dies in one O(1) drop — no marking, no sweeping, no pause, and above all no ambiguity about when. The block is the lifetime; you can read it.
And here is the part that makes it a principle rather than a preference: the compiler rejects the lie in the other direction, too. Store or return an arena-allocated object past its arena's close and you get a compile error at that line — escape analysis treats "this outlives its declared lifetime" exactly like a type error, because it is one: the value's label (dies with the block) and its use (lives beyond it) disagree. The bug that GC converts into a leak found in a heap dump three weeks out is instead a red squiggle at the exact statement that wrote the contradiction. Meanwhile the SDK refuses even small cosmetic fictions here — there is no destroy() convention, no Destroyable interface, because under arenas a reference-nulling "cleanup" method frees nothing. Things that genuinely own external resources get an explicit, honest close() or free(). Nothing else gets to pantomime one.
Case study: the lie we told ourselves — a String is an object
If the principle only ever indicted Java, you'd be right to suspect it was a prejudice wearing a lab coat. So here is the case where the defendant is us — and the lie runs in the opposite direction. Java's mistakes relabel non-objects as objects. We took a genuine object and pretended it wasn't one.
The relabel. SuperJ's String began life headerless: just a char* into a length-prefixed buffer. The systems instinct behind it is easy to reconstruct — a string is "just bytes," the bare pointer is zero-copy into C, and skipping the object header saves a word. All true. All beside the point. Because a String in a Java-family language lives a thoroughly polymorphic life: it's a map key behind erased generics, it flows through Object-typed slots, its equals/hashCode get called through dynamic dispatch, it answers instanceof. Identity, dispatch, a type tag — a String uses every service an object header exists to provide. It is an apple. We labeled it "raw buffer."
The patch. The fiction tore at every erased boundary, so the compiler grew a boxing shim: whenever a String crossed into Object-land, codegen synthesized a {vtable, type_id, data} box around the bare pointer, on the spot. Then the shim needed shims. Erased dispatch grew special String arms — a type-id test with a dedicated "is it secretly a string?" branch — because a boxed String's methods weren't reachable the normal way. Erased returns consumed as Strings needed the reverse: an unwrap back to the naked pointer. Foreign C strings needed their own re-boxing path. Each patch was small, local, and perfectly reasonable — which is exactly how a staircase feels from one step away.
The tax. One calloc(1, 24) per map put and per get — every single time a String met a generic. On the tracked benchmark that was two hundred thousand heap allocations per timed round, roughly 30% of the cost of every String-keyed map operation, verified by reading the emitted IR. And no optimizer could save us: whole-program LTO stared straight at the box and couldn't delete it, because the box wasn't an inefficiency — it was the load-bearing patch. You cannot optimize away a lie's rent while the lie still stands.
The seam. The language ended up with two ABIs for one type — the raw-data-pointer path and the object path — and a golden test whose entire job is to pin that both paths agree on equals and hashCode, so they can't drift apart. When one type needs a treaty between its two selves, enforced by a regression suite, the design is telling you something.
The fix was the honest sentence, finally said: a String is { vtable, type_id, char* data } — a real object, built once at construction, with the data field still pointing at the same length-prefixed, NUL-terminated buffer, still handed to C unchanged. Zero-copy interop survived intact; the pretense is what died. The per-operation box vanished from the hot loop (a gate now greps the IR to keep it gone), the special-case arms became ordinary dispatch, and the benchmark dropped ~30% on the spot. The word-per-value we'd "saved" by lying had been costing us a heap allocation per use.
If everything is an object, being an object means nothing
There's an information-theoretic way to say all of this at once. A predicate carries information only if something can fail it. "Everything is an Object" is a claim with zero bits of content — it cannot distinguish, so it cannot inform, so nothing can be safely built on it. The moment instanceof Object is a tautology, the word object has been spent.
Worse than spent, actually — a universal label doesn't just say nothing, it hides everything. If arrays, integers, strings, lambdas, and locks are all "objects," then the label won't tell you which of them has identity, which can be dispatched on, which allocates, which can be null, which has a lifetime worth tracking. All the load-bearing distinctions are still there — they've just been pushed below the vocabulary, where you rediscover them one ArrayStoreException, one Integer cache cliff, one unboxing NPE at a time. The universal type is where category errors go to hide.
So SuperJ spends the word carefully, and — as the String episode proves the hard way — the care cuts in both directions:
- An array is not an object: no header, no vtable, no
type_id, not assignable toObject— because nothing in a reflection-free, GC-free language ever consumes an array's object-hood, and we won't charge every array a header for a service with no customers. - A primitive is not an object: no boxing, no identity, compile error at the boundary.
- A struct is not an object: it's a fixed layout whose bytes are the wire format — no vtable, no inheritance, nothing between you and the frame.
- A String is an object, and now says so: full header, real dispatch, first-class
Object— because it genuinely lives that life, and for as long as we pretended otherwise we paid rent on the pretense at every erased boundary.
Four kinds of value, four different answers, each answer earned by what the value actually does. That's what it takes for Object to be worth anything: it must be possible to not be one. In SuperJ, when a value is an Object, that's a fact you can build on — it has an identity, a type tag, a dispatch table, a polymorphic life. The claim has content because the claim can be false.
The economics, stated once
Look back at the four staircases side by side — three of Java's, one of ours:
| the relabel | the patch | the tax | the seam | |
|---|---|---|---|---|
| arrays (Java) | "an array is an Object" | covariance | store-check on every write + reified element types | arrays vs. generics, forever |
| primitives (Java) | "an int is an Integer" | the −128…127 cache | invisible allocation, == cliffs, unboxing NPEs | Stream<Integer> vs IntStream, fastutil, Valhalla |
| memory (Java) | "lifetime is not your concern" | weak/soft/phantom refs, finalizers → Cleaner | headers, write barriers, pauses, tuning | try-with-resources: manual lifetime, readmitted for everything but memory |
| String (SuperJ, mea culpa) | "a String is a raw buffer" | box-at-every-erased-boundary + special dispatch arms | a heap allocation per generic use, ~30% of map ops | two ABIs for one type, held together by a golden test |
Four domains, one arithmetic. The first lie is always cheap — that's what makes it tempting; the relabel costs nothing on the day it ships. The patches are where the interest accrues, the taxes are how it's collected, and the seam is the balloon payment: the honest design you can't have later because the dishonest one is squatting on its foundations. A lie is a loan against the type system, and the type system always collects. We know, because we've now been on both sides of the counter.
The honesty principle is just the refusal to take the loan: say what the thing is, at the first opportunity, at compile time, even when the truth is momentarily inconvenient — a port that won't compile, a boxing you have to write by hand, an ownership question you have to answer in the source, a header you have to pay for because the value really does live a polymorphic life. Those inconveniences are real, and they are the entire cost: paid once, up front, at the line that matters, by the person with the most context. The alternative defers the same cost — with interest — to whoever is standing nearest when the fiction finally tears.
Every removal in SuperJ that looks like austerity — no autoboxing, no GC, no reflection, no threads, no universal Object — is this trade made in one direction. The String header is the same trade made in the other. We are not collecting deletions, and we are not hoarding bytes. We are declining, one loan at a time, to borrow against the truth.