Modern Java in Production
ID
Lesson 3 of 9 · 15m

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 object
a.equals(c); // true

Literals 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 change
order.items().add(new Item()); // ...but this might well work

List.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 cache
Integer p = 128, q = 128;
p == q; // false — outside the cache, two objects
p.equals(q); // true

The 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 exception

Math.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:

  1. Override both or neither. Overriding equals alone means two equal objects land in different buckets and neither can find the other.
  2. 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 name
void replace(User u) { u = new User("new"); } // caller sees nothing

final 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.