- What is Autoboxing / Unboxing?
- How it Works Under the Hood
- The Integer Cache (−128 to 127)
- == vs equals Pitfall
- Performance Implications
- Null Unboxing and NullPointerException
Every Java primitive has a corresponding wrapper class in java.lang:
| Primitive | Wrapper | Cache range |
| byte | Byte | −128 to 127 |
| short | Short | −128 to 127 |
| int | Integer | −128 to 127 (upper bound configurable) |
| long | Long | −128 to 127 |
| float | Float | none |
| double | Double | none |
| char | Character | 0 to 127 |
| boolean | Boolean | TRUE 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.
// 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.
Autoboxing is entirely a compiler transformation. The JVM has no notion of autoboxing; it sees
only explicit method calls in the bytecode.
// 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).
// 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).
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.
// 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 ==:
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
}
}
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.
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.
// 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.
}
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.
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.
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.
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
}
}
// 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.