Values, identity and equality
Why == on Strings betrays you, what int and Integer really differ by, the HashMap key contract, and the immutability that is only skin deep.
Most puzzling Java behaviour comes from one distinction: a variable holds either a
value or a reference, and == always compares what the variable holds.
== on objects compares references
String a = "hello";String b = "hello";a == b; // true — both point at the same interned literal
String c = new StringBuilder("hel").append("lo").toString();a == c; // false — same characters, different objecta.equals(c); // trueLiterals are interned into a shared pool at class load, which is why the first
comparison surprises people into thinking == works on strings. Anything built
at runtime is a new object. Use equals for text, always; == for text is a bug
that passes its own test because the test used literals.
Immutable means the object, not what it points at
String is immutable: no method changes it, every “modification” returns a new
one. That is why it is safe to share across threads and safe as a map key.
The same word gets used loosely elsewhere and is only skin deep:
List<String> scopes = List.of("read", "write");scopes.add("admin"); // UnsupportedOperationException
record Order(List<Item> items) {} // the reference cannot changeorder.items().add(new Item()); // ...but this might well workList.of gives you a genuinely unmodifiable list — and it also rejects null
elements, which Arrays.asList does not. Collections.unmodifiableList(x) is a
view: mutate x and the “unmodifiable” list changes under you. List.copyOf
copies first, which is the one you want in a constructor.
int and Integer differ in more than syntax
int is a primitive: a value, never null, compared by ==. Integer is an
object: nullable, boxed on the heap, and compared by == as a reference.
Integer x = 127, y = 127;x == y; // true — small values come from a cacheInteger p = 128, q = 128;p == q; // false — outside the cache, two objectsp.equals(q); // trueThe cache covers −128..127 by default. This is why == on boxed integers is a
bug that works in testing with small numbers and fails in production with real
ones. And an Integer that is null will throw NullPointerException the moment
it is unboxed into an int — including in arithmetic and in a switch.
Overflow is silent, not an error:
int max = Integer.MAX_VALUE;max + 1; // -2147483648, wrapped around, no exceptionMath.addExact throws instead, which is what you want for anything counting
money.
The HashMap key contract
To work as a key, a type must implement equals and hashCode consistently:
equal objects must return equal hash codes. Break it in either direction and
lookups fail silently rather than loudly.
Two rules that follow, and the second one bites:
- Override both or neither. Overriding
equalsalone means two equal objects land in different buckets and neither can find the other. - Keys must be effectively immutable in the fields that make up the hash.
Mutate one after insertion and the entry is stranded — present in the map,
unreachable by
get, still visible when you iterate. Records make this easy to get right, as long as you copy collection components in the constructor.
Pass-by-value, including references
Java passes everything by value. For an object, the value passed is the reference — so the method can mutate the object, and cannot change which object the caller’s variable points at.
void rename(User u) { u.setName("new"); } // caller sees the new namevoid replace(User u) { u = new User("new"); } // caller sees nothingfinal on a field says the reference cannot be reassigned. It says nothing about
whether the object it points at can change — final List<String> tags can still
be added to all day.
Where this bites in practice, with the JPA and Jackson boundaries: Modern Java Production Patterns.
Discussion
Loading comments…