The Java Language

Lesson 1 of 5

Types, Primitives and Boxing

"Appearances are a glimpse of the unseen." — Anaxagoras, as reported by Sextus Empiricus (5th century BC)

Java has two kinds of value and tries very hard to let you forget it. There are primitives — int, double, boolean, char and the rest — which are raw values with no identity, no methods and no null. And there are objects, which have all three. Integer is an object that contains an int, and since Java 5 the compiler has quietly converted between them wherever it needs to.

That conversion is called autoboxing, and it is the most successful piece of syntactic sugar in the language. It is also the source of a specific family of bugs that are completely invisible in the source code, because the defect is in the conversion the compiler inserted rather than in anything you wrote.

This chapter is the catalogue. Every result in it is measured in the blocks, and the blocks run the same here as they do on any JVM — unlike the last two chapters, nothing in this one has to be modelled. By the end you will be able to answer the question that opens more Java interviews than any other, and answer it with a reason rather than a rule.

The big question

If Integer a = 127, b = 127 makes a == b true, and changing both to 128 makes it false, what exactly is == comparing — and when did your code decide which?

The cache that makes == lie

Start with the famous one, because its explanation unlocks most of the rest.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

a == b is true up to 127 and false from 128. The boundary is not arbitrary and it is not a bug.

Autoboxing does not compile to a constructor call. Integer a = 127 compiles to Integer.valueOf(127), and valueOf consults a cache of pre-made Integer objects covering −128 to 127. Inside that range you get the same object every time, so == — which compares references — finds two references to one object and says true. Outside it, valueOf makes a new object, and == correctly reports that two distinct objects are not the same object.

The cache exists for a good reason. Small integers are overwhelmingly the common case — loop counters, sizes, status codes, map values — and allocating a fresh object for every 0 and 1 in a program would be waste on a grand scale. Byte, Short, Integer and Long all cache −128 to 127; Character caches 0 to 127; Boolean has exactly two instances and always returns one of them. Float and Double cache nothing, because the set of small floating-point values is not small.

The last line of that block is the one that makes it concrete. An autoboxed 127 and an explicit Integer.valueOf(127) are the same object, because autoboxing is a call to valueOf — there is no separate mechanism, and the compiler is not doing anything you could not have written yourself.

The lesson is the one every Java style guide states and few explain: == on boxed types compares identity, and identity is an implementation detail you do not control. Use equals. The upper bound of the cache is even configurable at startup with -XX:AutoBoxCacheMax, so code that depends on 127 behaving differently from 128 can have its behaviour changed by a JVM flag it never heard of.

An analogy

A library keeps a single reference copy of each of the hundred most-requested books, and prints a fresh copy of anything rarer on demand. Ask twice for a popular title and you are handed the same physical object both times; ask twice for an obscure one and you get two. If your test for "is this the same book?" is "is this the same physical object?", you will conclude that popular books are the same book and unpopular ones are not — which says nothing about the books and everything about the library's stocking policy. equals asks about the text; == asks about the object.

One asymmetry worth memorising

There is a wrinkle that makes the previous rule less simple than it sounds, and it catches people who have learned "never use == on boxes" as a slogan.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

boxed == other is false and boxed == primitive is true, for the same number. When both operands are references, == compares references. When one is a primitive, the language unboxes the other and compares values. So the slogan should be "== on boxes compares identity when both sides are boxes" — and the version people remember produces confusion the first time a comparison against a primitive works perfectly.

The map result is the other half. Integer.valueOf(1) and Long.valueOf(1) are not equal, because Integer.equals returns false for anything that is not an Integer. A Map<Object, ...> keyed by mixed numeric types will hold both and find neither when you look with the wrong one — a real bug in code that reads JSON, where a parser's choice between Integer and Long for the same number depends on its magnitude.

A little history

Java shipped in 1996 with primitives and objects as separate worlds, and that was a performance decision rather than an oversight: an int in a field is four bytes, while an object is a header plus a reference plus the four bytes, and in 1996 that mattered enormously. The cost was that generics, when they arrived in Java 5, could not hold primitives — hence List<Integer> and never List<int>. Autoboxing shipped in the same release to make that bearable, and it worked so well that most Java programmers have never had to think about the seam. Project Valhalla has been trying since 2014 to close the gap properly with value types that have no identity at all, which would make == on them meaningless rather than misleading. That it is still in progress after a decade tells you how deep the division runs.

Integers that wrap without telling you

Primitives have a fixed width, and arithmetic that exceeds it does not fail. It wraps, silently, and keeps going.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Three results, three different lessons.

