Immutability

An immutable object's state can never change after construction — no setters, no mutation through any exposed reference — which makes it trivially safe to share, cache, and use as a hash key or across threads.

Immutability is a design discipline, not a language feature you turn on: a class is immutable only if every part of its implementation — fields, constructors, and any objects it exposes — is deliberately arranged so that no code, anywhere, can observe a change in its state after construction. String is Java's flagship example, and understanding exactly how it (and well-designed immutable classes generally) achieve this is essential.

What Makes an Object Immutable

An object is immutable when its observable state cannot change after the constructor finishes. This is a stronger requirement than it first sounds: it's not enough for the fields to be final — if a final field holds a reference to a mutable object (like an array or ArrayList), and that object is exposed to outside code, the immutable object's effective state can still be changed through that exposed reference.

A properly immutable class typically satisfies all of the following: all fields are private and final; the class itself is final or otherwise structured to prevent state-breaking subclasses; the constructor fully initializes all fields and validates them; no method mutates any field after construction; and any mutable object referenced by a field is defensively copied on the way in and on the way out.

final Fields Are Necessary but Not Sufficient

final on a field guarantees the field can never be reassigned to point at a different object after construction. It says nothing about whether the object it already points to can itself be mutated. A class with only final fields is not automatically immutable if any of those fields references a mutable type.

import java.util.List;
import java.util.ArrayList;

// Looks immutable (final field) but is NOT.
final class LeakyBag {
    private final List<String> items;
    LeakyBag(List<String> items) { this.items = items; } // no defensive copy!
    List<String> items() { return items; } // no defensive copy either!
}

public class Main {
    public static void main(String[] args) {
        List<String> source = new ArrayList<>();
        source.add("a");
        LeakyBag bag = new LeakyBag(source);

        source.add("b"); // mutates the bag's internal state from outside!
        bag.items().add("c"); // ALSO mutates the bag's internal state!

        System.out.println(bag.items()); // [a, b, c] -- not actually immutable
    }
}

Defensive Copying: Constructors

To close the incoming leak, the constructor should copy any mutable argument into a new object it owns exclusively, rather than storing the caller's reference directly. Any later mutation the caller makes to their original object then has no effect on the immutable object.

final class Bag {
    private final List<String> items;
    Bag(List<String> items) {
        this.items = new ArrayList<>(items); // defensive copy on the way in
    }
}

Defensive Copying: Accessors

To close the outgoing leak, an accessor that returns a mutable field must not hand out the field itself — it should return either a copy, or (more efficiently) an unmodifiable view that throws if a caller tries to mutate it.

final class Bag {
    private final List<String> items;
    Bag(List<String> items) {
        this.items = new ArrayList<>(items);
    }
    List<String> items() {
        return List.copyOf(items); // immutable snapshot, safe to hand out
        // or: return Collections.unmodifiableList(items);
    }
}

No Mutating Methods

An immutable class simply does not provide setters or any method that changes a field after construction. Any operation that looks like it "modifies" the object — think String's replace(), substring(), or toUpperCase() — must instead construct and return a brand-new object representing the result, leaving the original untouched.

String as the Canonical Example

String is immutable: once created, its character content never changes. Every method that appears to "modify" a String — concat(), replace(), substring(), toUpperCase(), trim() — returns a new String object; the original is untouched.

Because concatenation and similar operations always produce new objects, don't overclaim exactly when/whether the JVM reuses storage internally (e.g., string-pool/interning details) -- that's an implementation detail. The language-level guarantee is only that the original String's observable content never changes.

String original = "hello";
String upper = original.toUpperCase();

System.out.println(original); // "hello" -- unchanged
System.out.println(upper);    // "HELLO" -- a new String object

Constructor Validation

Because an immutable object's state can never be corrected after the fact, the constructor is the single point where all invariants must be checked and enforced. If invalid data slips past the constructor, it is permanently baked into every instance.

final class Range {
    private final int min, max;
    Range(int min, int max) {
        if (min > max) {
            throw new IllegalArgumentException("min must not exceed max");
        }
        this.min = min;
        this.max = max;
    }
}

Immutable vs. Unmodifiable — An Important Distinction

"Unmodifiable" and "immutable" are related but not identical. Collections.unmodifiableList(list) returns a view that itself throws UnsupportedOperationException on mutation attempts — but if the underlying list is still mutated through its original reference, the unmodifiable view reflects that change too, because it is only a wrapper, not an independent copy. A genuinely immutable collection, such as one produced by List.copyOf(...) or List.of(...), holds its own data and cannot be affected by any outside mutation at all.

