DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

Why Java Says 127 == 127 but 128 != 128

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);
Enter fullscreen mode Exit fullscreen mode

Output:

true
false
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

Output:

43
2147483647
1010
true
-1
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Output:

128 127
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Output:

10 10
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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() or intValue().
  • 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);
Enter fullscreen mode Exit fullscreen mode

Output:

6
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Output:

false
true
true
Enter fullscreen mode Exit fullscreen mode

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, Integer and Long (range -128 to 127), for Character (0 to 127) and for Boolean (only true and false).
  • Float and Double have no cache.
  • The upper limit for Integer can be raised with a JVM setting, but never write code that depends on that.
  • This is the reason valueOf is better than new 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
Enter fullscreen mode Exit fullscreen mode

On recent Java versions, the error looks like this:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Output:

499999500000
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Output:

[30]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 need null as a valid answer, or when an API demands it.
  • valueOf or new? Always valueOf, or just let autoboxing do it. It uses the cache, and new Integer(...) is deprecated.
  • parseInt or valueOf? Integer.parseInt("42") returns a primitive int. Integer.valueOf("42") returns an Integer object. Use the one that matches what you need.
  • == or equals? 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

  1. Comparing wrappers with ==. It works for small values because of the cache, then fails on 128. Use .equals().
  2. Forgetting that unboxing can throw. Any Integer that might be null is a hidden NullPointerException if you assign it to an int. Check for null first, or use getOrDefault.
  3. Using wrapper types in hot loops. Long sum instead of long sum quietly creates a flood of objects. Use primitives for counters and totals.
  4. Mixing up list.remove(int) and list.remove(Object). Box the value with Integer.valueOf(...) when you want to remove by value.
  5. Expecting wrappers to convert to each other. Long l = 5; and Double d = 5; don't compile. Use 5L and 5.0.
  6. Using new Integer(...). It's deprecated, skips the cache and creates needless objects. Use Integer.valueOf(...) or autoboxing.

Quick recap

  • Primitives hold simple values. Wrapper classes (Integer, Double, Character and 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() and intValue().
  • Integer.valueOf() caches values from -128 to 127, which is why 127 == 127 is true and 128 == 128 is false for Integer objects.
  • Wrapper objects are immutable, so "changing" one creates a new object.
  • Watch for null unboxing, boxing in loops, remove overloads 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)