🔒 Section 13 · Question #8

Is It Enough to Make All Fields final for Immutability

The final modifier on a field guarantees only one invariant: the reference pointer cannot be reassigned to another object after construction. However, final provides zero protec...


🟢 Junior Level

30-Second Summary

No, absolutely not! Believing that final fields alone make a class immutable is one of the most common pitfalls in Java interviews.

The final modifier on a field guarantees only one invariant: the reference pointer cannot be reassigned to another object after construction. However, final provides zero protection against mutations to the internal state of the referenced object:

  • If a final field references a mutable object (e.g., ArrayList, HashMap, int[], or java.util.Date), the internal contents of that object can still be mutated via its methods.
  • If the class itself is not declared final, subclasses can override getters or add mutable state.
  • If the this reference escapes during construction, other threads can observe uninitialized fields.

Practical Demonstration of the Vulnerability

// Appears immutable at first glance, but is completely MUTABLE!
public final class VulnerableUser {
    private final String name;
    private final List<String> roles; // final reference!

    public VulnerableUser(String name, List<String> roles) {
        this.name = name;
        this.roles = roles; // VULNERABILITY 1: Retains direct reference to caller's list
    }

    public String getName() { return name; }

    public List<String> getRoles() {
        return roles; // VULNERABILITY 2: Leaks direct reference to internal list
    }
}

How External Code Breaks Immutability:

List<String> myRoles = new ArrayList<>();
myRoles.add("USER");

VulnerableUser user = new VulnerableUser("Alice", myRoles);

// Exploitation 1: Mutating state via the original caller's list
myRoles.add("ADMIN"); 
System.out.println(user.getRoles()); // [USER, ADMIN] — State was modified from the outside!

// Exploitation 2: Mutating state via the exposed getter
user.getRoles().clear();
System.out.println(user.getRoles()); // [] — Internal state was wiped out!

🟡 Middle Level

Shallow Immutability vs. Deep Immutability

  1. Shallow Immutability: All object fields are final, but the objects they point to can still be modified (e.g., record User(String name, List<String> roles)).
  2. Deep Immutability: The entire transitive object graph is 100% frozen and unmodifiable from root to leaf nodes.
Shallow Immutability:
[User Object] (final ref) ───► [ArrayList] (MUTABLE: size, elementData[])

Deep Immutability:
[User Object] (final ref) ───► [ImmutableCollections.ListN] (unmodifiable) ───► ["ADMIN"] (String, immutable)

When final Fields ARE Genuinely Sufficient

Making all fields private final in a final class is sufficient only when every field belongs to one of the following categories:

  1. Primitive types: int, long, boolean, double, etc.
  2. Inherently immutable standard library types:
    • Strings: java.lang.String
    • Primitive wrappers: Integer, Long, Double, etc.
    • Arbitrary-precision numbers: BigDecimal, BigInteger
    • Modern Date and Time (Java 8+): LocalDate, LocalTime, Instant, Duration
    • Identifiers: UUID
    • Other deeply immutable custom domain classes

When final Fields are CATEGORICALLY Insufficient

Defensive copying and encapsulation wrappers are mandatory if fields contain:

  • Arrays: Any array in Java (int[], String[], Object[]) is always mutable.
  • Collections: List, Set, Map (even if the reference is final, mutating methods like add(), remove(), and clear() alter the collection).
  • Legacy Date/Time types: java.util.Date, java.util.Calendar (contain mutating methods like setTime()).
  • Mutable domain entities: Any class with setters or mutable internal state.

🔴 Senior Level

The Universal Formula for True Immutability

True immutability is an architectural property of the entire object graph and its construction pipeline:

\[\text{True Immutability} = \text{final Class} + \text{private final Fields} + \text{Defensive Copies (In/Out)} + \text{No This-Escape}\]

Each component of this formula seals a specific vulnerability vector:

  1. final Class: Blocks polymorphic hijacking and getter overriding.
  2. private final Fields: Prevents external reassignment and activates the JMM §17.5 Freeze Action.
  3. Defensive Copy: Isolates internal state from external tampering via parameters or getters.
  4. No This-Escape: Guarantees no thread can observe the object before the Freeze Action completes.

JMM §17.5: Transitivity Boundaries of the Freeze Action