List<String> mutable = new ArrayList<>(List.of("a", "b"));
List<String> view = Collections.unmodifiableList(mutable);

mutable.add("c"); // legal: mutating through the original reference
System.out.println(view); // [a, b, c] -- the "unmodifiable" view still changed!

List<String> trulyImmutable = List.copyOf(mutable);
mutable.add("d");
System.out.println(trulyImmutable); // [a, b, c] -- unaffected: it's an independent copy

Preventing Subclass-Introduced Mutability

Declaring an immutable class final (or, since Java 17, sealed with only immutable permitted subclasses) prevents a subclass from adding mutable state or overriding a method in a way that breaks the immutability guarantee callers rely on. Without this, code that receives a reference typed as the immutable class can never be fully sure the actual runtime object doesn't secretly carry mutable state introduced by a subclass.

Thread-Safety Benefits

An immutable object can be freely shared between threads with no synchronization at all, because there is no mutable state for concurrent access to corrupt — there is nothing to race on. This makes immutable objects a foundational building block for concurrent code: caching computed results, sharing configuration, and building lock-free data structures all lean heavily on immutability.

This benefit is a direct consequence of having no mutable state, not a separate mechanism — it is not "magic" thread safety; it is the simple absence of anything to race on.

Records as an Immutability Shortcut

Records (Java 16+) provide implicitly final fields and generate accessors, equals(), hashCode(), and toString() automatically — but a record is only as immutable as its components. A record holding a mutable field (like an int[] or ArrayList) still allows that referenced object to be mutated unless the record's compact constructor explicitly defensively copies it.

record Team(List<String> members) {
    // Compact constructor: defensively copy to guarantee true immutability.
    Team {
        members = List.copyOf(members);
    }
}

Practical Usage