Overflow wraps. Integer.MAX_VALUE + 1 is Integer.MIN_VALUE, because two's complement arithmetic is modular and the hardware does not care that you have run out of range. No exception, no flag, no log line. Math.addExact and its relatives exist precisely to turn that silence into an ArithmeticException, and they are the right tool anywhere a wrapped value would be a correctness problem rather than an inconvenience.

Math.abs(Integer.MIN_VALUE) is negative. There are 2,147,483,648 negative ints and only 2,147,483,647 positive ones, so the most negative value has no positive counterpart and abs returns it unchanged. Any code that computes an absolute value and assumes the result is non-negative — a hash reduced to a bucket index, say — has a bug that will fire for exactly one input.

The expression's type is decided before the assignment. long wrong = 1_000_000 * 1_000_000 multiplies two ints, overflows, and then widens the already-broken result. The variable's type has no influence on how the expression is evaluated. One L on one literal fixes it, which is why that suffix appears in so much time-and-size arithmetic.

Floating point is not a near-miss decimal

Then there are the doubles, which behave exactly as IEEE 754 says and nothing like the decimals people expect.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

The first result is the one everybody has seen. 0.1 in binary is a repeating fraction, exactly as a third is in decimal, so storing it in 64 bits rounds it — and two rounded values added together give a third rounding. Comparing floating-point results with == is therefore meaningless unless you know the values are exact, which for anything derived from arithmetic you generally do not.

The next two are the ones that produce genuinely strange bugs, because == and equals disagree. NaN == NaN is false, by IEEE 754 — a not-a-number is not equal to anything, including itself. But Double.valueOf(NaN).equals(Double.valueOf(NaN)) is true, because equals is specified to compare the bit patterns so that collections behave sensibly. The mirror image happens with signed zero: -0.0 == 0.0 is true, while Double.valueOf(-0.0).equals(0.0) is false.

That asymmetry is not a defect; it is two different contracts meeting. Arithmetic comparison follows the floating-point standard. equals and compareTo follow the collections contract, which requires that an object be equal to itself and that ordering be total. The practical consequence is concrete: a NaN stored in a HashSet can be found again, while a NaN compared with == can never be matched — so the only correct test is Double.isNaN.

And unlike integer division, floating-point division by zero does not throw. It produces Infinity, -Infinity or NaN, and the computation continues, carrying the poison forward until something eventually renders it to a user. That asymmetry is deliberate: integer division has no value to return for 1 / 0, while floating point has a defined one, and IEEE 754 chose to keep computing rather than stop. It is the right choice for numerical work and a trap for business logic, where a NaN propagates silently through every subsequent operation and surfaces as a blank field three screens away from the division that caused it.

The practical guidance is short. Never hold money in a double. Use BigDecimal, which stores an exact unscaled value and a scale and will not round behind your back, or hold integer minor units — pence, kobo, cents — and divide only at the point of display. The usual objection is performance, and it is almost always misplaced: a payment service does a handful of arithmetic operations per request and many thousands of other things, so exactness is free in any sense that matters. Reserve double for measurements and the things built on them — physics, statistics, graphics, machine learning — where the inputs are approximate to begin with and the rounding is smaller than the measurement error.

Where you must compare computed doubles, compare with a tolerance rather than ==, and choose the tolerance relative to the magnitude of the values rather than as a fixed constant, since the gap between representable doubles grows with their size.

From my notebook

I went into this chapter certain of a piece of folklore: that the conditional operator mixing Integer and int unboxes unconditionally, so false ? someNullInteger : 0 throws. I wrote it as fact, then measured it, and it returns 0. The expression's type is int, which is the part the folklore gets right, but the untaken branch is never evaluated, so a null in it is harmless. The NPE happens only when the null branch is the one selected. I had conflated "the compiler inserts an unboxing conversion here" with "the unboxing runs", which are different claims — and the difference is exactly the kind of thing that makes a rule you half-remember worse than no rule at all.

Conversions you did not write

The last group are the ones with no visible syntax at all. The compiler inserts a conversion, and the conversion is where the failure lives.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Four traps, and the common thread is that the dangerous construct is shorter than the safe one.

Unboxing a null. int x = map.get(key) compiles to map.get(key).intValue(), so a missing key is an NPE — thrown on a line with no visible dereference. Assigning to Integer instead is fine. This is the single most common autoboxing bug in production Java, and getOrDefault exists largely because of it.

List.remove. remove(int) removes by index and remove(Object) removes by value, and for a List<Integer> both are applicable. Java resolves overloads without boxing first, so remove(1) takes the index overload. The fix is to be explicit with Integer.valueOf(…), and the general lesson is that overloading on a primitive and its wrapper is a design mistake the library itself has apologised for.

