Why does == sometimes work on two Integer objects and sometimes not, and where else does autoboxing catch people out?
Boxing produces objects, so == compares references. The specification requires small values to come from a cache, which is why comparisons on small numbers appear to work and fail above the cached range. The other costs are a NullPointerException on unboxing and overload resolution preferring the primitive method.
What the interviewer is scoring
- Does the candidate separate reference identity from value equality before mentioning the cache at all
- Whether the cached range is attributed to the language specification rather than to an implementation quirk
- That a null value in a map or a nullable column is connected to an unboxing failure on a line with no visible call
- Can they explain why remove with an int argument on a List of Integer deletes by position
- Whether they know which wrapper types carry no caching requirement
Answer
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); // true: both references came out of the cache
Integer c = 128, d = 128;
boolean sameLarge = (c == d); // false: above the required range, two objects
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 = ...;
// NPE if the key is absent: get returns null, and the assignment unboxes it.
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); // removes index 1, leaving [10, 30]
ids.remove(Integer.valueOf(1)); // removes the value 1, if present
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.
Likely follow-ups
- Why does a conditional expression that mixes Integer and int throw when the Integer is null?
- Which wrapper types does the specification not require to be cached, and why not?
- How would you find unnecessary boxing in a hot loop instead of guessing where it is?
- What does Integer.valueOf give you that the deprecated constructor did not?
Related questions
- Implement a FIFO queue using only two stacks, then tell me what a dequeue costs.mediumSame kind of round: coding4 min
- Count the set bits in a 32-bit integer. Now tell me what your loop does when I hand it a negative number.mediumSame kind of round: coding4 min
- Find me every customer with no order in the last thirty days, then tell me which ways of writing that get NULLs wrong.mediumSame kind of round: coding4 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- Your test still reaches the network even though you patched the client. Talk me through fixture scope, parametrisation, and where a patch has to point.mediumSame kind of round: concept4 min
- This form marks invalid fields with red text and a red border. What has to change?mediumSame kind of round: concept4 min
- How is `this` determined in JavaScript, and why does a method lose it when you pass it as a callback?mediumSame kind of round: concept4 min
- How do you remove elements from a collection while iterating it, and what makes an iterator fail-fast?mediumSame kind of round: concept5 min