The Runtime Theory
Runtime & Execution

Boxing, Unboxing, and Value vs Reference Semantics

Why every boxed int is a heap allocation, why int[] beats ArrayList and Integer[] by 4-16x, how Java generics erasure forces boxing, and how C# structs avoid it.

The Runtime Theory Team3 min read#boxing#value-types#generics#java#csharp#memory
On this page

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:

text
int[]  a = {1, 2, 3};      // 12 bytes, contiguous:  [1][2][3]
Integer[] b = {1, 2, 3};   // 24+ bytes:  [ptr][ptr][ptr] -> 3 separate heap objects

The 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:

csharp
object o = 42;                 // box: allocate, copy the int into a heap object
int n = (int)o;                // unbox: read it back, check the type first

The 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 generic T is a real value type parameter, so the array inside is int[] with elements stored inline. Generics eliminated the allocation. List<int> is still slower than int[], but for different reasons: Count is 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 erasedInteger compiles down to Object, so the list is really an Object[] where every element is a boxed Integer on the heap.
java
// 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 + read

That'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 struct array (Point[]) is contiguous — the same win as int[], no boxing.
  • Boxing still happens at object boundaries: 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 use ref — a correctness trap born directly from copy semantics.
  • Every struct argument and return value pays a full copy unless passed ref/in. Span<T> and stackalloc exist so value data can be manipulated without touching the heap at all.
csharp
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 — avoid

Boxing 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.