Two conversions the compiler inserts silently
Autoboxing turns a primitive into an instance of its wrapper class, and unboxing turns it back. The compiler inserts both wherever the types require it: assigning an int to an Integer, passing an int where an Object is expected, adding two Integer values, or taking the int out of a Map<String, Integer>. What makes the feature awkward is not the conversion but its invisibility. Boxing creates an object, unboxing dereferences one, and neither appears in the source.
Once you hold that, the identity question answers itself. == on two references compares the references, and it does so whether or not the referents happen to be numbers. Two Integer objects holding the same value are equal under equals and not necessarily identical under ==.
The cache is specified, which is what makes it dangerous
Boxing does not always allocate. The language specification requires that boxing a boolean, a byte, a char up to 127, or a short or int between -128 and 127 yields a reference to a cached object, so boxing the same small value twice gives you the same instance both times.
Integer a = 127, b = 127;
boolean sameSmall = (a == b);
Integer c = 128, d = 128;
boolean sameLarge = (c == d);
An implementation may cache more than the specified range; HotSpot lets you raise the Integer upper bound with a system property. That freedom is precisely why you must not depend on the boundary. Code that compares boxed values with == is not merely wrong for large numbers, it is wrong in a way whose visible behaviour depends on the value, the type and the JDK configuration, so it passes review, passes tests written with small fixtures, and fails on real data. Compare with equals, or unbox one side explicitly so the comparison becomes numeric.
The point generalises beyond Integer. Short carries the same required range and Character is required to cache up to 127; Long.valueOf is documented to cache the same small range; Boolean has only two values to cache; and Float and Double carry no caching guarantee at all, so == on boxed floating-point values is unreliable everywhere. If a review argument ever comes down to "but it works for our IDs", the answer is that the specification promises this for -128 to 127 and nothing else.
Unboxing a null
The second cost is a NullPointerException thrown on a line where nothing appears to be called. Any expression that needs the primitive out of a wrapper dereferences the wrapper, so a null produces a failure at that point rather than where the null came from.
Map<String, Integer> counts = ...;
int n = counts.get("missing");
The same trap hides in a conditional expression that mixes a wrapper with a primitive. Because the two branches must share a type, the compiler unboxes the wrapper branch, so an expression that looks as though it merely selects between two values throws when the wrapper is null. Nullable database columns mapped to wrapper fields feed this constantly: the entity loads fine, and the arithmetic three layers away fails. The defence is to keep nullability at the boundary, converting once with an explicit default rather than letting a wrapper travel through arithmetic where the compiler will quietly dereference it.
Overload resolution prefers not to box
The third cost is the one people find hardest to believe.
List<Integer> ids = new ArrayList<>(List.of(10, 20, 30));
ids.remove(1);
ids.remove(Integer.valueOf(1));
List declares both remove(int) and remove(Object). Overload resolution proceeds in phases, and the first phase considers only conversions that do not involve boxing. remove(int) is applicable without boxing, so it wins, and the value-based overload is never reached. Nothing warns you, because both calls compile and both do something reasonable.
The generalisation is worth carrying into other APIs: when a method is overloaded on a primitive and on a reference type, an unadorned literal or int variable selects the primitive version. The related failure needs no overloading at all — calling get on a Map<Long, ?> with an int variable boxes it to an Integer, which is never equals to any Long, so the lookup misses silently and you spend an afternoon on it. Cast to the right primitive width, or make the argument's type explicit.
Where the allocation actually matters
Boxing allocates, and in a loop that accumulates into a wrapper it allocates once per iteration, producing garbage proportional to the iteration count. This is a real effect and also the one most often overstated, so treat it as a profiling question rather than a rule: the escape analysis in a modern JIT can remove some of these allocations, and a single boxed value in a request-scoped path is irrelevant. What is worth doing unconditionally is choosing the primitive-specialised type where the API offers one — a long accumulator instead of a Long, IntStream instead of Stream<Integer>, comparingInt instead of comparing — because it costs nothing to write and removes the question.
Finally, prefer Integer.valueOf to the wrapper constructors, which have been deprecated for removal since Java 9. valueOf consults the cache; the constructor was contractually obliged to return a fresh object, which is both slower and the thing that made == comparisons unpredictable in the first place.
© 2026 Preptima. Originally published at preptima.com (opens in a new tab).