Java Integer == Fails at 128 — The Cache Boundary Bug
Integer comparison returns false for 200 but true for 50.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- Autoboxing = compiler automatically converts primitive to wrapper (int → Integer) via Integer.valueOf()
- Unboxing = compiler automatically converts wrapper to primitive (Integer → int) via intValue()
- Integer cache: values -128 to 127 return cached objects — == works for 100, fails for 200
- Performance: Using Long in a loop instead of long creates heap objects each iteration = 5-10x slower + GC pressure
- Production trap: Unboxing null Integer → NullPointerException on hidden intValue() call, stack trace points to innocent-looking arithmetic
- Biggest mistake: Using == to compare Integer objects — it works for small numbers (cache) and fails for large numbers (new objects), making the bug intermittent
Imagine you have a coin (a primitive int) and a coin purse (an Integer object). Sometimes a shop only accepts purses, not loose coins. Autoboxing is Java automatically dropping your coin into a purse before handing it over. Unboxing is Java taking the coin back out when you need raw math. You never see it happen — Java just does it quietly behind the scenes. The catch? Opening and closing purses costs a tiny bit of effort, and if the purse is empty (null), handing it over causes a crash.
Every Java developer writes code that mixes primitive types and their object counterparts constantly — passing an int into an ArrayList, returning an Integer from a method, or comparing values with ==. For years before Java 5, you had to manually convert between them, writing verbose boilerplate that cluttered your logic and made bugs easier to hide. Autoboxing changed that, and understanding it deeply separates developers who write clean, performant code from those who wonder why their app slows down or throws a NullPointerException out of nowhere.
The problem autoboxing solves is the fundamental tension at the heart of Java: primitives (int, double, boolean, etc.) are fast and live on the stack, but the Collections framework and generics only work with objects. You can't put an int into a List<Integer> directly — the JVM needs a full-fledged object with a memory address. Autoboxing bridges that gap automatically, letting the compiler handle the conversion so your code stays readable without you thinking about it every single time.
After reading this article you'll understand exactly what autoboxing and unboxing are, why they exist, how the JVM handles the conversion under the hood (including the integer cache you've probably never heard of), and the three real mistakes that trip up even experienced developers. You'll also walk away with solid answers to the interview questions that actually get asked about this topic.
What Autoboxing and Unboxing Actually Are (Under the Hood)
Java has eight primitive types: byte, short, int, long, float, double, char, and boolean. Each one has a corresponding wrapper class in java.lang — Integer, Double, Boolean, etc. These wrapper classes turn a primitive into a full Java object, which means it can be stored in collections, used with generics, passed where Object is expected, and set to null.
Autoboxing is the compiler automatically calling Integer.valueOf(int) for you when a primitive is used where an object is expected. Unboxing is the compiler automatically calling intValue() (or the equivalent) when an object is used where a primitive is expected. This all happens at compile time — the compiler inserts the conversion calls into the bytecode so the JVM never sees the raw conversion gap.
This isn't magic and it isn't free. Every autoboxed value allocates a new object on the heap (with one important exception we'll cover). Knowing that the compiler is secretly inserting method calls lets you predict performance, understand NullPointerExceptions, and reason about == comparisons that behave in surprising ways.
Integer.valueOf() and intValue() calls the compiler inserted. This makes the hidden mechanism completely visible and kills any doubt about what's happening.Integer.valueOf() uses the integer cache for values -128 to 127. For values outside this range, it creates a new object every call.-XX:AutoBoxCacheMax=<size> but doing so is rare.Integer.valueOf() calls; unboxing is the compiler inserting .intValue() calls — it's not runtime magic, it's compile-time code generation you can verify with javap.int sum = 0; not Integer sum = 0;public Integer findScore(String name) returning null if not found.The Integer Cache — Why == Comparisons Will Lie to Your Face
Here's the thing that trips up even senior developers: Java caches Integer objects for values between -128 and 127. This means Integer.valueOf(100) returns the exact same object every time — not a new one. It's a JVM optimization built into the spec because small integers are used so frequently that creating millions of identical tiny objects would be wasteful.
The consequence is bizarre. Comparing two autoboxed Integer values with == works correctly for small numbers but silently fails for larger ones, because == on objects compares memory addresses, not values. Two Integer objects holding 200 are different objects at different addresses, so == returns false even though they hold the same number.
This is one of the most famous Java interview questions for good reason — it looks like a bug in the language but it's actually documented, intentional behavior. The fix is simple: always use .equals() to compare Integer objects. Never use == unless you specifically want to check object identity. The same cache behavior applies to Short, Byte, Character (0–127), and Boolean (both cached).
-XX:AutoBoxCacheMax. Some frameworks (e.g., some application servers) increase this value, making the bug even more subtle.Dm: Use of == to compare Integer objects should be treated as an error in CI, not a warning.Performance Pitfalls — When Autoboxing Quietly Kills Your Loop
Autoboxing feels invisible, but it isn't free. Each conversion creates a heap object (except cached values), which means more garbage for the GC to collect. In a tight loop that runs thousands or millions of times, unnecessary autoboxing can turn a fast operation into a slow one without a single obvious line of code to blame.
The classic trap is accidentally using a wrapper type as an accumulator in a loop. If you declare Long totalRevenue instead of long totalRevenue, every single addition operation unboxes the Long, adds the primitive, then autoboxes the result back into a new Long object. A loop running a million times creates a million short-lived objects the GC must track and collect.
The rule of thumb is simple: use primitives for local variables and computation. Use wrapper types only when the API requires it — for collections, generics, method signatures that return null as a 'no value' signal, or database/JSON mapping where null is meaningful. This distinction is what modern Java engineers mean when they talk about being intentional with types.
Long totalRevenueSlow = 0L creates 1,000,000 Long objects for 1M iterations. The fast version with long totalRevenueFast creates zero objects.The NullPointerException Nobody Expects — Null Unboxing in Practice
Here's the sneakiest autoboxing bug: unboxing a null wrapper causes a NullPointerException that looks like it came from nowhere. When Java tries to call .intValue() on a null Integer reference, it throws. The stack trace points at a line that looks like plain arithmetic or a simple variable assignment — no obvious null check, no object method call in your code. That's exactly why it's so confusing.
This pattern appears constantly in real code. A method returns Integer (with null meaning 'no data found'). The caller assigns it to an int variable. Boom. Or a Map lookup returns null for a missing key, and the result is immediately used in a calculation.
The fix isn't to avoid nullable wrappers — they're genuinely useful for signalling absence of a value. The fix is to always validate before unboxing when the wrapper could be null. Use null checks, Optional<Integer>, or provide a default with Objects.requireNonNullElse(). Understanding that unboxing is a hidden method call is the mental model that makes these bugs obvious before they bite you.
int total = calculatePrice() + calculateTax() where both methods return Integer).Objects.requireNonNullElse(wrapper, defaultValue) before unboxing, or use Optional to make nullability explicit.Java Primitive Types and Their Wrapper Classes — The Cheat Sheet You'll Memorise
Every autoboxing and unboxing disaster traces back to one thing: not knowing which primitive maps to which wrapper. The compiler hides this, but the JVM doesn't forgive ignorance.
There are exactly eight primitive types in Java, each with a corresponding wrapper class in java.lang. Boolean wraps boolean, Byte wraps byte, Short wraps short, Character wraps char, Integer wraps int, Long wraps long, Float wraps float, Double wraps double. That's it. No more. No less.
Notice the pattern: primitive names are lowercase, wrappers start with a capital. Except for char → Character and int → Integer. Those two are the ones that burn juniors who try to guess the naming convention.
When the compiler inserts autoboxing or unboxing, it's calling valueOf() and xxxValue() behind the scenes. Knowing these pairs by heart means you catch implicit conversions before they hit production. Every performance issue with autoboxing starts with someone treating an int like an Integer in a tight loop. Map the types, avoid the trap.
The Generic Generational Trap — Autoboxing in Collections You Never Wrote
Here's where autoboxing slaps you when you least expect it: generics. The Java compiler forces collections to use wrapper types. You write List<Integer>, you push ints into it, and the compiler silently calls Integer.valueOf() for every single element.
This isn't just about your code. It's about the code you never wrote. Third-party libraries that return List<Integer> when you wanted int[]? You're paying autoboxing tax on every access. Sorting? Iterating? Each get() call triggers an implicit unboxing. Each put() triggers an autobox.
The real grief shows up in maps. Try HashMap<Integer, Double> in a data processing pipeline that runs over a million entries. You're allocating an Integer and a Double object—two heap allocations—every time you insert. The GC will remind you why you should have used a primitive collection library or a plain array.
Production lesson: if your collection size exceeds 10,000 elements and you see GC pressure, profile it. Chances are autoboxing is drowning your heap in short-lived wrapper objects. Trove, Eclipse Collections, or plain arrays exist for a reason. Use them.
List<Integer> + for-each loop + 100,000+ elements = GC nightmare. Each iteration allocates an Integer on add and unboxes it on get. Switch to int[] or a primitive-backed collection library for tight loops.The Intermittent Integer Comparison That Corrupted Financial Reports
== and returning false. The same code compared small values (e.g., 50) correctly. The team saw the failure only on accounts with >127 transactions. The bug was intermittent by value, not by timing.== worked because they'd tested with small numbers and it passed. They didn't know about the integer cache. They also assumed that since both Integers came from the same source (autoboxing of ints from a database query), they would be the same object. They didn't know that Integer.valueOf(200) creates a new object each time.if (storedCount == currentCount) { ... } where both were Integer objects from different map lookups. For values between -128 and 127, JVM reuses cached objects, so == works. For values 128 and above, JVM creates new objects each time. The comparison failed because the two Integer objects were different heap objects with the same value. The bug was completely invisible until a day with 200+ transactions occurred. The reconciliation logic used == for equality, leading to incorrect matches and corrupt reports..equals(): if (storedCount.equals(currentCount)).
2. Added a project-wide lint rule: -Xlint:unchecked and SpotBugs rule Dm: Use of == to compare Integer objects.
3. For high-performance paths, unboxed to primitive int before comparison: int stored = storedCount; int current = currentCount; if (stored == current) (null-checked first).
4. Updated the team's coding standards: 'Never use == to compare wrapper objects. Always use .equals().'- The integer cache causes
==to work for -128..127 and fail for other values. This is the most common autoboxing bug in production Java code. - Never use
==to compare Integer, Long, Short, Byte, Character, or Boolean objects. Always use.equals(). - SpotBugs and IntelliJ inspection warnings about 'Boxed value comparison' are not noise — they catch real bugs.
- If performance is critical, unbox to primitive after null check:
int a = integerA; int b = integerB; if (a == b)
== with .equals().int total = price + tax)price or tax is an Integer that is null. Unboxing called intValue() on null. Add null checks before unboxing: if (price != null && tax != null)Long total = 0L inside loop). Change to primitives: long total = 0L. Profile with JFR to see allocation spikes.int triggers unboxing. Use Integer result = map.get(key); if (result != null) { int val = result; } or int val = map.getOrDefault(key, 0);IntArrayList from Eclipse Collections, or stick with int[]. Profile with async-profiler to confirm allocation rate.javap -c YourClass.class | grep -A10 'if_icmpne\|if_acmpne'echo 'System.out.println(IntegerCache.high);' | jshell -if (a == b) with if (a.equals(b)). For primitive comparison, unbox first: if (a != null && b != null && a.intValue() == b.intValue())Key takeaways
Integer.valueOf() calls; unboxing is the compiler inserting .intValue() callsCommon mistakes to avoid
5 patternsUsing == to compare Integer objects
if (a != null && b != null && a.intValue() == b.intValue()). Add SpotBugs rule to block == on boxed types in CI.Declaring a wrapper type as a loop accumulator (e.g., `Long total = 0L`)
Integer.valueOf() as a hotspot.long total = 0L. The wrapper is not needed. Only use wrapper if null is meaningful or the collection requires it.Unboxing a wrapper returned from a Map or database call without a null check
Integer result = map.get(key); if (result != null) { int val = result; }. For Map, use map.getOrDefault(key, 0) if default value acceptable. Use Objects.requireNonNullElse(wrapper, defaultValue) for nullable wrappers.Using Integer where primitive would suffice in a hot path
int fields in performance-critical classes, not Integer.Assuming Integer cache size is always -128..127
Interview Questions on This Topic
What is the integer cache in Java, and how does it affect == comparisons between autoboxed Integer values? Can you show an example where == gives different results for Integer variables holding 100 vs 200?
Integer.valueOf() method. When you autobox an int within this range, the same cached object is returned. For values outside this range, a new Integer object is created each time. Example: Integer a = 100; Integer b = 100; a == b returns true because both reference the same cached object. Integer c = 200; Integer d = 200; c == d returns false because they are two different objects on the heap. Using == compares object references (memory addresses), not the numeric values. This is why you should always use .equals() to compare the values of wrapper objects. The cache range can be extended with the JVM flag -XX:AutoBoxCacheMax, but relying on this is fragile.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Java Basics. Mark it forged?
6 min read · try the examples if you haven't