Here's a Java puzzle that confused me for days. Look at this code:
Integer a = 127, b = 127;
System.out.println(a == b);
Integer c = 128, d = 128;
System.out.println(c == d);
Output:
true
false
Same code, one number bigger, opposite result. My first thought was that Java was broken. It's not. It's showing us what happens behind the scenes when Java converts between simple values like int and objects like Integer.
In this post, we'll learn what wrapper classes are, what boxing, unboxing, autoboxing and auto-unboxing mean (and how they differ), and why the puzzle above behaves the way it does. By the end, you'll also know the traps to avoid.
Unless I show a full class, the snippets go inside a main method.
Primitives vs objects
Java has two kinds of data.
Primitives are simple values. There are exactly eight: byte, short, int, long, float, double, char and boolean. A primitive holds the value itself, like 10 or true. It has no methods, and it can never be null.
Objects are instances of classes, like String or ArrayList. A variable holds a reference to the object on the heap. Objects have methods, and a reference can be null.
int age = 25; // the variable holds 25 directly
String name = "Asha"; // the variable holds a reference to an object
Most of the time, primitives are all you need. But sometimes Java insists on an object. That's where wrapper classes come in.
What are wrapper classes?
A wrapper class is an object version of a primitive. It "wraps" the primitive value inside an object. Java gives you one wrapper for each primitive, all in the java.lang package:
-
byte→Byte -
short→Short -
int→Integer -
long→Long -
float→Float -
double→Double -
char→Character -
boolean→Boolean
Notice that two of them have longer names: int becomes Integer, and char becomes Character. The other six just capitalize the first letter.
Why do we need them?
There are four main reasons:
1. Collections and generics need objects. You can't write List<int>. Generics only work with objects, so you write List<Integer>.
List<int> bad = new ArrayList<>(); // compile error
List<Integer> good = new ArrayList<>(); // works
2. A primitive can't be null, but a wrapper can. Sometimes "no value" is a real answer, like a test score that hasn't been entered yet.
Integer score = null; // fine: "no score yet"
int plainScore = null; // compile error
3. Useful methods and constants. Wrappers come with helpers that primitives can't have:
int n = Integer.parseInt("42");
System.out.println(n + 1);
System.out.println(Integer.MAX_VALUE);
System.out.println(Integer.toBinaryString(10));
System.out.println(Character.isDigit('7'));
System.out.println(Double.compare(1.5, 2.5));
Output:
43
2147483647
1010
true
-1
4. Some APIs expect Object. Anything that takes an Object (or generic type) needs a wrapper if you want to pass a number.
Wrappers are immutable
Like String, a wrapper object can never change after it's created. Watch what happens when we "change" an Integer:
Integer i = 127;
Integer j = i;
i++;
System.out.println(i + " " + j);
Output:
128 127
i++ didn't modify the object. It created a new Integer holding 128 and pointed i at it. j still points to the old one.
Boxing and unboxing
Now the two key words:
-
Boxing means converting a primitive into its wrapper object (
int→Integer). -
Unboxing means converting a wrapper object back into a primitive (
Integer→int).
Think of putting a gift into a box and taking it out again.
boxing
int 10 ----------> Integer object (on the heap)
<----------
unboxing
You can do both by hand with methods:
int a = 10;
Integer boxed = Integer.valueOf(a); // boxing
int b = boxed.intValue(); // unboxing
System.out.println(boxed + " " + b);
Output:
10 10
Every wrapper has its own pair: Double.valueOf(...) and doubleValue(), Boolean.valueOf(...) and booleanValue(), and so on.
(You may see old code write new Integer(10). That constructor is deprecated, because valueOf is better. We'll see why in a moment.)
Autoboxing and auto-unboxing
Doing this by hand every time would be tiring. So since Java 5, the compiler does it for you automatically:
- Autoboxing is boxing done automatically by the compiler.
- Auto-unboxing is unboxing done automatically by the compiler.
Integer x = 10; // autoboxing: int -> Integer
int y = x; // auto-unboxing: Integer -> int
So the difference between the pairs is just who does the work:
-
Boxing / unboxing: the general idea, which you can do manually by calling
valueOf()orintValue(). - Autoboxing / auto-unboxing: the compiler does the same thing for you, with no method calls written by you.
Where it happens
Autoboxing and auto-unboxing happen in many everyday places:
List<Integer> nums = new ArrayList<>();
nums.add(5); // autoboxing: 5 becomes an Integer
int first = nums.get(0); // auto-unboxing: Integer becomes int
System.out.println(first + 1);
Output:
6
They also happen in arithmetic (Integer a = 5; Integer b = a + 3; unboxes a, adds, then boxes the result), in method arguments, and in return values.
Behind the scenes: the compiler rewrites your code
There's no magic. The compiler simply inserts the method calls we wrote by hand:
Integer x = 10; // compiler turns this into: Integer x = Integer.valueOf(10);
int y = x; // compiler turns this into: int y = x.intValue();
This matters, because those hidden calls have a cost, and in one case (null) they can crash your program. We'll see both soon.
The Integer cache: solving the puzzle
Let's go back to the puzzle. First, remember the difference between == and .equals(). For objects, == checks whether two variables point to the same object, not whether the values match.
Now, Integer.valueOf() (which autoboxing uses) has a trick. Small numbers are used all the time, so Java pre-creates and caches the Integer objects for -128 to 127. If your value is in that range, valueOf gives you a shared object. If it's outside, you get a new object. In simplified form:
public static Integer valueOf(int i) {
if (i >= -128 && i <= 127) {
return IntegerCache.cache[i + 128]; // shared, pre-made object
}
return new Integer(i); // a fresh object
}
Here's the picture for our puzzle:
CACHE (shared objects for -128 ... 127) REST OF THE HEAP
+--------------------------------------+ +-------------+
| ... [126] [127] ... | | Integer 128 | <- c
+--------------------------------------+ +-------------+
^ ^ | Integer 128 | <- d
| | +-------------+
a b
a and b point to the SAME object c and d point to DIFFERENT objects
a == b -> true c == d -> false
So a == b is true because both point to the same cached 127. And c == d is false because each 128 is a separate object. Let's confirm with equals, and with a mix of wrapper and primitive:
Integer c = 128, d = 128;
int e = 128;
System.out.println(c == d);
System.out.println(c.equals(d));
System.out.println(c == e);
Output:
false
true
true
The last line is true because when you compare a wrapper with a primitive, Java auto-unboxes the wrapper and compares plain numbers. The rule to remember:
-
Wrapper
==wrapper compares references (risky). -
Wrapper
==primitive unboxes and compares values. -
.equals()compares values (safe).
A few facts about the cache:
- It exists for
Byte,Short,IntegerandLong(range -128 to 127), forCharacter(0 to 127) and forBoolean(onlytrueandfalse). -
FloatandDoublehave no cache. - The upper limit for
Integercan be raised with a JVM setting, but never write code that depends on that. - This is the reason
valueOfis better thannew Integer(...). The constructor always creates a new object and skips the cache.
Trap 1: auto-unboxing a null
A wrapper can be null. A primitive can't. So when Java tries to unbox a null, it has nothing to unbox, and your program crashes.
Integer score = null;
int s = score; // NullPointerException
On recent Java versions, the error looks like this:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because ...
See the method name in the message? intValue(). That's the call the compiler secretly added. The most common real-life version of this bug is with a Map:
Map<String, Integer> stock = new HashMap<>();
stock.put("pen", 10);
int pens = stock.get("pen");
System.out.println(pens);
int pencils = stock.get("pencil"); // get() returns null, then unboxing fails
Output:
10
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
There's no "pencil" key, so get returns null, and Java tries to unbox it. The fix is to provide a default: stock.getOrDefault("pencil", 0).
Trap 2: the hidden cost of boxing
Every time Java boxes a value outside the cache, it creates a new object. A wrapper object is also bigger than the primitive it holds. An Integer typically takes 16 bytes, while an int takes 4.
Compare how the data is stored:
int[] : [ 10 ][ 20 ][ 30 ] values sit side by side
List<Integer> : [ ref ][ ref ][ ref ] references, each pointing to its own object
| | |
v v v
Integer Integer Integer
10 20 30
For a handful of items, you'll never notice. In a big loop, you will:
Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i;
}
System.out.println(sum);
Output:
499999500000
The answer is right, but look at sum += i. Each round, Java unboxes sum, adds i, then boxes the result into a new Long. That's roughly a million short-lived objects. If we simply declare long sum = 0L;, the loop uses plain numbers with no objects at all, and it's much faster. One letter, L vs l, makes a real difference.
Trap 3: overloaded methods and boxing
Here's one that catches even experienced developers. List has two remove methods: remove(int index) and remove(Object o).
List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
list.remove(1); // removes the item at INDEX 1
list.remove(Integer.valueOf(10)); // removes the VALUE 10
System.out.println(list);
Output:
[30]
With list.remove(1), Java sees a plain int, so it picks the index version and removes 20. To remove by value, you have to box it yourself with Integer.valueOf(10), so Java picks the object version.
Trap 4: wrappers don't convert into each other
Widening works for primitives, but not for wrappers:
long a = 5; // fine: int to long
Long b = 5; // compile error: int cannot be converted to Long
Long c = 5L; // fine
Double d = 5; // compile error
Double e = 5.0; // fine
Autoboxing only boxes to the matching wrapper. An int boxes to Integer, and nothing else. The same idea applies to equals: Integer.valueOf(1).equals(Long.valueOf(1)) is false, because they're different classes.
Which one should I use?
-
Primitive or wrapper? Default to the primitive (
int). Use the wrapper only when you need an object: in collections and generics, when you neednullas a valid answer, or when an API demands it. -
valueOfornew? AlwaysvalueOf, or just let autoboxing do it. It uses the cache, andnew Integer(...)is deprecated. -
parseIntorvalueOf?Integer.parseInt("42")returns a primitiveint.Integer.valueOf("42")returns anIntegerobject. Use the one that matches what you need. -
==orequals? For wrappers, use.equals()(or unbox first). For primitives, use==. - Manual or automatic boxing? Let the compiler do it, but stay aware of it. In hot loops and big data structures, check that you aren't boxing millions of times without need.
Common mistakes
-
Comparing wrappers with
==. It works for small values because of the cache, then fails on 128. Use.equals(). -
Forgetting that unboxing can throw. Any
Integerthat might benullis a hiddenNullPointerExceptionif you assign it to anint. Check fornullfirst, or usegetOrDefault. -
Using wrapper types in hot loops.
Long suminstead oflong sumquietly creates a flood of objects. Use primitives for counters and totals. -
Mixing up
list.remove(int)andlist.remove(Object). Box the value withInteger.valueOf(...)when you want to remove by value. -
Expecting wrappers to convert to each other.
Long l = 5;andDouble d = 5;don't compile. Use5Land5.0. -
Using
new Integer(...). It's deprecated, skips the cache and creates needless objects. UseInteger.valueOf(...)or autoboxing.
Quick recap
-
Primitives hold simple values. Wrapper classes (
Integer,Double,Characterand so on) are their object versions. - You need wrappers for collections and generics, for
null, for helper methods, and for APIs that expect objects. - Boxing turns a primitive into a wrapper. Unboxing turns it back.
-
Autoboxing and auto-unboxing are the same conversions, done automatically by the compiler. It rewrites your code to call
valueOf()andintValue(). -
Integer.valueOf()caches values from -128 to 127, which is why127 == 127istrueand128 == 128isfalseforIntegerobjects. - Wrapper objects are immutable, so "changing" one creates a new object.
- Watch for
nullunboxing, boxing in loops,removeoverloads and==on wrappers.
Try it yourself
Here's a small practice idea: write the Integer a = 127 and Integer c = 128 puzzle yourself and run it. Then change the numbers to -128, -129, 1000 and 100 and predict each result before you run it. After that, create a Map<String, Integer>, read a missing key into an int and see the NullPointerException for yourself. Finally, fix it with getOrDefault.
For a bonus experiment, time the Long sum loop against the long sum loop using System.nanoTime(), and see how big the difference gets with 100 million iterations.
If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.
Top comments (0)