Narrowing casts truncate toward zero. (int) -3.9 is −3, not −4. A cast is not Math.floor, and (byte) 300 is 44 because it keeps the low eight bits.

Compound assignment contains an invisible cast. b = b + 300 does not compile, because b + 300 is an int. But b += 300 compiles fine, because the specification defines it as b = (byte)(b + 300) — with the narrowing cast supplied for you. The shorthand is not shorthand; it is a different operation with different safety.

char is not a character

The last section of that block deserves its own treatment, because char is the primitive whose name is a lie.

A char is sixteen bits — a UTF-16 code unit, not a character. When Java was designed, Unicode fitted in sixteen bits and the two were the same thing. Unicode outgrew that in 1996, and the fix was surrogate pairs: characters above U+FFFF are stored as two chars. So "a😀b" has a length() of 4 and a codePointCount of 3, charAt(1) returns 55357 — half of an emoji, meaningless alone — and substring(0, 2) cuts the pair down the middle and produces a broken string.

This is why length() is not the number of characters, why iterating a string with charAt is wrong for any text you did not write yourself, and why codePoints() exists. It is the same lesson Volume 21 reached through PHP's byte strings, arrived at from the opposite direction: PHP gives you bytes and makes the problem obvious, while Java gives you something that looks like characters and is not.

One detail worth measuring rather than assuming, because the folklore is wrong: new StringBuilder("a😀b").reverse() gives b😀a, not a broken string. reverse is explicitly surrogate-aware and keeps pairs intact. It still breaks combining characters and emoji built from several code points — a flag, or a family — so the general rule holds, but the specific accusation usually levelled at reverse is unfounded.

Note also the arithmetic. 'a' + 'b' is 195, an int, because any arithmetic on char promotes to int first. A char is a number with a presentation layer, and the moment you do maths on it the presentation is gone.

In the real world

These are not academic. Money held as double is the classic production disaster — accumulate enough rounded additions and the ledger disagrees with itself, which is why financial code uses BigDecimal or integer minor units. IDs parsed from JSON arrive as Integer below a threshold and Long above it, so a map keyed by the boxed value stops finding entries once traffic grows. A hash reduced with Math.abs(h) % buckets throws ArrayIndexOutOfBoundsException for the one input that hashes to Integer.MIN_VALUE. And a timestamp computed as days * 24 * 60 * 60 * 1000 overflows int at about 24 days, which is a bug that passes every test anyone thought to write.

Common Mistakes

  1. Comparing boxed values with ==. It compares identity, which is true below 128 because of a cache and false above it. Use equals, and note that the cache's upper bound is adjustable with a JVM flag.

  2. Believing == on a box always compares identity. With a primitive on one side, the box is unboxed and values are compared. The rule depends on both operand types, not just one.

  3. Mixing boxed numeric types as map keys. Integer.valueOf(1) and Long.valueOf(1) are not equal, so a map can hold both and find neither when you look with the other.

  4. Assuming integer overflow will be caught. It wraps silently. Math.addExact and friends throw instead, and they are the right choice wherever a wrong number is worse than an exception.

  5. Trusting Math.abs to return a non-negative number. Math.abs(Integer.MIN_VALUE) is negative, because two's complement has one more negative value than positive. Use Math.floorMod for bucket indexes.

  6. Letting an expression overflow before it is widened. long x = bigInt * bigInt evaluates in int and then widens the broken result. The target type never reaches back into the expression.

  7. Comparing doubles with ==. Almost no derived floating-point value is exactly what you expect. Compare with a tolerance, or use BigDecimal where exactness is required.

  8. Testing for NaN with ==. NaN == NaN is false by the floating-point standard while Double.equals is true by the collections contract. Only Double.isNaN is correct.

  9. Unboxing a possibly-absent map value. int x = map.get(k) throws a NullPointerException on a line containing no visible dereference. Assign to Integer, or use getOrDefault.

  10. Calling remove(int) when you meant remove(Object). On a List<Integer> the primitive overload wins and removes by index. Wrap the argument in Integer.valueOf to say what you mean.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
