Autoboxing, Unboxing and the Integer Cache

Java keeps primitives (int, long, boolean…) separate from objects for performance reasons. But collections, generics, and many APIs require objects. Autoboxing is the compiler feature that bridges this gap automatically, converting an int to an Integer and back without explicit casts. Understanding the rules behind this conversion prevents subtle bugs, especially around equality comparisons and NullPointerExceptions.

What is Autoboxing / Unboxing?

Every Java primitive has a corresponding wrapper class in java.lang:

PrimitiveWrapperCache range
byteByteβˆ’128 to 127
shortShortβˆ’128 to 127
intIntegerβˆ’128 to 127 (upper bound configurable)
longLongβˆ’128 to 127
floatFloatnone
doubleDoublenone
charCharacter0 to 127
booleanBooleanTRUE and FALSE constants

Autoboxing is the automatic conversion of a primitive to its wrapper when the code requires an object. Unboxing is the reverse: converting a wrapper back to a primitive.

Java
// Without autoboxing (pre-Java 5)
Integer x = Integer.valueOf(42);   // Manual boxing
int     y = x.intValue();          // Manual unboxing

// With autoboxing (Java 5+)
Integer a = 42;   // Autoboxing  β€” compiler inserts Integer.valueOf(42)
int     b = a;    // Unboxing    β€” compiler inserts a.intValue()

// Autoboxing in a collection
List<Integer> numbers = new ArrayList<>();
numbers.add(1);      // int 1 autoboxed to Integer.valueOf(1)
numbers.add(2);
int sum = numbers.get(0) + numbers.get(1);  // Both unboxed to int before addition
System.out.println(sum);  // 3

Autoboxing also happens in method calls, variable assignments, conditional expressions, and arithmetic operations involving a mix of primitives and wrappers.

How it Works Under the Hood

Autoboxing is entirely a compiler transformation. The JVM has no notion of autoboxing; it sees only explicit method calls in the bytecode.

Java
// Source code
Integer i = 100;
int j = i + 50;

// What the compiler generates (conceptually)
Integer i = Integer.valueOf(100);
int j = i.intValue() + 50;

This means every autoboxing call may allocate a new heap object (outside the cache range), and every unboxing call invokes a virtual method. For most code this is negligible, but it becomes significant in hot loops (see the performance section).

Java
// Tracing all autobox/unbox events explicitly
public class AutoboxTrace {
    public static void main(String[] args) {

        // 1. Assignment boxing
        Integer a = 200;   // Integer.valueOf(200) β€” new object (outside cache)

        // 2. Method parameter boxing
        printDouble(5);    // int 5 autoboxed to Integer.valueOf(5)

        // 3. Arithmetic triggers unboxing
        Integer x = 10;
        Integer y = 20;
        int sum = x + y;   // x.intValue() + y.intValue()
        System.out.println(sum); // 30

        // 4. Compound assignment β€” both box AND unbox
        Integer count = 0;
        count++;           // Equivalent to: count = Integer.valueOf(count.intValue() + 1)
        System.out.println(count); // 1

        // 5. Ternary operator forces boxing if types differ
        boolean condition = true;
        Integer result = condition ? 42 : null;  // Integer (could be null)
        System.out.println(result);
    }

    static void printDouble(Integer n) {
        System.out.println(n * 2);
    }
}

Step 4 (the ++ on an Integer) is particularly surprising: it unboxes, increments, then re-boxes, creating a new Integer object for every increment. The reference is reassigned, not the object mutated (wrapper types are immutable).

The Integer Cache (βˆ’128 to 127)

Integer.valueOf(n) does not always allocate a new object. The JVM maintains a cache of Integer objects for values from βˆ’128 to 127 (inclusive). For any value in this range, valueOf returns the same cached instance every time.

Java
// Inside java.lang.Integer (simplified)
private static class IntegerCache {
    static final Integer[] cache;

    static {
        // Cache values -128 to 127 (high can be configured via JVM flag)
        int high = 127;
        cache = new Integer[(high - (-128)) + 1];
        int j = -128;
        for (int k = 0; k < cache.length; k++) {
            cache[k] = new Integer(j++);
        }
    }
}

public static Integer valueOf(int i) {
    if (i >= -128 && i <= Integer.IntegerCache.high) {
        return IntegerCache.cache[i + 128];
    }
    return new Integer(i);
}

The consequences of the cache are visible when comparing Integer objects with ==:

