LLD Dojo

Objects that hold their shape · chapter 12 of 33

Immutability, and the bugs it deletes

Chapter 2.2 · Part 2, Objects that hold their shape · about 30 minutes

What you need before this chapter: chapter 2.1, encapsulation — private fields, a constructor that validates, and a method as the only door into a change. You also need HashSet and equals/ hashCode from Part 1.

When you finish this chapter you will be able to:


1. What chapter 2.1 already bought, and what it did not

The Ticket built in chapter 2.1 has two fields marked private final: plate and arrivedAtMinute. Once a Ticket is constructed, those two values cannot change again. Not through a setter, because there is none. Not by any other path, because final means the field is assigned exactly once. That is a stronger guarantee than encapsulation alone gives you. A private mutable field still changes; it just changes through a method you control. A private final field does not change at all.

That distinction sounds academic until an object gets used somewhere that depends on it never changing. Here is where that happens: an attendant keeps a checklist of every vehicle currently in the lot, kept as a Set<Vehicle>. That makes "is this plate on site right now" a fast lookup instead of a scan.

import java.util.Objects;

public final class Vehicle {

    private String plate;
    private final String type;

    public Vehicle(String plate, String type) {
        this.plate = plate;
        this.type = type;
    }

    public String plate() {
        return plate;
    }

    /** OCR misreads a plate sometimes, and this is how an attendant corrects it. */
    public void correctPlate(String plate) {
        this.plate = plate;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Vehicle v)) return false;
        return plate.equals(v.plate) && type.equals(v.type);
    }

    @Override
    public int hashCode() {
        return Objects.hash(plate, type);
    }
}

equals and hashCode are overridden so two Vehicle objects with the same plate and type count as the same vehicle, the way chapter 1.3 covered. For tracking who is on site right now, adding a vehicle and checking whether it is already tracked, this class does exactly what is asked of it.

2. The new requirement, and the bug it exposes

A camera reads plates automatically, and it is not perfect. Every so often it reads a 1 as an I, and an attendant needs to correct the plate on a vehicle that is already tracked. correctPlate exists for exactly this.

import java.util.HashSet;
import java.util.Set;

public class Main {
    public static void main(String[] args) {
        Set<Vehicle> onSite = new HashSet<>();
        Vehicle v = new Vehicle("KA01AB1234", "CAR");
        onSite.add(v);

        System.out.println("contains before correction: " + onSite.contains(v));

        v.correctPlate("KA01AB1235");

        System.out.println("contains after correction:  " + onSite.contains(v));
        System.out.println("set size:                   " + onSite.size());

        boolean sameReferenceStillInSet = false;
        for (Vehicle x : onSite) {
            if (x == v) sameReferenceStillInSet = true;
        }
        System.out.println("object literally still there via iteration: " + sameReferenceStillInSet);
    }
}
contains before correction: true
contains after correction:  false
set size:                   1
object literally still there via iteration: true

onSite.contains(v) reports false for an object that is, by the last line of output, still sitting inside the set. Nothing was removed. A HashSet finds an element by computing its hashCode and looking in the bucket that hash points to. v's hash was computed once, from "KA01AB1234", when it was inserted. correctPlate changed the plate to "KA01AB1235" without telling the set, so a fresh call to hashCode() now returns a different number and contains looks in the wrong bucket entirely. The vehicle has not left the lot. As far as the attendant's tool is concerned, it has vanished.

This is worse than the bug chapter 2.1 fixed. That bug produced an obviously wrong number, -55 minutes, which is at least a value a later check could catch. This one produces false for a question whose true answer is true, silently, and the object is not corrupted at all — every field it holds is legitimate. The set's internal bookkeeping is what broke, because the set trusted a hash that the object was allowed to change out from under it.

3. The move: identity that never changes

The fix is not a better method. It is removing correctPlate entirely and accepting that once a Vehicle's plate and type are set, they never change again for the life of that object.

