Java Primitives vs. Wrappers: Bits, Objects, and the Traps in Between

Java has two kinds of types, and the JVM thinks of them as different animals. A primitive — int, long, doubleis the value: a fixed number of bits, compared with == by value, no identity, no null. A wrapper — Integer, Long, Double — is a reference to a heap object: it has an identity, it can be null, and it costs extra memory to exist.

The compiler papers over the difference so well that int x = 5; and Integer x = 5; look interchangeable. They are not. The confusion between the two worlds is a classic source of subtle Java bugs: == quietly changing meaning, a null detonating far from where it was inserted, and memory multiplying several times over. This post walks the four layers in order — what the eight primitives actually are, where each kind of value lives, how auto-boxing and unboxing work, and what boxing costs — with a runnable program for each, all on OpenJDK 21.

The eight primitives and their shadows

Unlike C, where int may be 16 or 32 bits, Java’s primitive sizes are pinned by the language itself: the JLS §4.2 and §4.3 specify 8 bits for byte, 16 for short and char, 32 for int and float, 64 for long and double; boolean is only specified as 8 bits per array element (JLS §10.1). Every primitive has a wrapper object counterpart, and the program below prints the pairing while also showing two quirks of the numeric types: char is a number in disguise, and byte arithmetic is silently promoted to int (binary numeric promotion, JLS §5.6).

public class Main {
    record Row(String primitive, String wrapper, String bitsPerJLS) {}

    public static void main(String[] args) {
        Row[] table = {
            new Row("boolean", "Boolean", "8 bits per array element (JLS 10.1)"),
            new Row("byte",    "Byte",    "8   (JLS 4.2.7)"),
            new Row("short",   "Short",   "16  (JLS 4.2.8)"),
            new Row("char",    "Character", "16  (JLS 4.2.11)"),
            new Row("int",     "Integer", "32  (JLS 4.2.9)"),
            new Row("long",    "Long",    "64  (JLS 4.2.10)"),
            new Row("float",   "Float",   "32  (JLS 4.3.3)"),
            new Row("double",  "Double",  "64  (JLS 4.3.4)"),
        };
        System.out.printf("%-8s %-10s %s%n", "primitive", "wrapper", "size");
        for (Row r : table)
            System.out.printf("%-8s %-10s %s%n", r.primitive(), r.wrapper(), r.bitsPerJLS());

        System.out.println();
        // char is a *numeric* type: 'A' participates in arithmetic
        char c = 'A';
        System.out.println("char 'A' + 1          = " + (c + 1) + "  (int 66)");
        System.out.println("(char)('A' + 1)       = " + (char) (c + 1));

        // byte + byte is computed as int (binary numeric promotion, JLS 5.6)
        byte b1 = 100, b2 = 100;
        int promoted = b1 + b2;
        System.out.println("byte 100 + byte 100   = " + promoted + " (type int, overflows a byte)");

        // primitives compare by value with ==
        int a = 42, b = 42;
        System.out.println("42 == 42 (primitives) = " + (a == b));
    }
}
primitive wrapper    size
boolean  Boolean    8 bits per array element (JLS 10.1)
byte     Byte       8   (JLS 4.2.7)
short    Short      16  (JLS 4.2.8)
char     Character  16  (JLS 4.2.11)
int      Integer    32  (JLS 4.2.9)
long     Long       64  (JLS 4.2.10)
float    Float      32  (JLS 4.3.3)
double   Double     64  (JLS 4.3.4)

char 'A' + 1          = 66  (int 66)
(char)('A' + 1)       = B
byte 100 + byte 100   = 200 (type int, overflows a byte)
42 == 42 (primitives) = true

Two things worth pausing on. char 'A' + 1 yields the int 66, not 'B'char participates in integer arithmetic, and if you want the letter back you must cast. And byte 100 + byte 100 produces 200 of type int, which is why byte b = 100; b = b + 1; fails to compile: the addition is already wider than byte, so the assignment needs an explicit cast. These are small things, but both are consequences of the rule that Java doesn’t narrow a result into a smaller type without an explicit cast.

Where a value lives: bits versus objects

Now the distinction that matters. Primitives in local variables are just bits in the method frame’s local variable array; a wrapper variable holds a reference to an object on the heap. The method-call demo below looks innocent — two methods that both “add 10” — but they do it to fundamentally different things.

public class Main {
    // int is passed by value: the method gets a copy of the bits
    static void bump(int x)   { x += 10; }
    // Integer is a reference: "x += 10" unboxes, adds, reboxes —
    // the caller's reference is never touched
    static void bumpBox(Integer x) { x += 10; }

