Layers of Logic

8.2

Immutable Classes

An object that can never change is safe to share with anyone, forever, with no locks and no copies. Getting there takes four steps, and `final` is only the first one.

Core17 min read4 exercises
01

Previously on

Three separate threads arrive here at once.

In Section 6.3 you found that copying an object copies the arrows, not what they point at. In its last exercise you watched a private field get modified twice from outside the class.

In Section 6.4 you found that final freezes the variable and says nothing at all about the object at the other end.

In Section 7.1 you learned encapsulation, and were told it goes further than getters and setters.

This section is all three arriving together.

02

The problem

Here is a class that looks completely safe. Every field is private. Every field is final. There is not a single setter.

public class Squad {
    private final String name;
    private final int[] memberIds;

    public Squad(String name, int[] memberIds) {
        this.name = name;
        this.memberIds = memberIds;
    }

    public int[] getMemberIds() {
        return memberIds;
    }
}

And here is somebody changing it from outside, twice, with no reflection and no tricks.

int[] ids = {101, 102, 103};
Squad alpha = new Squad("Alpha", ids);

ids[0] = 999;                        // the caller kept the array
alpha.getMemberIds()[1] = 888;       // and the getter handed it straight back

Both of alpha’s member ids have changed. private stopped nobody. final stopped nobody.

Neither keyword ever promised to. private controls who can name the field. final controls whether the arrow can be replaced. A reference that escaped your class defeats both, and you handed it out yourself.

03

The idea

An immutable object is one whose state can never change after it is constructed.

Not hard to change. Not changed only by approved methods. Cannot change. There is no sequence of legal Java that alters it.

You already use several. String is immutable. So are Integer, Long, Double, LocalDate and BigDecimal.

Why this is worth so much

PropertyWhy it follows from immutability
Safe to sharegive it to anybodynobody can change it, so nobody can surprise you
Thread safe for freeno locks, evera race needs something that changes
Safe as a map keyHashMap will not lose itits hash can never change (Section 8.3)
Can cache its own hashcompute oncethe answer can never go stale
No defensive copies neededby the callerthere is nothing to protect against
Easy to reason aboutread the constructor, doneno later line can have changed it

That third row is the one that will matter most. Phase X is coming, and a mutable object used as a map key is a bug that loses your data silently.

04

Under the hood

Going deeper

The four steps

Making a class genuinely immutable

  1. Make the class finalOtherwise somebody extends it, adds a mutable field, and hands their subclass to code that expected your immutable one.
  2. Make every field private and finalprivate stops direct naming. final stops reassignment. Necessary, and on their own not enough.
  3. Provide no setters, and no method that changes a fieldAny method that wants to change something returns a new object instead.
  4. Defensively copy every mutable field, in AND outCopy in the constructor so the caller cannot keep a handle. Copy in the getter so the caller cannot take one. This is the step people miss.

Here is the fixed class:

public final class Squad {                       // 1. final class
    private final String name;                   // 2. private final
    private final int[] memberIds;

    public Squad(String name, int[] memberIds) {
        this.name = name;
        this.memberIds = memberIds.clone();       // 4a. copy on the way IN
    }

    public String getName() {
        return name;                              // safe: String is immutable
    }

    public int[] getMemberIds() {
        return memberIds.clone();                 // 4b. copy on the way OUT
    }

    public Squad withMember(int newId) {          // 3. change means a NEW object
        int[] longer = Arrays.copyOf(memberIds, memberIds.length + 1);
        longer[longer.length - 1] = newId;
        return new Squad(name, longer);
    }
}

Now run the attack again:

int[] ids = {101, 102, 103};
Squad alpha = new Squad("Alpha", ids);

ids[0] = 999;                        // changes the caller's array. alpha's copy is untouched.
alpha.getMemberIds()[1] = 888;       // changes a throwaway copy. alpha's array is untouched.

Both attacks now hit copies. The real array never leaves the object.

Changing an immutable object means making a new one

Squad alpha = new Squad("Alpha", new int[]{101, 102});
Squad bigger = alpha.withMember(103);       // alpha is unchanged

Notice the naming. Not addMember, which sounds like it changes something, but withMember, which reads as “give me one like this, with an extra member”. Java’s own immutable types follow the same convention: LocalDate.plusDays(), String.toUpperCase(), BigDecimal.add().

