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
| 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 | TRUEFALSE |
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.
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.
// 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 ++IntegerInteger
The Integer Cache (β128 to 127)
Integer.valueOf(n)IntegervalueOf
// 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
}
}
Note:
== vs equals Pitfall
This is one of the most common Java interview questions and a real source of production bugs. Always use
.equals()==
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
}
}
The same trap exists for BooleanBoolean.TRUEBoolean.FALSE==
// 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.
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 ) in computational loops.double - Prefer
,IntStream ,LongStream overDoubleStream for numeric work.Stream<Integer> - Use
instead ofint[] for large numeric arrays.List<Integer> - Avoid
patterns: useLong sum = 0L .long sum = 0L - Profile before optimising: JIT can sometimes eliminate boxing in simple cases.
Null Unboxing and NullPointerException
Wrapper objects can be nullnullNullPointerException
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
}
}