    public static void main(String[] args) {
        int p = 100;
        bump(p);
        System.out.println("int p after bump(p):        " + p);

        Integer b = 100;
        bumpBox(b);
        System.out.println("Integer b after bumpBox(b): " + b);

        System.out.println();
        // == on wrappers compares object IDENTITY, not value
        Integer i127a = Integer.valueOf(127);
        Integer i127b = Integer.valueOf(127);
        Integer i128a = Integer.valueOf(128);
        Integer i128b = Integer.valueOf(128);

        System.out.println("valueOf(127) == valueOf(127): " + (i127a == i127b)
                + "   sameObject: " + (i127a == i127b)
                + "   idA=0x" + Integer.toHexString(System.identityHashCode(i127a))
                + " idB=0x" + Integer.toHexString(System.identityHashCode(i127b)));

        System.out.println("valueOf(128) == valueOf(128): " + (i128a == i128b)
                + "   sameObject: " + (i128a == i128b)
                + "   idA=0x" + Integer.toHexString(System.identityHashCode(i128a))
                + " idB=0x" + Integer.toHexString(System.identityHashCode(i128b)));

        System.out.println("valueOf(128).equals(valueOf(128)): "
                + i128a.equals(i128b));
    }
}
int p after bump(p):        100
Integer b after bumpBox(b): 100

valueOf(127) == valueOf(127): true   sameObject: true   idA=0x12a3a380 idB=0x12a3a380
valueOf(128) == valueOf(128): false   sameObject: false   idA=0x5a07e868 idB=0x76ed5528
valueOf(128).equals(valueOf(128)): true

Both caller variables are still 100 after the call, for different reasons. bump mutated a copy of the bits; the caller’s value was never in scope of that write. bumpBox is subtler: x += 10 is sugar for x = x + 10, which unboxes x, adds, and reboxes — creating a brand-new object and pointing the local x at it. The caller’s reference b is untouched, still at the original object. In both cases the caller’s variable survives unchanged; with int it’s because the method worked on a copy, with Integer because the method never could reach through the reference.

The lower half of the output is the trap people actually hit. == on Integer compares identity, not value — and the reason 127 appears to work with == is an implementation detail you should not build on. JLS §5.1.7 specifies that Integer.valueOf returns a cached object for values in a range that is a subset of −128…127, and a new object otherwise. The run confirms the boundary: the two 127 values are literally the same object (matching identity hash codes), while the two 128 values are distinct objects with different hash codes — even though equals says they’re the same number. If == returned true for 127, that was the cache, not semantics. The reliable rule: == for primitives, equals for wrappers.

When unboxing meets null

Auto-boxing and unboxing are compiler-inserted conversions — JLS §5.1.7 and §5.1.8 — so you can write int i = someInteger; as if the object were the value. The conversion is Integer.intValue() under the hood, and intValue() on null is a NullPointerException. What makes this dangerous is that the compiler does the insertion everywhere it type-checks, so the unboxing can hide in places where you’re thinking about values, not references.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;

public class Main {
    public static void main(String[] args) {
        // 1) unboxing null in arithmetic
        Integer count = null;
        try {
            int total = count + 1;   // unboxing happens here
        } catch (NullPointerException e) {
            System.out.println("1) null + 1           -> NPE");
        }

        // 2) == with a wrapper and a literal unboxes the wrapper:
        //    this throws instead of evaluating to false
        Integer flag = null;
        try {
            boolean hit = (flag == 0);
        } catch (NullPointerException e) {
            System.out.println("2) null == 0          -> NPE (not false)");
        }

        // 3) a null hidden inside a generic collection explodes on unbox
        List<Integer> values = new ArrayList<>(Arrays.asList(1, 2, 3, null));
        try {
            int sum = values.stream().mapToInt(Integer::intValue).sum();
        } catch (NullPointerException e) {
            System.out.println("3) sum of [1,2,3,null] -> NPE at the null element");
        }
        // the fix: filter out nulls before unboxing
        int safe = values.stream().filter(Objects::nonNull)
                         .mapToInt(Integer::intValue).sum();
        System.out.println("   filtered sum       = " + safe);

        // 4) normal unboxing in the common case
        Integer boxed = 42;
        int unboxed = boxed;      // compiler inserts Integer.intValue()
        System.out.println("4) 42 autoboxed then unboxed + 1 = " + (unboxed + 1));
    }
}
1) null + 1           -> NPE
2) null == 0          -> NPE (not false)
3) sum of [1,2,3,null] -> NPE at the null element
   filtered sum       = 6
4) 42 autoboxed then unboxed + 1 = 43

Case 2 is the one that bites: flag == 0 is not a reference comparison returning false. Because one operand is a literal, the compiler unboxes flag first — and unboxing null is a NullPointerException, per JLS §5.1.8. A test that was supposed to be false instead crashed the caller.

Case 3 is the same mechanic wearing a disguise. null is a perfectly legal element of a List<Integer> — the list stores references, and references can be null. Nothing happens when the null is inserted; it detonates the moment someone unboxes it, here inside the stream’s mapToInt. So the stack trace points at the consumer, while the root cause sits wherever the null entered the collection. (Incidentally, List.of(1, 2, 3, null) would have rejected the null at construction time — that’s a useful place to want your nulls to die.) The practical fix is what the program shows: decide your null policy before values cross into primitive territory, filtering or defaulting on the way out of the generic world.