Java
public class CacheDemo {
    public static void main(String[] args) {

        // Values within the cache range β€” same object returned
        Integer a = 100;
        Integer b = 100;
        System.out.println(a == b);       // true  (cached β€” same reference)
        System.out.println(a.equals(b));  // true

        // Values outside the cache range β€” new object each time
        Integer c = 200;
        Integer d = 200;
        System.out.println(c == d);       // false (not cached β€” different references)
        System.out.println(c.equals(d));  // true  (compares values)

        // Boundary values
        Integer e = 127;
        Integer f = 127;
        System.out.println(e == f);  // true  β€” last cached value

        Integer g = 128;
        Integer h = 128;
        System.out.println(g == h);  // false β€” first uncached value

        Integer i = -128;
        Integer j = -128;
        System.out.println(i == j);  // true  β€” first cached value

        Integer k = -129;
        Integer l = -129;
        System.out.println(k == l);  // false β€” outside cache
    }
}

Note:

== vs equals Pitfall

This is one of the most common Java interview questions and a real source of production bugs. Always use .equals() to compare the values of wrapper objects. Use == only to test reference identity, which for wrappers is almost never what you want.

Java
public class EqualsPitfall {

    public static void main(String[] args) {

        // ─── Integer ───────────────────────────────────────────────────────
        Integer x = 1000;
        Integer y = 1000;

        System.out.println(x == y);       // false β€” different objects
        System.out.println(x.equals(y));  // true  β€” same value

        // This looks like it should always work β€” but it depends on the cache!
        Integer a = 50;
        Integer b = 50;
        System.out.println(a == b);  // true TODAY (cached), but never rely on this

        // ─── Long ──────────────────────────────────────────────────────────
        Long m = 200L;
        Long n = 200L;
        System.out.println(m == n);       // false
        System.out.println(m.equals(n));  // true

        // ─── Mixed type comparison ─────────────────────────────────────────
        Integer p = 100;
        Long    q = 100L;
        // p == q  β€” compiler error: incompatible types Integer and Long
        System.out.println(p.equals(q));         // false! Different types
        System.out.println(p.longValue() == q);  // true  β€” unbox both to long

        // ─── The safe pattern: always use equals for wrappers ─────────────
        Integer score1 = Integer.valueOf(args.length > 0 ? Integer.parseInt(args[0]) : 999);
        Integer score2 = 999;
        // Never do: if (score1 == score2) ...
        if (score1.equals(score2)) {
            System.out.println("Scores match");
        }

        // ─── Comparing nullable wrappers safely ───────────────────────────
        Integer nullable = null;
        // Objects.equals handles null safely
        System.out.println(java.util.Objects.equals(nullable, 42));  // false
        System.out.println(java.util.Objects.equals(nullable, null)); // true
    }
}
Never use == to compare Integer, Long, Double, or any other wrapper type values. The behaviour depends on the Integer cache and can silently change if values shift across the cache boundary (βˆ’128–127). Always use .equals() or, for nullable wrappers, Objects.equals().

The same trap exists for Boolean: there are only two cached instances (Boolean.TRUE and Boolean.FALSE), so == happens to work, but relying on it is still poor style and confusing to readers.

Java
// Common real-world bug: comparing Map return values with ==
Map<String, Integer> wordCount = new HashMap<>();
wordCount.put("hello", 1000);

Integer count = wordCount.get("hello");

// BUG β€” works for small numbers (cached), silently fails for large ones
if (count == 1000) {
    System.out.println("Found 1000 occurrences");  // Never printed!
}

// CORRECT
if (count != null && count.equals(1000)) {
    System.out.println("Found 1000 occurrences");  // Correctly printed
}

// Also correct β€” unbox explicitly
if (count != null && count == 1000) {  // Wait β€” this unboxes 'count' via ==!?
    // This actually WORKS because the primitive literal 1000 triggers unboxing
    // of 'count', making it a primitive comparison. But it risks NPE if count == null.
}

Performance Implications

Autoboxing is convenient but not free. Each boxing operation outside the cache range allocates a new object on the heap, increasing garbage collection pressure. Unboxing adds a virtual method call. These costs accumulate in hot loops.

Java
public class PerformanceDemo {

    static final int N = 10_000_000;

    // BAD β€” autoboxing on every iteration: N allocations + N unboxings
    static long sumWithBoxing() {
        Long total = 0L;
        for (long i = 0; i < N; i++) {
            total += i;  // long i autoboxed to Long, then unboxed for +=
        }
        return total;
    }

    // GOOD β€” all primitives, zero allocation
    static long sumWithPrimitives() {
        long total = 0L;
        for (long i = 0; i < N; i++) {
            total += i;
        }
        return total;
    }

    // ALSO BAD β€” Integer in the loop variable
    static int countMatches(List<Integer> list, int target) {
        int count = 0;
        for (Integer n : list) {         // unbox each element
            if (n == target) count++;    // == triggers unboxing of 'n'
        }
        return count;
    }

    // BETTER β€” use primitive stream or manual unboxing once
    static int countMatchesFast(List<Integer> list, int target) {
        return (int) list.stream()
                         .mapToInt(Integer::intValue)  // unbox stream
                         .filter(n -> n == target)
                         .count();
    }