A subtle nuance of the Java Memory Model often overlooked in concurrent systems:

  • The Freeze Action at constructor exit freezes only the reference field itself.
  • The JMM does NOT transitively freeze the internal fields of an object referenced by a final field if that object was instantiated outside the constructor!
  • If an existing mutable ArrayList is passed into a constructor and assigned to a final field:
    • The pointer to the ArrayList is published safely.
    • However, the elements inside that ArrayList may still be mutated concurrently without synchronization, creating a classical Data Race under JMM rules.
  • Only objects constructed inside the constructor prior to the Freeze Action receive safe publication guarantees. This makes defensive copying (List.copyOf()) essential for multi-threaded safety.

Observational (Logical) vs. Bitwise Immutability

In high-performance systems, engineers distinguish between:

  • Bitwise Immutability: Not a single bit in the object’s heap memory changes after constructor completion.
  • Observational (Logical) Immutability: To all external observers, the object’s public state and behavior never change, even if internal non-final cache fields mutate behind the scenes.

java.lang.String is the quintessential example of observational immutability:

public final class String {
    private final byte[] value; // Bitwise immutable
    private int hash;           // NOT final! Mutates lazily on first hashCode() call
}

String is not bitwise immutable, but it remains strictly observationally immutable and fully thread-safe.


🎯 Interview Cheat Sheet

Comparison: Only final Fields vs. Truly Immutable Class

| Evaluated Aspect | Only final Fields | Truly Immutable Class | | :— | :— | :— | | Pointer Reassignment | Prevented by compiler | Prevented by compiler | | Field Content Mutation | ❌ Possible (collections, arrays) | ✅ Prevented (defensive copies) | | Subclassing Protection | ❌ None (if class is not final) | ✅ Sealed (final class) | | JMM Safe Publication | Partial (only the reference pointer) | Complete transitive safety | | Mutation via Getters | ❌ Exposed if mutable reference returned | ✅ Returns unmodifiable views or clones |


4 Tricky Interview Questions

1. Does the JMM §17.5 Freeze Action transitively protect objects reachable through a final field?

Answer: JMM §17.5 guarantees safe publication transitively only if the referenced objects were created and initialized within the constructor of the enclosing object before the Freeze Action executes. If an externally created mutable object (like a caller-provided ArrayList) is simply assigned to a final field, its internal state is not protected by the Freeze Action. Concurrent modifications to that list by other threads will cause data races. Defensive copying inside the constructor is required.

2. Is a Java record deeply immutable out-of-the-box if one of its components is a byte[] array?

Answer: No. Java records provide only shallow immutability. The compiler marks the class final and component fields private final. For a byte[] component, the canonical constructor assigns the array reference directly, and the accessor method returns that exact array reference. Callers can execute recordInstance.data()[0] = 0xFF; to mutate state. Deep immutability requires manually overriding the compact constructor and the accessor to clone the array via .clone().

3. If a class is final with private final fields, but the getter returns Collections.unmodifiableList(this.internalList), can the object’s state still mutate?

Answer: Yes, if this.internalList was assigned directly from a constructor parameter without a defensive copy. Collections.unmodifiableList() is a live view, not an independent snapshot. While callers cannot mutate the list via the getter, the creator of the original list can still call originalList.add("mutate"), and that modification will immediately reflect inside the “immutable” object and its unmodifiable view.

4. Why is clone() dangerous for making defensive copies of general objects?

Answer: Under Effective Java Item 13, clone() is vulnerable to subclass hijacking:

  • If a class is non-final, a caller can pass a malicious subclass instance that overrides clone() to return a shared reference or store a reference in a static registry.
  • Object.clone() performs only shallow copying.
  • Rule: clone() should only be used for defensive copying of arrays (array.clone()), because arrays are final and cannot be subclassed. For all other classes, use copy constructors or static copy methods.

Red Flags (DO NOT Say)

  • ❌ “Making all fields final is sufficient to make any class immutable.” (A disqualifying answer; ignores mutable collections, arrays, and subclasses).
  • ❌ “Java records are always deeply immutable.” (Records only provide shallow finality; mutable components remain mutable).
  • ❌ “Using Collections.unmodifiableList() in the getter makes up for omitting a defensive copy in the constructor.” (It is only a view; mutations to the original list will change the internal state).
  • ❌ “Making an array field final prevents modifications to its elements.” (final only locks the array reference; elements can be modified freely).