What is the difference between int and Integer?
Model answer — int is a primitive: a raw 32-bit value with no identity, no methods and no null. Integer is an object wrapping one, so it has identity, can be null, and can go in collections and generics — which is the main reason it exists, since generics cannot hold primitives. Since Java 5 the compiler converts between them automatically, so you rarely write the conversion. The two practical consequences are that an Integer can be null and will throw a NullPointerException when unboxed, and that == on two Integers compares object identity rather than numeric value.
Intermediate
Why is Integer a = 127, b = 127; a == b true, while the same code with 128 is false?
Model answer — Autoboxing compiles to Integer.valueOf, which returns cached instances for −128 to 127 and creates a new object outside that range. So at 127 both variables reference the same cached object and == is true; at 128 they are two objects and it is false. The cache exists because small integers dominate real programs and allocating one per loop counter would be wasteful. The real lesson is that == is asking about identity, which is an implementation detail — the upper bound is even configurable with -XX:AutoBoxCacheMax, so code that depends on this can change behaviour under a JVM flag. Always equals for value comparison. One wrinkle worth knowing: if one side is an int rather than an Integer, the box is unboxed and the comparison is numeric, so it works regardless of the cache.
Advanced
Where does floating-point behaviour in Java violate the contracts that collections rely on, and how is that resolved?
Model answer — Two places, and they pull in opposite directions. IEEE 754 says NaN is not equal to anything including itself, so NaN == NaN is false — but a collections contract requires an object to equal itself, or you could put something in a HashSet and never find it again. So Double.equals is specified to compare bit patterns instead, making Double.valueOf(NaN).equals(Double.valueOf(NaN)) true. The mirror case is signed zero: -0.0 == 0.0 is true arithmetically, but equals and compareTo treat them as distinct so that ordering is total and consistent with equality. The resolution is that Java deliberately runs two contracts — the numeric one for operators, the collections one for equals, hashCode and compareTo — and they disagree on exactly these two values. It matters in practice when sorting or deduplicating data that can contain NaN, and the only correct arithmetic test for it is Double.isNaN.
Company lens — how the same topic gets asked
micro1Direct: the 127-versus-128 question, why you use equals, and where an autoboxing NullPointerException comes from.
GoogleSpecification-level — what the conditional operator's type is, when a conversion is inserted, and why equals and == disagree on NaN.
MetaAPI design: why overloading on a primitive and its wrapper is a trap, using List.remove as the worked example.
AmazonCorrectness under scale — overflow in duration and size arithmetic, money in floating point, and hash bucketing that breaks on one input.

Hands-on

Hands-on

Model the rules you have just measured. Implement sameObject, predicting reference identity for boxed values; comparisonKind, deciding whether == compares references or numbers; overflows, judging when integer arithmetic wraps; nanSays, resolving the two contracts that disagree; and conversionRisk, naming the hazard in an implicit conversion. A stub scores zero, so a passing run means the model is real rather than lucky.

Java — Try it Yourself
Loading editor…
Output
Click Run to compile and execute the code.

Summary

  • Autoboxing compiles to Integer.valueOf, which returns cached objects for −128 to 127, so == on boxed values is true below the boundary and false above it for reasons that have nothing to do with the numbers.
  • == compares identity between two references and values when one side is a primitive, so the familiar rule depends on both operand types rather than one.
  • Byte, Short, Integer and Long cache −128 to 127; Character caches 0 to 127; Boolean has two instances; Float and Double cache nothing.
  • Wrapper equals checks type as well as value, so Integer.valueOf(1) and Long.valueOf(1) are unequal and a mixed-type map finds neither.
  • Integer overflow wraps silently, and Math.addExact is how you ask to be told instead.
  • Math.abs(Integer.MIN_VALUE) is negative, because two's complement has one more negative value than positive — use Math.floorMod for bucket indexes.
  • An expression's type is fixed before assignment, so long x = intA * intB overflows in int and then widens the wrong answer.
  • NaN == NaN is false while Double.equals on two NaNs is true, because operators follow IEEE 754 and equals follows the collections contract. Only Double.isNaN is a correct test.
  • int x = map.get(absentKey) throws a NullPointerException on a line with no visible dereference, which is the most common autoboxing bug in production.
  • Compound assignment contains an invisible narrowing cast, so b += 300 compiles where b = b + 300 does not — different operations, not shorthand.

Boxing is about the gap between a value and an object. The next chapter is about what it means for two objects to be the same one — equals and hashCode, the contract between them, and what breaks in a HashMap when you honour one and forget the other.

Editorial reflection

What makes this material worth a whole chapter is that none of it is difficult. Every rule here fits in a sentence, and an engineer who has read them once can recite them. They still cause bugs constantly, and I think the reason is that they are invisible at the point of failure: the defect is in a conversion the compiler inserted, on a line that looks like an assignment. You cannot review what is not written. That is a general property of convenience features and worth holding on to as you meet more of them — autoboxing, varargs, string concatenation, enhanced for, lambdas. Each removes ceremony that was protecting you from something, and the trade is almost always worth making. But the thing it removed is still there, doing its job or failing at it, just slightly out of sight.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.