    public static void main(String[] args) {
        long start, end;

        start = System.currentTimeMillis();
        long r1 = sumWithBoxing();
        end = System.currentTimeMillis();
        System.out.println("Boxing:    " + r1 + " in " + (end - start) + " ms");

        start = System.currentTimeMillis();
        long r2 = sumWithPrimitives();
        end = System.currentTimeMillis();
        System.out.println("Primitive: " + r2 + " in " + (end - start) + " ms");
        // The primitive version is typically 5–10x faster for large N
    }
}

Practical performance guidelines:

  • Use primitive types (int, long, double) in computational loops.
  • Prefer IntStream, LongStream, DoubleStream over Stream<Integer> for numeric work.
  • Use int[] instead of List<Integer> for large numeric arrays.
  • Avoid Long sum = 0L patterns: use long sum = 0L.
  • Profile before optimising: JIT can sometimes eliminate boxing in simple cases.

Null Unboxing and NullPointerException

Wrapper objects can be null; primitives cannot. Unboxing a null wrapper throws a NullPointerException: one of the most confusing NPEs because there is no explicit dereference in the source code.

Java
public class NullUnboxing {

    public static void main(String[] args) {

        // ─── Direct null unboxing ───────────────────────────────────────
        Integer boxed = null;
        int primitive = boxed;  // NPE β€” compiler inserts boxed.intValue()
        // java.lang.NullPointerException: Cannot unbox null value

        // ─── In a conditional expression ───────────────────────────────
        Integer a = null;
        int b = (a != null) ? a : 0;  // Safe β€” guarded
        System.out.println(b); // 0

        // ─── NPE from a Map lookup ─────────────────────────────────────
        Map<String, Integer> config = new HashMap<>();
        // config.get("timeout") returns null for missing keys
        // int timeout = config.get("timeout");  // NPE!

        // Safe patterns:
        int timeout1 = config.getOrDefault("timeout", 30);     // use getOrDefault
        Integer raw   = config.get("timeout");
        int timeout2  = (raw != null) ? raw : 30;              // null check first
        int timeout3  = java.util.Optional.ofNullable(config.get("timeout"))
                                           .orElse(30);         // Optional

        System.out.println(timeout1); // 30

        // ─── NPE in arithmetic expressions ────────────────────────────
        Integer x = getCount();  // returns null
        // int doubled = x * 2;  // NPE β€” unboxes null

        int doubled = (x == null) ? 0 : x * 2;
        System.out.println(doubled); // 0

        // ─── NPE in switch (Java 14+ enhanced switch) ─────────────────
        Integer status = null;
        // switch (status) { ... }  // NPE thrown before any case is evaluated

        // ─── NPE in boolean unboxing β€” the most surprising case ────────
        Boolean flag = null;
        // if (flag) { ... }  // NPE β€” unboxes null Boolean to boolean

        if (Boolean.TRUE.equals(flag)) {  // Safe null-friendly comparison
            System.out.println("True");
        } else {
            System.out.println("Not true (null or false)"); // Prints this
        }
    }

    static Integer getCount() {
        return null; // Simulates an absent value from a map/DB
    }
}
Java
// Real-world trap: auto-unboxing in method return
public class NullReturnTrap {

    private static final Map<String, Integer> scores = new HashMap<>();

    // Return type is primitive 'int' β€” triggers unboxing of null map result
    public static int getScore(String player) {
        return scores.get(player);  // NPE if player not in map!
    }

    // Safe version 1 β€” use Integer as return type
    public static Integer getScoreSafe1(String player) {
        return scores.get(player);  // Caller handles null
    }

    // Safe version 2 β€” use getOrDefault
    public static int getScoreSafe2(String player) {
        return scores.getOrDefault(player, 0);
    }

    // Safe version 3 β€” Optional
    public static java.util.OptionalInt getScoreSafe3(String player) {
        Integer v = scores.get(player);
        return v == null ? java.util.OptionalInt.empty()
                         : java.util.OptionalInt.of(v);
    }

    public static void main(String[] args) {
        scores.put("Alice", 95);

        // getScore("Bob") would throw NPE
        System.out.println(getScoreSafe2("Alice")); // 95
        System.out.println(getScoreSafe2("Bob"));   // 0 (default)

        getScoreSafe3("Alice").ifPresent(s -> System.out.println("Alice: " + s));
        System.out.println(getScoreSafe3("Bob").isPresent()); // false
    }
}
Java 21 enhanced NullPointerException messages now say exactly which variable was null (e.g., "Cannot unbox the return value of 'scores.get(player)'"). This makes diagnosing null unboxing bugs much easier, but prevention through correct use of Optional and getOrDefault is always better than debugging them.