How does Java express immutability with final fields and unmodifiable collections, and why does functional-style code depend on it?
answer
- final field = no reassignment (shallow: reference frozen, target may mutate)
- List.of/Map.of, List.copyOf, Collections.unmodifiable* => UnsupportedOperationException
- copyOf decouples from source; unmodifiableList is a live view
- Records = generated final fields (still shallow)
- Parallel streams need stateless/non-interfering => immutability
basics
~20 sMark fields final so they can't be reassigned after construction, and use unmodifiable collections (like List.of(...) or List.copyOf(...)) so nobody can add or remove elements. Immutable data can't change under you, which makes functional code and shared/parallel use safe.
solid answer
~50 sJava expresses immutability at two levels. A final field can't be reassigned after construction, giving a shallowly immutable reference. Unmodifiable collections — List.of(...), Map.of(...), List.copyOf(...), or Collections.unmodifiableList(...) — reject add/remove/set with UnsupportedOperationException, so the structure can't be mutated through that view. Records (Java 16+) generate final fields and bake this in for data carriers. The key caveat is shallow vs deep: a final field still lets you mutate the object it points to, and an unmodifiable List wrapping a mutable list still exposes mutable elements; List.copyOf defends against aliasing the source, but element contents stay mutable unless those elements are themselves immutable. Functional code leans on immutability because pure functions must not observe changing state: lambdas capture by snapshot, streams (especially parallel) require non-interfering, stateless behavior, and immutable values are inherently thread-safe and freely shareable without locks.
code
java · 23 linesimport java.util.List;
final class Order {
private final String id; // primitive-like: fully frozen
private final List<String> items; // reference: frozen pointer, defend the contents
Order(String id, List<String> items) {
this.id = id;
this.items = List.copyOf(items); // defensive copy -> unmodifiable, decoupled from caller
}
List<String> items() { return items; } // already unmodifiable; safe to expose
}
class Demo {
void show() {
var src = new java.util.ArrayList<>(List.of("a", "b"));
var o = new Order("o1", src);
src.add("c"); // does NOT affect o.items (copyOf decoupled it)
System.out.println(o.items()); // [a, b]
// o.items().add("x"); // throws UnsupportedOperationException
}
}go deeper
Uses final fields and List.of/List.copyOf to prevent reassignment and mutation, and recognizes UnsupportedOperationException as the unmodifiable-collection signal.
Distinguishes the factory methods, copyOf, and unmodifiable views, and knows mutation attempts throw UnsupportedOperationException; uses records for data carriers.
Explains shallow vs deep immutability, applies defensive copies on constructor inputs and getters, and ties immutability to non-interfering/stateless parallel-stream requirements.
Invokes the JMM safe-publication guarantee for final fields, treats immutability as a concurrency and API-design strategy across a system, and weighs allocation/GC costs of pervasive immutable copies versus the safety it buys.
## What 'immutable' means An object is **immutable** if its observable state cannot change after it is created. Once built, every read returns the same thing forever. This matters because changing shared state is the root of most concurrency bugs and of surprising 'spooky action at a distance' where one part of a program mutates data another part is relying on. ## Tool 1 — the final keyword on fields Declaring a field `final` means it must be assigned exactly once (in the declaration or the constructor) and can **never be reassigned**: ```java final class Point { private final int x; private final int y; Point(int x, int y) { this.x = x; this.y = y; } int x() { return x; } int y() { return y; } } ``` `final` on a field freezes the **reference/value the field holds**, not necessarily the object it points to. For primitives (`int x`) that fully freezes the value. For a reference (`final List<String> tags`), it freezes *which list* the field points at — you can't reassign `tags`, but you might still call `tags.add(...)` if the list itself is mutable. This is the crucial **shallow** nature of `final`. There is also a **JMM (Java Memory Model) guarantee**: final fields set in a constructor are safely published — other threads that see a properly-constructed object are guaranteed to see the correct final-field values without extra synchronization. This is why immutable objects are thread-safe by construction. ## Tool 2 — unmodifiable collections The standard collections (`ArrayList`, `HashMap`...) are mutable. Java offers several ways to get a collection you cannot structurally change: - **Factory methods**: `List.of(a, b, c)`, `Set.of(...)`, `Map.of(k, v, ...)` (Java 9+) create compact, **immutable** collections. They reject `add`/`remove`/`set`/`put` with `UnsupportedOperationException`, and `List.of`/etc. also reject null elements. - **Defensive copies**: `List.copyOf(existing)`, `Map.copyOf(existing)` create an unmodifiable snapshot **decoupled from the source** — later changes to `existing` don't show through. - **Unmodifiable views**: `Collections.unmodifiableList(inner)` wraps an existing list in a read-only view. Beware: it is a *view*, so if someone still holds a reference to `inner` and mutates it, the changes appear through the view. `copyOf` avoids that aliasing; the view does not. All of these throw `UnsupportedOperationException` on mutation attempts — that exception is the signal you're holding an unmodifiable collection. ## Tool 3 — records A **record** (Java 16+) is a concise declaration for an immutable data carrier: `record Point(int x, int y) {}` generates `final` fields, a canonical constructor, accessors, `equals`/`hashCode`/`toString`. Records bake in shallow immutability for the common 'bag of values' case, but the same shallow caveat applies — a record field of a mutable type can still be mutated. ## Shallow vs deep immutability — the trap Two layers can still leak: 1. A `final` field holding a mutable object: `final int[] data` — you can't reassign `data`, but `data[0] = 9` is allowed. 2. An unmodifiable collection of mutable elements: `List.of(mutablePoint)` — you can't add/remove, but `mutablePoint.setX(...)` (if it existed) still changes the contained object. **Deep immutability** requires that the contained objects are themselves immutable all the way down. Achieve it by storing only primitives/immutable types, taking **defensive copies** of mutable inputs in the constructor (`this.tags = List.copyOf(tags);`), and returning immutable views/copies from getters. ## Why functional code needs this 'Functional style' favors **pure functions**: a function whose output depends only on its inputs and which causes no observable side-effects. Immutability is what makes purity practical: - **Lambda capture is by snapshot** of effectively-final locals — immutable values fit this perfectly and never surprise the closure. - **Streams require non-interference**: a pipeline must not modify the source while it runs, and **parallel streams require stateless, non-interfering lambdas** with no shared mutable state, or you get races and wrong results. Immutable inputs satisfy this automatically. - **Free sharing / thread-safety**: an immutable object can be shared across threads with no locks, copied freely, and used as a safe map key (its `hashCode` won't drift). - **Easier reasoning and equality**: value-based `equals` is meaningful only when the values don't mutate after being placed in a set/map. Levels: junior = 'use final and List.of so things can't change'; middle = the factory/copyOf/view distinctions and UnsupportedOperationException; senior = shallow-vs-deep, defensive copies, records, and why parallel streams demand it; principal = the JMM safe-publication guarantee and immutability as a system-wide concurrency strategy.
- What is the difference between Collections.unmodifiableList(x) and List.copyOf(x)?unmodifiableList returns a read-only VIEW backed by x: mutations through the view are blocked, but if anyone still holds x and mutates it, the changes show through. copyOf takes an independent snapshot, so later changes to x are invisible. Prefer copyOf for true defensive isolation.
- Why do parallel streams specifically demand immutability / statelessness?Parallel streams split the source across threads and combine results. If lambdas read or write shared mutable state, you get data races and non-deterministic results. Stateless, non-interfering operations over immutable data have no shared writable state, so they parallelize correctly.
final is like laminating a card so you can't rewrite what's printed — but if the card points to a whiteboard, someone can still erase the whiteboard (shallow). An unmodifiable collection is a display case: you can look at the items and can't add or remove any, yet if an item itself has moving parts, those parts still move. Deep immutability means the items are solid too.
saying these in an interview costs you the question
- Claiming a final field makes the referenced object fully immutable (it's shallow)
- Thinking Collections.unmodifiableList copies the list (it's a view; the backing list can still change)
- Forgetting defensive copies of mutable constructor args / getters
- Saying List.of returns a mutable list (it throws on add/remove)
- Assuming records give deep immutability