Wrapper classes and autoboxing
What the compiler writes when it boxes and unboxes, why two Integers of 128 aren't ==, and what boxing costs in memory and speed.
On this page
📦 What are wrapper classes and autoboxing?
Every primitive has a wrapper class that turns the value into an object: int → Integer, long → Long, boolean → Boolean, and so on. Collections and generics only work with objects, so List<Integer> holds Integer objects, never raw ints. Autoboxing is the compiler quietly converting between the two: list.add(42) really compiles to list.add(Integer.valueOf(42)).
For this lesson, we'll focus on what the compiler generates for boxing and unboxing, the Integer cache that makes == behave strangely, and what boxing costs in memory and CPU. This is one of the most reliable interview topics in Java: a single "what does this print?" with two Integers tells the interviewer whether you know the difference between identity and value.
Below the line (out of scope)
equals()andhashCode()contracts in general (Core Java module)- Primitive specialisations in streams (
IntStream,LongStream) beyond a quick mention - Project Valhalla value classes (preview, not in Java 21)
The 30-second answer
Wrapper classes (Integer, Long, Double, ...) are immutable objects that hold one primitive, so primitives can go into collections and generics. Autoboxing compiles to Integer.valueOf(x) and unboxing to x.intValue(). valueOf returns cached objects for -128 to 127, so == between two boxed values is true inside that range and usually false outside it: always compare wrappers with equals(). Unboxing null throws NullPointerException, and boxing in hot loops costs an allocation and about 16 bytes per Integer, so keep primitives in hot paths.
What you’ll learn
- The eight wrapper classes and why
valueOfreplaced the deprecated constructors - What autoboxing and unboxing compile to, and where the hidden
NullPointerExceptioncomes from - How the Integer cache (-128 to 127,
-XX:AutoBoxCacheMax) makes==unreliable - What a boxed value costs: 16 bytes per Integer object and an allocation per boxing in hot loops
01Picture it first: a value in a box
Think of a wrapper as a sealed envelope with a number written inside. The number (the primitive) is the thing you care about. The envelope (the object) is what lets you post it into a List or a Map, which only accept envelopes. Two envelopes can contain the same number and still be two different envelopes, and that difference is the whole story of == versus equals().
| Primitive | Wrapper | Cached by valueOf |
|---|---|---|
byte |
Byte |
all 256 values |
short |
Short |
-128 to 127 |
int |
Integer |
-128 to 127 (upper bound configurable) |
long |
Long |
-128 to 127 |
char |
Character |
0 to 127 |
boolean |
Boolean |
TRUE and FALSE |
float |
Float |
none |
double |
Double |
none |
All wrappers are immutable and final. Integer x = 5; x++; doesn't change an object; it unboxes, adds one and boxes a different Integer into x.
02What the compiler actually writes
Try it yourself first
Before reading on, guess which method calls javac inserts into these three lines.
Integer boxed = 42; // boxing
int raw = boxed; // unboxing
List<Integer> list = new ArrayList<>();
list.add(raw); // boxing again
Run javap -c on the class and you'll see:
bipush 42
invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
...
invokevirtual java/lang/Integer.intValue:()I
...
invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
invokeinterface java/util/List.add:(Ljava/lang/Object;)Z
Notice three things:
- Autoboxing is pure compile-time sugar. The bytecode has ordinary method calls; the JVM doesn't know "autoboxing" exists.
- Boxing always goes through
valueOf, never through a constructor. That's what makes the cache apply. - Unboxing is a plain method call on the reference. If the reference is
null, that call throwsNullPointerException.
valueOf, not the constructor
Before Java 5, you wrote new Integer(42). Don't. The wrapper constructors have been deprecated since Java 9 and marked deprecated for removal since Java 16 (JEP 390), because they always allocate and bypass the cache. Use Integer.valueOf(42) (or plain autoboxing) to get a possibly cached object, and Integer.parseInt("42") when you want a primitive from a string.
Integer a = Integer.valueOf(100); // may return a cached instance
int b = Integer.parseInt("100"); // primitive, no object at all
Integer c = new Integer(100); // compiles with a removal warning, always a new object
03The Integer cache and == comparisons
Here's the question that shows up in nearly every Java screen.
Try it yourself first
Predict all four lines before scrolling.
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // ?
System.out.println(c == d); // ?
System.out.println(c.equals(d)); // ?
int e = 128;
System.out.println(c == e); // ?
true
false
true
true
Integer.valueOf keeps a static array of pre-built Integer objects for -128 to 127 (inside Integer.IntegerCache). Boxing 127 twice hands you the same object, so ==, which compares references, says true. Boxing 128 twice creates two different objects, so == says false. The last line is true because comparing an Integer with an int unboxes the Integer and compares numbers.
Boxing 127 returns the same cached Integer, so == is true; boxing 128 allocates two separate objects, so == is false while equals() is true
Many candidates say "== works for small numbers". It's frequently a mistake to rely on that, because the cache range is an implementation detail with a configurable upper bound. Tests with IDs 1 to 100 pass, and production breaks the day IDs pass 127. The rule is simple: compare wrappers with equals() (or Objects.equals(a, b) if either may be null), or unbox explicitly.
The JLS only guarantees caching for -128 to 127 (plus true, false and char 0 to 127). HotSpot lets you raise the Integer upper bound with -XX:AutoBoxCacheMax=<n> (or the system property java.lang.Integer.IntegerCache.high). With -XX:AutoBoxCacheMax=2000, Integer c = 1000, d = 1000; c == d prints true. The lower bound stays at -128, and Long, Short and Character caches aren't configurable.
More == traps
Long big = 127L;
System.out.println(big.equals(127)); // false: 127 boxes to Integer, not Long
System.out.println(big.equals(127L)); // true
Double x = 1.0, y = 1.0;
System.out.println(x == y); // false: Double is never cached
System.out.println(true ? Integer.valueOf(1) : Double.valueOf(2)); // 1.0 (!)
The last one is sneaky: in a conditional expression with an Integer and a Double operand, both are unboxed and binary numeric promotion makes the result a double. So you get 1.0, not 1.
04Unboxing null: the NullPointerException nobody sees coming
Because unboxing is a call to intValue(), any null wrapper explodes the moment it meets a primitive context.
Map<String, Integer> counts = new HashMap<>();
int n = counts.get("missing"); // NPE: get() returned null
Integer maybe = null;
int x = flag ? maybe : 0; // NPE: the conditional unboxes maybe
Boolean enabled = config.get("feature"); // null if the key is absent
if (enabled) { ... } // NPE
Since Java 14, helpful NullPointerException messages make this easier to spot in logs: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null. When you see intValue(), longValue() or booleanValue() in an NPE message, think "unboxing".
The fixes are boring and reliable: counts.getOrDefault("missing", 0), Boolean.TRUE.equals(enabled), or keep the field a primitive when "absent" isn't a meaningful state.
05Real world: what boxing costs
On a 64-bit HotSpot JVM with compressed oops (the default for heaps under about 32 GB), an object has a 12-byte header, and objects are padded to 8-byte multiples:
| Thing | Size |
|---|---|
int in an int[] |
4 bytes |
Integer object |
16 bytes (12 header + 4 value) |
Long / Double object |
24 bytes (12 header + 8 value, padded) |
Reference to it in an Object[] |
4 bytes (compressed oop) |
So a million values cost about 4 MB in an int[] and about 20 MB in an ArrayList<Integer> (16 MB of Integer objects plus 4 MB of references), before counting ArrayList spare capacity. A HashMap<Integer, Integer> adds a 32-byte entry per mapping on top. Those objects are also scattered across the heap, so iterating them is slower than walking a dense array.
JDK 24 added experimental compact object headers (JEP 450, made a product feature in JDK 25 by JEP 519, still off by default). With -XX:+UseCompactObjectHeaders the header shrinks to 8 bytes, so a Long drops to 16 bytes. On Java 21, assume the numbers in the table.
The classic boxing bug is an accumulator declared as a wrapper:
Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i; // unbox, add, Long.valueOf(...) every iteration
}
That's up to a million short-lived Long allocations. Declaring long sum removes all of them. Escape analysis sometimes rescues code like this, but you can't count on it. The same applies to streams: prefer mapToInt(...).sum() over map(...).reduce(0, Integer::sum).
The most common production version I see is a Map<Long, Long> used as a counter in a hot path, or a List<Integer> with millions of IDs loaded from a database. Heap dumps show millions of java.lang.Long instances, and GC pressure goes up. The fix is a primitive array, or a primitive collection library (Eclipse Collections, fastutil) when you need map semantics.
My default: use primitives for fields, locals and arithmetic; use wrappers only where an object is required (generics, collections) or where null genuinely means "no value", such as a nullable database column.
06How to say it in the interview
When I ask about autoboxing, I'm listening for three things: do you know boxing compiles to valueOf and that it's cached for -128 to 127, do you immediately say "use equals()", and do you know the two runtime costs, NullPointerException and allocation.
In the interview
"Wrappers like Integer are immutable objects around a primitive so values can live in collections and generics. Autoboxing is compiler sugar: boxing becomes Integer.valueOf and unboxing becomes intValue(). valueOf caches -128 to 127, which is why == on two boxed 127s is true and on two boxed 128s is false, so I always compare wrappers with equals. Unboxing null throws an NPE, and boxing in a hot loop allocates a 16-byte object each time, so I keep primitives in hot paths and use IntStream or primitive arrays for large data."
Here's how this might look if you sketch it on the whiteboard:
On the whiteboard
Boxing
Integer x = 5→Integer.valueOf(5)int y = x→x.intValue()(NPE if null)- cache: -128..127 (Integer high: -XX:AutoBoxCacheMax)
==on wrappers = identity → use equals()new Integer()deprecated for removal (Java 16)- Integer 16 B vs int 4 B; avoid boxing in loops