import java.util.Objects;

public final class Vehicle {

    private final String plate;
    private final String type;

    public Vehicle(String plate, String type) {
        this.plate = Objects.requireNonNull(plate, "plate");
        this.type = Objects.requireNonNull(type, "type");
    }

    public String plate() {
        return plate;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Vehicle v)) return false;
        return plate.equals(v.plate) && type.equals(v.type);
    }

    @Override
    public int hashCode() {
        return Objects.hash(plate, type);
    }
}

Correcting a misread plate is no longer "change this vehicle." It is "this was never the right vehicle; remove the wrong one and add the right one." That is an honest way to say what a plate correction actually is: a new vehicle, not an edit to an old one.

Set<Vehicle> onSite = new HashSet<>();
Vehicle v = new Vehicle("KA01AB1234", "CAR");
onSite.add(v);

System.out.println("contains before: " + onSite.contains(v));

onSite.remove(v);
Vehicle corrected = new Vehicle("KA01AB1235", "CAR");
onSite.add(corrected);

System.out.println("contains corrected: " + onSite.contains(corrected));
System.out.println("set size: " + onSite.size());
contains before: true
contains corrected: true
set size: 1

No object's hash ever changes after it is inserted, so the set's bucket for every element it holds stays correct for that element's whole life. That is immutability, stated plainly: once a Vehicle is built, every value that makes it what it is stays fixed. Any code that computed something from those values, a hash, a cache entry, a key in a map, stays correct for as long as the object exists.

4. Where this is the wrong default

Not everything should work this way. The corpus this course draws from has a class that makes the opposite choice on purpose: CoinPurse, from corpus/vending-machine, which tracks the coins a vending machine currently holds.

public void add(Coin coin) {
    counts.merge(Objects.requireNonNull(coin, "coin"), 1, Integer::sum);
}

add mutates the purse in place and returns nothing. A vending machine's escrow purse gets a coin added every time a customer inserts one, sometimes several times a second across a bank of machines. Written the immutable way, adding a coin would have to build an entire new purse and hand it back, leaving the caller to store it.

public final class ImmutablePurse {

    private final Map<Coin, Integer> counts;

    public ImmutablePurse(Map<Coin, Integer> counts) {
        this.counts = Map.copyOf(counts);
    }

    /** Adding a coin does not change this purse. It builds a new one. */
    public ImmutablePurse add(Coin coin) {
        Map<Coin, Integer> next = new EnumMap<>(Coin.class);
        next.putAll(counts);
        next.merge(coin, 1, Integer::sum);
        return new ImmutablePurse(next);
    }

    // totalMinor() omitted here — same idea as CoinPurse's
}

This compiles, and it looks disciplined. It also creates a new failure that the mutable version never had:

ImmutablePurse escrow = new ImmutablePurse(Map.of());
escrow.add(Coin.QUARTER); // return value discarded
escrow.add(Coin.QUARTER);
System.out.println("total: " + escrow.totalMinor());
total: 0

Both calls to add build a new purse with a quarter in it and immediately throw that purse away, because nothing captured the return value. The fix is to always reassign, escrow = escrow.add(...), and doing that everywhere restores the correct total of 50. That is exactly the bug immutability was supposed to prevent, arriving from a different direction. A value silently fails to update because the reference nobody rebound is stale — the same shape of mistake as " hi ".trim(); on its own line, expecting the string itself to have changed.

CoinPurse stays mutable because nothing about it is ever used as a hash key, shared across threads without a lock, or handed out and trusted not to change. Its entire job is to be added to and removed from, over and over, for as long as a machine runs. Forcing it to be immutable would not remove a risk. It would swap a visible bug for a worse, silent one. The mutable version fails a set lookup immediately. The immutable version compiles cleanly, warns of nothing, and only shows a wrong number once a total is checked.