Why step one matters

Leaving off final on the class looks harmless:

public class Squad { ... }                    // not final

public class SneakySquad extends Squad {
    private int[] extra = new int[10];        // a mutable field the parent knows nothing about
    public void change() { extra[0] = 999; }
}

Now any method expecting a Squad can be handed a SneakySquad, and thanks to polymorphism from Section 7.2 it cannot tell the difference. Your guarantee is gone, and the code relying on it has no way to know.

String is final for exactly this reason.

The other route: a private constructor

If you need subclasses for internal reasons, make the constructor private and hand out static factory methods instead:

public class Squad {
    private Squad(...) { }
    public static Squad of(String name, int[] ids) { return new Squad(name, ids.clone()); }
}

Nobody outside can extend a class they cannot construct.

Records give you most of this for free

public record Point(int x, int y) { }

That single line gives you private final fields, a constructor, accessors, and correct equals, hashCode and toString. Records are immutable by design and the class is implicitly final.

But records do not solve step four:

public record Squad(String name, int[] memberIds) { }     // still leaky

An int[] is mutable, so this record hands out a reference to it. You have to add a compact constructor to defend it:

public record Squad(String name, int[] memberIds) {
    public Squad {
        memberIds = memberIds.clone();      // copy on the way in
    }
    public int[] memberIds() {
        return memberIds.clone();           // copy on the way out
    }
}

The lesson generalises: a record is shallowly immutable. It stops the fields being reassigned. It cannot stop the objects they point at from changing, because nothing in Java can do that for you.

05

What it costs

Every change creates a new object. Changing one field of a ten field immutable object means building a whole new one. In a tight loop that is real allocation pressure, and it is exactly why StringBuilder exists alongside the immutable String.

Defensive copies cost memory and time. Copying a large array on every getter call is expensive, and it happens whether or not the caller ever intended to modify anything.

Constructors get large. With no setters, everything has to arrive at once, so a ten field immutable class has a ten argument constructor. That is hard to call correctly, and it is where the builder pattern usually shows up.

Some things also genuinely change. A bank balance, a game score, a connection pool. Forcing immutability onto something naturally stateful produces awkward code. The rule is not “make everything immutable”, it is “make everything immutable that can be”.

What you get is a whole category of bug removed. No aliasing surprises, no locks, no defensive copying by callers, no stale hash codes. You can hand an immutable object to anyone, including ten threads at once, and never think about it again.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. You made every field `private final` and removed all setters. Somebody still changes your object. How?

    Show the answer

    Through a reference you handed them, in one of two places.

    The constructor. If you stored the object the caller passed in, the caller still holds it. They can change it whenever they like, and your field points at the thing they changed.

    A getter. If you returned a mutable field directly, the caller now has a reference to your internals and can modify it from outside.

    Both are the same mistake: a reference crossing the boundary of your class. final froze the arrow, and both of these change what is at the end of it, which final never promised anything about.

    The fix is a defensive copy, in both directions. Copy on the way in, copy on the way out.

  2. Why does an immutable object need no locks when several threads use it?

    Show the answer

    Because every race condition needs one thing to exist: something that changes while somebody else is looking at it. Remove the change and the race cannot happen.

    Ten threads reading the same immutable object all see identical data, because no sequence of events can make it differ. There is no window between reading and writing, because there is no writing.

    This is why String, Integer and LocalDate are immutable, and why "make it immutable" is the first advice you get for any concurrency problem. Not a workaround, but the removal of the condition the bug requires.

  3. If an immutable object cannot change, how does `name.toUpperCase()` work?

    Show the answer

    It does not change anything. It builds and returns a new String and leaves the original exactly as it was.

    So name.toUpperCase() on its own throws the result away and leaves name unchanged. You have to write name = name.toUpperCase(); for anything to happen.

    Every method on every immutable type works this way. It produces a new object and returns it, and if you do not assign the result, nothing happened.

    The cost is a new object per operation, which is why joining a thousand Strings with + is slow and why StringBuilder exists.

07

Exercises

Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.

4 exercises115 pointsabout 125 minutes

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Break It, Then Seal It

Real work·30 min·25 points

checkedex-8-2-a

Every field is private. Every field is final. The class is final. And there are still two ways to change it from outside.

Run it and watch both happen. Then name each one precisely. Not “the array leaked”, but which line handed out which reference, and to whom.

