🏗️ Classes & Objects · Intermediate

Immutable classes in Java

final class, private final fields, no setters, defensive copies.

🧩 The mysteryString has never once changed behind your back. Want that superpower for your own classes? It's a four-step recipe — with one sneaky trap.

Never changes after birth

An immutable object's state can't change after construction. Any "change" builds a new object instead, just like String.toUpperCase(). Bonus: immutable objects are safe to share between threads with no locking at all.

The recipe

1. Declare the class **final. 2. Make fields private final. 3. Provide no setters. 4. Make defensive copies** of mutable data going in and out.

final class Money {
    private final long cents;
    Money(long cents) { this.cents = cents; }
    Money plus(Money o) {
        return new Money(cents + o.cents);
    }
}
🔮 Predict it

final ≠ frozen

What does this print?

final var sb = new StringBuilder("go");
sb.append("al");
System.out.println(sb);
  1. go
  2. goal
  3. Compile error
Show the answer

goal. final freezes the reference — sb can't point to another builder — but the builder object is still mutable.

⚠️ The trap

Copy on the way in

The fields are private final, the getter returns a copy... but the constructor keeps the caller's own list. The caller can still modify it later, changing your "immutable" Team. Store a copy: this.names = List.copyOf(names);

final class Team {
    private final List<String> names;
    Team(List<String> names) {
        this.names = names; // caller's list!
    }
}
🔮 Predict it

A defensive copy at work

The constructor clones the array. What prints?

final class Range {
    private final int[] v;
    Range(int[] v) { this.v = v.clone(); }
    int first() { return v[0]; }
}
void main() {
    int[] data = {5, 6};
    var r = new Range(data); data[0] = 0;
    System.out.println(r.first());
}
  1. 0
  2. 5
  3. 6
Show the answer

5. clone() gave Range its own array, so changing the caller's data afterwards doesn't affect it.

🤔 Think first

Why must the class be final?

Fields are private and final. Why also declare the class final?

Think about it, then reveal the answer

Otherwise someone could write a subclass that adds mutable state or overrides methods — and pass it anywhere your "immutable" type is expected. Making the class final (or its constructors private) closes that hole. Note: synchronized is not part of the recipe — immutable objects need no locks.

💼 In the real world

In real projects

String, Integer and all of java.time are immutable, which is why they make perfect HashMap keys and can be shared across threads freely. For plain data, Java 16 records give you private final fields, a constructor and accessors in one line.

Key takeaways

  1. Declare the class final so no subclass can add mutability
  2. Fields private final, no setters
  3. Copy mutable inputs in, and copies (or read-only views) out
  4. final on a reference doesn't freeze the object it points to
🤯 Did you know?

Records are only shallowly immutable: a record holding a List can still have that list changed from outside — unless its constructor stores List.copyOf(...).

Practice questions

Which step is NOT part of the usual recipe for an immutable class?

  1. Make every method synchronized
  2. Declare the class final
  3. Make fields private and final
  4. Provide no setter methods
Check your answer

Make every method synchronized. Immutable objects need no locking at all, since nothing can change. The other three steps are the core of the recipe.

What does this print?

final StringBuilder sb = new StringBuilder("a");
sb.append("b");
System.out.println(sb);
  1. a
  2. ab
  3. Compile error
Check your answer

ab. final stops sb from pointing to a different object, but the StringBuilder it points to is still mutable.

Next world: Inheritance & Polymorphism — one class becomes the parent of many, and a single method call can do a dozen different things.