Immutable value types are ideal for anything used as a Map key or Set element (their hash code can never change out from under the collection — see Equality & Hashing), for representing money, coordinates, dates/times (java.time's LocalDate, Instant, etc. are all immutable), and for safely publishing shared configuration or constants across threads without synchronization.

Edge Cases

Immutability is about the object's own fields, not about objects it merely references being immutable too — a truly immutable class holding a reference to a mutable object must defend that boundary explicitly, as shown above; it does not happen automatically just because the holding class is well-intentioned.

An object can be immutable while still containing references to other immutable objects that were computed lazily (e.g., a cached hash code computed once on first use) — such internal, unobservable caching does not violate immutability as long as the object's externally observable state never changes.

Best Practices

Default to immutable classes wherever feasible, especially for value-like types (money, coordinates, identifiers). Make fields private and final. Validate everything in the constructor. Defensively copy any mutable field on the way in and any mutable field returned on the way out, or return unmodifiable views/immutable snapshots. Declare the class final (or seal it) to prevent subclasses from undermining the guarantee.

Key Takeaways

An immutable object's observable state can never change after construction, by design.

final fields alone do not guarantee immutability — a final field referencing a mutable object still allows that object to change.

Defensive copying (in the constructor and in accessors) is required to protect against outside mutation of internally referenced mutable objects.

"Unmodifiable" (a wrapper view) and "immutable" (an independent, unchangeable copy) are different guarantees; only the latter is unaffected by mutation of the original source.

String is immutable: apparent mutations always produce a new String object, leaving the original unchanged.

Immutable objects require no synchronization to share safely across threads, because there is no mutable state to race on.

final class Config { private final Map<String, String> settings; Config(Map<String, String> settings) { this.settings = settings; // stores the caller's map directly } Map<String, String> settings() { return settings; } // hands it out directly } final class Config { private final Map<String, String> settings; Config(Map<String, String> settings) { this.settings = Map.copyOf(settings); // defensive copy in } Map<String, String> settings() { return settings; } // Map.copyOf is already immutable }

Storing and returning a caller-supplied mutable Map directly lets outside code mutate what looks like an immutable Config's internal state, either before or after construction. Map.copyOf() (or defensive copying) closes both leaks.

final int[] scores = {90, 85, 77}; // "final means immutable" -- WRONG: scores[0] = 0; // perfectly legal; the array's contents are still mutable

// If true immutability is needed, don't rely on final alone: final List<Integer> scores = List.of(90, 85, 77); // genuinely immutable list final on a reference variable only prevents reassigning the variable; it does not freeze the contents of the array (or any other mutable object) it points to.

List<String> view = Collections.unmodifiableList(mutableList); // Treating 'view' as if it can never change: cacheForever(view); // dangerous -- mutableList can still be changed elsewhere

List<String> snapshot = List.copyOf(mutableList); // independent, truly immutable copy cacheForever(snapshot);

An unmodifiable view still reflects changes made through the original mutable reference; only an independently-copied immutable collection is safe to cache or share long-term without worrying about outside mutation.

List the essential properties a class needs to be genuinely immutable, beyond just declaring fields final.

Private and final fields, full validation and initialization in the constructor, no mutating methods, defensive copies of any mutable field on the way into the constructor, defensive copies (or unmodifiable/immutable views) of any mutable field on the way out through accessors, and typically the class itself declared final to prevent subclasses from breaking the guarantee.

Why does a final field holding a reference to a mutable object NOT guarantee immutability?

final only prevents the field from being reassigned to a different object; it says nothing about the mutability of the object already referenced. Code holding that same reference (or that receives it from an unguarded accessor) can still call mutating methods on it.

What is the practical difference between Collections.unmodifiableList(list) and List.copyOf(list)?

unmodifiableList() returns a wrapper view over the original list: it rejects direct mutation attempts through the view, but still reflects changes made through the original list reference. List.copyOf() creates an independent copy that is unaffected by any later mutation of the source list.

Does 'apparent mutation' of a String, like str.toUpperCase(), change the original String object?

No. It returns a new String object representing the transformed content; the original String object's character content never changes.

Why are immutable objects considered safe to share across threads without synchronization?

Because their state can never change after construction, there is no mutable state for concurrent threads to race on — synchronization exists to protect mutable state from concurrent modification, and immutable objects simply have none.

Does a record automatically guarantee deep immutability of everything it holds?

No. A record's own components are final and cannot be reassigned, but if a component references a mutable object (like a List or array), that referenced object can still be mutated unless the record's compact constructor defensively copies it.

Interview-style: why must all validation for an immutable class happen in the constructor rather than later?

Because an immutable object's state can never be changed after construction, there is no later opportunity to correct or reject invalid data — if bad data isn't caught at construction time, it is permanently embedded in that instance for its entire lifetime.

Write an immutable class Interval with int start and end fields, validated so start <= end, with no setters.

final class Interval { private final int start; private final int end; Interval(int start, int end) { if (start > end) { throw new IllegalArgumentException("start must not exceed end"); } this.start = start; this.end = end; } int start() { return start; } int end() { return end; } int length() { return end - start; } }

All fields are private and final, the constructor validates the invariant start <= end and throws if violated, no method reassigns start or end, and the class is final to prevent subclasses from undermining the guarantee.

What does this print, and does Wrapper actually behave as immutable?

Prints "hi!" -- Wrapper is NOT immutable in effect, even though it's declared final with a final field. It stores the caller's StringBuilder reference directly (no defensive copy), so mutating the original StringBuilder after construction changes what Wrapper.value() reports.

Fix the Wrapper class from the previous example so it is genuinely immutable, given a StringBuilder input.

final class Wrapper { private final String value; // convert to an immutable representation immediately Wrapper(StringBuilder sb) { this.value = sb.toString(); // snapshot the content; no lingering mutable reference } String value() { return value; } }

By converting the mutable StringBuilder into an immutable String at construction time (rather than storing the StringBuilder reference itself), later mutation of the caller's original StringBuilder can no longer affect Wrapper's stored value.

What does each line print, and why do the two differ?

unmodifiable prints [1, 2, 3, 4] -- it is only a view over base, so it reflects base's mutation. copy prints [1, 2, 3] -- List.copyOf() made an independent snapshot at the time it was called, which is completely unaffected by later changes to base.

final class Wrapper {
    private final StringBuilder sb;
    Wrapper(StringBuilder sb) { this.sb = sb; }
    String value() { return sb.toString(); }
}

public class Main {
    public static void main(String[] args) {
        StringBuilder original = new StringBuilder("hi");
        Wrapper w = new Wrapper(original);
        original.append("!");
        System.out.println(w.value());
    }
}
List<Integer> base = new ArrayList<>(List.of(1, 2, 3));
List<Integer> unmodifiable = Collections.unmodifiableList(base);
List<Integer> copy = List.copyOf(base);

base.add(4);

System.out.println(unmodifiable);
System.out.println(copy);