Fix both with defensive copies, then run the attack again and confirm both now hit throwaway copies.

Finish with the design question. The name field needs no copy at all. Say why, then say what would have to change if it were a StringBuilder instead of a String. That distinction is what tells you where copies are needed and where they are waste.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Seal {
    static boolean leaksThroughConstructor()
    static boolean leaksThroughGetter()
    static boolean sealedResistsConstructor()
    static boolean sealedResistsGetter()
    static boolean nameNeedsCopy()
    static boolean builderNeedsCopy()
    static boolean leaksThroughStringBuilder()
    static int copiesNeeded()
}

LeakySquad keeps the array it is given and hands it straight back. Leave it as it is. SealedSquad is the same class with both holes closed. Each reporting method tries to change the private array from outside and says whether it worked, so leaks return true and sealed returns false. BuilderSquad makes the same point with a field that is not an array.

What your program must do

  • Change a private final field from outside, through the constructor and through the getter
  • Close both holes with defensive copies and confirm neither attack works
  • Say why the name field needs no copy
  • Show a StringBuilder field leaking the same way, and say what that proves
Seal.java
import java.util.Arrays;

public class Seal {

    // Both ends are open. Leave this one exactly as it is.
    static final class LeakySquad {
        private final String name;
        private final int[] memberIds;

        LeakySquad(String name, int[] memberIds) {
            this.name = name;
            this.memberIds = memberIds;
        }

        String getName()     { return name; }
        int[] getMemberIds() { return memberIds; }
        public String toString() { return name + " " + Arrays.toString(memberIds); }
    }

    // TODO: the same class with both holes closed
    static final class SealedSquad {
        private final String name;
        private final int[] memberIds;

        SealedSquad(String name, int[] memberIds) {
            this.name = name;
            this.memberIds = memberIds;
        }

        String getName()     { return name; }
        int[] getMemberIds() { return memberIds; }
        public String toString() { return name + " " + Arrays.toString(memberIds); }
    }

    // A mutable field that is not an array. Same question.
    static final class BuilderSquad {
        private final StringBuilder notes;
        BuilderSquad(StringBuilder notes) { this.notes = notes; }
        StringBuilder getNotes() { return notes; }
    }

    // Try to change the PRIVATE array from outside. Did it work?
    static boolean leaksThroughConstructor() { return false; }  // TODO
    static boolean leaksThroughGetter()      { return false; }  // TODO

    // Now try the same two attacks on SealedSquad.
    static boolean sealedResistsConstructor() { return true; }  // TODO
    static boolean sealedResistsGetter()      { return true; }  // TODO

    // Which fields need a defensive copy, and which do not?
    static boolean nameNeedsCopy()    { return true; }   // TODO
    static boolean builderNeedsCopy() { return false; }  // TODO

    // Show the StringBuilder field leaking the same way the array did.
    static boolean leaksThroughStringBuilder() { return false; }  // TODO

    // How many copies does closing this properly take?
    static int copiesNeeded() { return 1; }  // TODO

    public static void main(String[] args) {
        // TODO: change a private final field from outside, twice, two different ways
    }
}
Hint 1
Both leaks are the same mistake in two places: a reference to the array crossed the boundary of the class. Once on the way in, once on the way out.
Hint 2
Fix with memberIds.clone() in the constructor and memberIds.clone() in the getter. After that the real array never leaves the object.
Hint 3almost the answer
String is already immutable, so sharing it is safe and copying it would waste memory for nothing. A StringBuilder is mutable, so it needs exactly what the array needed. The rule is about whether the field's type can change, not about whether it is an array.
What this is really testing

Whether you can find both leaks in a class that looks airtight. Every field is private and final, and there are still two ways in.

B

Change Means a New Object

Real work·25 min·25 points

checkedex-8-2-b

Build a small immutable value type, the way Java’s own ones are built.

Every operation returns a new object and leaves the original alone. Prove it by printing the original after every operation and confirming it never moved.

Pay attention to the method names. plus rather than add. Java’s immutable types use this convention consistently, because a method called add on something that cannot change is a lie that costs the next reader real time.

Then notice what you did not have to write. No defensive copies anywhere, because both fields are already immutable. That is the compounding benefit: every immutable class you build makes the next one simpler.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Money {
    Money plus(Money other)
    Money minus(Money other)
    Money times(int factor)
    BigDecimal amount()
    String currency()
    static boolean fieldsNeedDefensiveCopies()
}