The mental model for this section: a null in a wrapper world is not a bug yet — it’s a load-bearing decision. Every unboxing site is a place where that decision is enforced, and the enforcement is an exception, not a 0 or a false.

What boxing actually costs

“Boxing is slow” is folklore worth checking. The real question has two parts: what does holding boxed values cost in memory, and what does touching them cost per iteration? This program measures both for 10 million values, using the platform ThreadMXBean’s allocated-bytes counter so the memory numbers are measured, not guessed.

import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;

public class Main {
    static final ThreadMXBean MX =
        ManagementFactory.getPlatformMXBean(com.sun.management.ThreadMXBean.class);

    static long allocated() {
        return MX.getCurrentThreadAllocatedBytes();
    }

    public static void main(String[] args) {
        final int N = 10_000_000;

        // how much memory does holding 10M values take?
        long before = allocated();
        int[] raw = new int[N];
        for (int i = 0; i < N; i++)
            raw[i] = (int) ((i * 2654435761L) & 0x7FFFFFFF);
        long rawAlloc = allocated() - before;

        before = allocated();
        Integer[] boxed = new Integer[N];
        for (int i = 0; i < N; i++)
            boxed[i] = raw[i];        // each value becomes a separate object
        long boxAlloc = allocated() - before;

        System.out.printf("int[10M]     held with: %12d bytes%n", rawAlloc);
        System.out.printf("Integer[10M] held with: %12d bytes  (%.1fx)%n",
                          boxAlloc, (double) boxAlloc / rawAlloc);

        // warmup so both loops run JIT-compiled
        long s = 0; for (int v : raw) s += v;
        s = 0;     for (Integer v : boxed) s += v;

        long a0 = allocated();
        long t0 = System.nanoTime();
        long sumA = 0;
        for (int v : raw) sumA += v;
        long aTime = System.nanoTime() - t0;
        long aAlloc = allocated() - a0;

        a0 = allocated();
        t0 = System.nanoTime();
        long sumB = 0;
        for (Integer v : boxed) sumB += v;   // reads each object, reads its int
        long bTime = System.nanoTime() - t0;
        long bAlloc = allocated() - a0;

        System.out.println("sum (primitive): " + sumA);
        System.out.println("sum (boxed):     " + sumB);
        System.out.printf("primitive loop: %7.2f ms, %8d bytes allocated in loop%n",
                          aTime / 1e6, aAlloc);
        System.out.printf("boxed loop:     %7.2f ms, %8d bytes allocated in loop%n",
                          bTime / 1e6, bAlloc);
    }
}
int[10M]     held with:     40000144 bytes
Integer[10M] held with:    200000168 bytes  (5.0x)
sum (primitive): 10737420510288064
sum (boxed):     10737420510288064
primitive loop:    8.42 ms,        0 bytes allocated in loop
boxed loop:        8.79 ms,        0 bytes allocated in loop

Read these two halves differently, because they contradict the “boxing is slow” cliché in an instructive way. The memory number is exactly where you’d expect it: 40,000,144 bytes for int[10M] versus 200,000,168 for the boxed array — a 5.0× difference. That delta works out to roughly 16 bytes per Integer object on top of the 4-byte reference in each array slot, consistent with each object carrying its own header in addition to the value. Ten million values stored as objects is a couple hundred megabytes of heap and ten million allocations for the garbage collector to eventually reclaim; ten million primitives are a single flat 40-megabyte array. This is the cost that does not go away: it’s the price of the object being real.

The speed number is the surprising part. After a warmup pass, the two sum loops run within a few tenths of a millisecond of each other (~8–9 ms each across two runs) and — the key measurement — zero bytes were allocated by either loop. The JIT can read the single int field straight out of each Integer without allocating anything new, so the per-iteration “unboxing tax” in a hot, uniform loop is nearly invisible on this machine and this JDK. That’s not a guarantee the cost is invisible everywhere — it’s a specific observation about a tight loop the compiler can optimize, and the exact ms figures will shift run to run. Where the allocation cost does show up is in the cases the optimizer can’t flatten: building the boxed collection itself (the 160 MB of object creation above), values flowing through generic APIs and streams where the type is only Object, and any path that creates a fresh wrapper per element.

So the honest summary from this run: prefer primitives in numeric hot paths and for storage of large numeric collections — the 5× memory and the allocation churn are real and measurable — but don’t believe that a boxed loop is automatically several times slower; the compiler will often hide the per-iteration cost for you, as long as the objects were created before the loop rather than inside it.

Takeaway

One sentence each: a primitive is the value — bits, compared with ==, no null; a wrapper is a box around the value — a heap object with an identity, nullable, and costing roughly an object header plus the value in memory. Everything else follows that line: == means value equality on one side and reference identity on the other; null is legal to store and fatal to unbox; and the compiler’s auto-boxing is a convenience that silently changes which of the two worlds your value is in. When a NullPointerException appears in code touching Integer, Long, or Double, start by finding the unboxing site — the box is being opened on something that was never in it.