object o = 42; — in Java and C#, that one line is a heap allocation. The int stops being
4 bytes on the stack and becomes a heap object with a header, a pointer, and garbage
collector tracking. That conversion is boxing, and its reverse — reading the int back
out — is unboxing. Boxes are where "value types" and "reference types" stop being
language theory and start being memory layout, allocation, and GC pressure. Here's what the
machine actually does.
Value types vs references
A value type is stored inline, exactly where you declared it: int inside an array is
4 bytes inside the array's contiguous buffer. A reference is a pointer to a heap object
whose referent is elsewhere. Two consequences follow mechanically:
int[] a = {1, 2, 3}; // 12 bytes, contiguous: [1][2][3]
Integer[] b = {1, 2, 3}; // 24+ bytes: [ptr][ptr][ptr] -> 3 separate heap objectsThe array of references costs three heap objects (each with an object header — 8–16 bytes apiece, plus the int), indirection on every access, and scattered memory that thrashes the cache. The value array is one allocation and perfect locality. That's the entire performance story of boxing in one diagram.
Boxing: the implicit heap allocation
Boxing happens silently whenever a value type is assigned to a reference-typed location:
object o = 42; // box: allocate, copy the int into a heap object
int n = (int)o; // unbox: read it back, check the type firstThe boxed int is a real object: a header (type pointer, sync state) plus 4 payload bytes —
in .NET that's ~24 bytes on the heap for a value that was 4. The unbox performs a type
check (is this really an int?) before reading. Both directions are per-element work. In
.NET 1.x, ArrayList was the box-everything collection: list.Add(i) boxed every single
element. That's precisely the pathology generics were invented to kill.
Why List<int> is slower than int[]
This is where the two ecosystems diverge, and the distinction matters:
- C#:
List<int>does not box — the genericTis a real value type parameter, so the array inside isint[]with elements stored inline. Generics eliminated the allocation.List<int>is still slower thanint[], but for different reasons:Countis a property (a method call, inlinable but not free), indexing does bounds checks the JIT sometimes can't eliminate, and the JIT's array-specific optimizations (vectorization, bounds-check elimination) apply most aggressively to real arrays. The gap is a few percent, not 10x. - Java:
ArrayList<Integer>is a different story. Java generics are erased —Integercompiles down toObject, so the list is really anObject[]where every element is a boxedIntegeron the heap.
// Java: every add() and get() crosses the box boundary
ArrayList<Integer> xs = new ArrayList<>();
xs.add(i); // autobox: Integer.valueOf(i) -> heap allocation
int n = xs.get(i); // unbox: Integer.intValue() -> type check + readThat's an allocation, a type check, and a dereference per element in both directions — and
the elements are scattered across the heap. int[] is 4 bytes per element, contiguous,
zero allocations. This is why "just use a list" is 10x advice on a warm path in Java, and
why Java's future value types (Valhalla) are about exactly this: giving value semantics to
generic types.
Java erasure: why the box is mandatory
Java's erasure is the root cause: at the bytecode level, ArrayList<Integer> is
ArrayList<Object>. A primitive int cannot live in an Object[], so the language
inserts an autoboxing conversion at every add and an unboxing at every get. The source
looks clean; the machine sees allocations. There is no way to opt out — Integer[] and
int[] are different types with a 4–16x cost difference (size + allocation + cache
behavior). The Integer.valueOf cache covers only −128..127, so most values are genuinely
allocated.
C# struct semantics: when copies are the cost
C# structs are value types: assignment copies the whole struct, references point at the original. The costs are inverted from Java:
- A
structarray (Point[]) is contiguous — the same win asint[], no boxing. - Boxing still happens at
objectboundaries:object o = somePoint;boxes the whole struct, and calling an interface method on a struct boxes it too unless the struct is cast through the generic constraint. - Mutating a struct property through a reference (
list[0].X = 5) silently mutates a copy unless you useref— a correctness trap born directly from copy semantics. - Every struct argument and return value pays a full copy unless passed
ref/in.Span<T>andstackallocexist so value data can be manipulated without touching the heap at all.
int[] raw = new int[1_000_000]; // 4 MB, contiguous, zero boxing
List<int> list = new List<int>(raw); // still unboxed, a few percent slower
ArrayList boxed = new ArrayList(raw); // 1,000,000 heap allocations — avoidBoxing is the price of erasure in Java, a boundary tax in C#, and in both cases it turns a 4-byte value into a heap object with a header and a pointer. Value vs reference isn't semantics — it's where your bytes live, and how many objects the GC has to track.