Every operation returns a new Money and leaves both operands where they were. Adding two different currencies has no answer, so throw IllegalArgumentException rather than returning one. amount() and currency() are there so the tests can read the value without depending on how toString formats it. fieldsNeedDefensiveCopies() is your written answer to the last requirement: say whether BigDecimal and String need copying before you store them.

What your program must do

  • Implement plus, minus and times so each returns a new Money
  • Confirm the original is never changed by any of them, including when a call fails
  • Reject adding two different currencies with IllegalArgumentException
  • Answer fieldsNeedDefensiveCopies, and be able to say why
Money.java
import java.math.BigDecimal;

public final class Money {
    private final BigDecimal amount;
    private final String currency;

    public Money(BigDecimal amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public BigDecimal amount()  { return amount; }
    public String currency()    { return currency; }

    // TODO: return a new Money holding the sum. Reject a different currency.
    public Money plus(Money other) {
        return null;
    }

    // TODO: return a new Money holding the difference. Reject a different currency.
    public Money minus(Money other) {
        return null;
    }

    // TODO: return a new Money worth factor times this one.
    public Money times(int factor) {
        return null;
    }

    // TODO: answer this once you have written the three methods above.
    // Do BigDecimal and String need copying before you store them?
    public static boolean fieldsNeedDefensiveCopies() { return true; }

    @Override public String toString() { return currency + " " + amount; }

    public static void main(String[] args) {
        Money a = new Money(new BigDecimal("10.50"), "INR");
        Money b = new Money(new BigDecimal("4.25"), "INR");

        Money sum = a.plus(b);
        System.out.println("a   = " + a);       // must still be 10.50
        System.out.println("sum = " + sum);
    }
}

Sample run

It prints
a   = INR 10.50
sum = INR 14.75
Hint 1
Each method builds and returns a new Money. Nothing assigns to a field, because the fields are final and there is nowhere to assign to.
Hint 2
Check the currency first in both plus and minus, before any arithmetic. Note the naming too: plus rather than add, because add sounds like it changes the object. Java's own immutable types follow this, as in LocalDate.plusDays.
Hint 3almost the answer
No defensive copies are needed because both fields are already immutable. BigDecimal and String cannot be changed by anybody, so sharing them is safe. That is how immutability spreads usefully: one immutable class makes every class holding it simpler.
What this is really testing

Whether you can design an API for a class that cannot change. The naming matters, because a method called add on an immutable class is a lie that costs somebody an afternoon.

C

The Record Is Only Shallowly Immutable

Hard·30 min·30 points

checkedex-8-2-c

Records are described as immutable. Test that claim.

Three lines of outside code, and all three reach inside a record that the language calls immutable. Run it and watch.

Then state precisely what a record does guarantee. The answer is real and it is narrower than the word suggests, and it is exactly the same distinction as final on a reference from Section 6.4.

Fix it with a compact constructor and overridden accessors. Note that one of the three fields needs nothing at all, and be able to say why.

The point is not that records are flawed. It is that no Java feature can make a mutable field immutable for you. Shallow immutability is the most any of them can offer, and knowing that is what keeps you from trusting the word.

What to write

These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.

class Shallow {
    static boolean nameFieldNeededCopying()
    static boolean notesAccessorNeededOverriding()
}

Squad is the record already in the file. Its constructor must copy both mutable inputs, memberIds() must return a fresh array copy on every call, and notes() must return an unmodifiable list. The two boolean methods record why String needs no copy and why the copied list needs no custom accessor.

What your program must do

  • Run it and confirm all three outside changes reached the record
  • Explain what a record actually guarantees, precisely
  • Fix it with a compact constructor and overridden accessors
  • Say whether the name field needed anything, and why
Shallow.java
import java.util.*;

public class Shallow {
    record Squad(String name, int[] memberIds, List<String> notes) { }

    // TODO: did the String field need copying before it was stored?
    static boolean nameFieldNeededCopying() { return true; }

    // TODO: after List.copyOf, did the notes accessor need overriding?
    static boolean notesAccessorNeededOverriding() { return true; }