The threshold: make a class immutable when something depends on its value never changing after first use — a hash key, a value shared between threads, a fact worth referring back to. Keep it mutable when its whole purpose is to accumulate change, with nothing external relying on a fixed snapshot of it.

Your turn

corpus/parking-lot's real Ticket is a record, not a hand-written class:

public record Ticket(String ticketId, String spotId, java.time.Instant entryTime) {}

Chapter 2.1's Ticket had a mutable paid flag and an exitMinute, set once by closeOut. Explain, in a sentence or two, why the real corpus Ticket does not need either field, given that corpus/parking-lot/reference/src/ParkingLot.java keeps open stays in a Map<String, Stay> openStays keyed by ticketId.

The answer. Whether a ticket is paid and when it exited are not facts about the ticket itself. They are facts about the stay, and the stay is a separate, short-lived record that exists only while a vehicle is parked. Once unpark runs, the stay is removed from openStays and a Receipt is returned; the Ticket was never mutated at all; it was handed to unpark as a lookup key and nothing about it needed to change. Putting paid on Ticket the way chapter 2.1 did was a reasonable design for a chapter working with one class. The corpus solves the same problem by keeping mutation somewhere that stops existing once it is no longer needed, and giving Ticket nothing to mutate at all.

Going deeper

final on a field is not only a promise to a reader that the value will not change. It is also a promise the Java Memory Model makes to every other thread, and it is the deepest reason immutable objects matter in concurrent code, well beyond avoiding a stale hash.

Ordinarily, one thread building an object and handing a reference to a second thread gives that second thread no guarantee about what it sees. Without synchronization, the second thread could observe a half-built object: a field that the constructor set, but whose write has not yet become visible to a CPU core running a different thread. Unsynchronized sharing of ordinary mutable objects between threads is unsafe for exactly this reason, even when nothing is ever mutated after construction. The risk lives in the publication itself, not only in later writes.

final fields are the exception the language carves out on purpose. The JLS guarantees that once a constructor finishes, any thread that obtains a reference to the object afterward sees every final field fully initialized, with no synchronization required to get that guarantee. An object built entirely from final fields is therefore safe to hand to another thread with nothing more than handing over the reference. This is exactly why corpus/logger's LogRecord is a record with four fields, all final, built fresh for every call to log():

public record LogRecord(Instant timestamp, Level level, String message, String threadName) { }

A LogRecord gets built on whichever thread called log, and then handed to every registered Appender, which may run on a different thread entirely, with no lock anywhere in that path. Nothing needs to guard the handoff, because every field is final and the object cannot be observed half-built.

corpus/logger's ManualClock shows the other side of the same rule, and its own comment states the reason directly:

private volatile Instant now;

ManualClock's whole purpose is to be mutated, by advance, while many threads read it at once, so it cannot lean on the final-field guarantee at all. volatile is the substitute: it forces every read of now to see the latest write, across threads, at the cost of a memory barrier the immutable LogRecord never has to pay. Immutability and volatile are two different answers to the identical question, "what will another thread see," and a class needs exactly one of them, never neither.

Why this matters in an interview

A Set or a Map with a key whose hashCode can change is a bug an interviewer expects you to spot on sight, in your own design or in one they show you. It is one of the few Java mistakes that produces a wrong answer with no exception and no stack trace to follow. The fix they want to hear is not "add a check." The type should never have been mutable once it went into a hash-based collection — a property of the type, not a rule about how careful its callers must be.

The concurrency material in Part 4 leans on this chapter directly. Every thread-safety argument that starts with "this object is immutable, so no lock is needed" is invoking the guarantee from Going Deeper. An interviewer who hears you say it correctly is hearing you cite the actual reason, not a rule memorised without one.


Next: chapter 2.3, Composition over inheritance — where the focus shifts from "can this object's own state change" to "what should an object be made of." A vending machine built from five collaborators takes the place of one deep class hierarchy.

← 2.1 Encapsulation: deciding who can change your data · All chapters · 2.3 Composition over inheritance →