    public static void main(String[] args) {
        int[] ids = {101, 102};
        List<String> notes = new ArrayList<>(List.of("initial"));

        Squad alpha = new Squad("Alpha", ids, notes);
        System.out.println("built : " + alpha.name() + " " + Arrays.toString(alpha.memberIds()) + " " + alpha.notes());

        ids[0] = 999;
        notes.add("added from outside");
        alpha.memberIds()[1] = 888;

        System.out.println("after : " + alpha.name() + " " + Arrays.toString(alpha.memberIds()) + " " + alpha.notes());

        // TODO: fix the record so none of those three lines can affect it
    }
}
Hint 1
A record makes the fields final. That stops them being reassigned. It says nothing about the objects they point at, which is exactly the final-reference confusion from Section 6.4.
Hint 2
The compact constructor is public Squad { memberIds = memberIds.clone(); notes = List.copyOf(notes); }. No parameter list, no assignment to this. It runs before the fields are set.
Hint 3almost the answer
You also need to override the accessors, or the record will keep handing the real array back out: public int[] memberIds() { return memberIds.clone(); }. List.copyOf already returns an unmodifiable list, so that accessor needs nothing.
What this is really testing

Whether you take a language feature at its word or test it. Records are described as immutable, and there is a case where that description is not enough.

D

An Immutable Registry

Hard·40 min·35 points·The Registry

ex-8-2-d

Build the Registry as a nested immutable structure. A Registry holding Unit objects, where neither can ever change.

Start with Unit, and notice how easy it is. Every field is a primitive or a String, so no defensive copies are needed at all. That ease is not luck. It is what you bought by making the inner class immutable first.

Then Registry, which holds a list and therefore needs the full four steps.

Prove it works properly. After every withUnit call, print the original and confirm it is unchanged. Then try to modify the list a caller receives from units() and confirm it throws.

This is the shape real code wants. Immutable objects holding immutable objects, where you can hand any part of it to anyone and never think about it again.

What your program must do

  • Make both classes genuinely immutable, all four steps
  • withUnit and withoutUnit must return new Registry objects, never modify
  • Prove that the original Registry is unchanged after every operation
  • Show that a caller cannot modify the list returned by units()
ImmutableRegistry.java
import java.util.*;

public class ImmutableRegistry {
    // TODO: final class Unit    - name, id, readiness, active. Immutable.
    // TODO: final class Registry - name, List<Unit>. Immutable.
    //
    // Registry needs:
    //   withUnit(Unit u)       -> a NEW Registry with one more unit
    //   withoutUnit(int id)    -> a NEW Registry with that unit gone
    //   units()                -> callers must not be able to modify the list
    //   findById(int id)       -> Unit or null
    //   averageReadiness()

    public static void main(String[] args) {
        // build a Registry, add units with withUnit, and prove the original never changes
    }
}
Hint 1
Unit is easy: all four fields are primitives or Strings, so no defensive copies are needed anywhere. That is the compounding benefit of building Unit immutably first.
Hint 2
Registry holds a List<Unit>, which is mutable, so it needs the full treatment. List.copyOf(units) in the constructor gives you an unmodifiable copy in one call, and it can then be returned directly from the accessor.
Hint 3almost the answer
For withUnit: build a new ArrayList from the existing list, add the unit, and pass it to a new Registry constructor. The original list is never touched, so the original Registry cannot change.
What this is really testing

Whether you can build a nested immutable structure, where a class holds other immutable objects. This is the shape you actually want in real code, and it is where the compounding benefit shows.

08

After the credits

Immutability is about to be needed in four places, and one of them is a bug that eats data.

Next section. In Section 8.3 you will write hashCode(). An immutable object can compute its hash once and cache it forever, because the answer can never go stale. String does exactly this.

Phase IX. String is immutable, and that is the whole reason the String Pool can exist. If one variable could change a shared String, every other variable holding it would change too. It also finally explains the == behaviour that surprised you in Section 3.2.

Phase X, and this is the important one. Use a mutable object as a HashMap key, then change a field it uses in its hash, and the map loses the entry. Not an exception. containsKey returns false for the very object you used to store it, and the value is unreachable forever.

That single bug is why immutability is not a style preference. Every safe map key in Java is immutable, and now you know both how to build one and what happens if you do not.

Phase XIV. An immutable object is thread safe with no locks at all, because a race condition needs something that changes. This is the first thing anyone says about concurrency, and you already have the reason.

Threads you opened in this section