Immutability
Why immutable objects are simpler, cheaper to share across threads, and safe as map keys — and how to build one that is actually immutable.
An immutable object cannot change after construction. That single property removes whole categories of bugs: no one can corrupt it after you validated it, no thread can see it half-updated, no map can lose it because its hash moved. The cost is that "changing" one means making a new one — and that cost is smaller than it looks, because the JVM allocates short-lived objects very cheaply. This lesson is the five rules, the difference between a copy and a view, what the JIT does and does not do with final, and the incident that every mutable cached object eventually causes.
What immutable actually requires
Marking a class final and its fields final is necessary and not sufficient. The rules:
- No setters, no mutating methods. Every operation returns a new instance.
- All fields
final, assigned in the constructor. - The class is
final(or all constructors private), so a subclass cannot add mutable state and pass as the immutable type. - Mutable components are defensively copied on the way in and never handed out.
- No
thisescape during construction — do not registerthiswith a listener or start a thread in the constructor.
Rule 4 is where most "immutable" classes fail:
public final class Itinerary {
private final List<Stop> stops;
public Itinerary(List<Stop> stops) {
this.stops = List.copyOf(stops); // copy in: the caller's list cannot change ours
}
public List<Stop> stops() {
return stops; // List.copyOf is unmodifiable; safe to return
}
public Itinerary withStop(Stop s) { // "change" = new object
List<Stop> next = new ArrayList<>(stops);
next.add(s);
return new Itinerary(next);
}
}Had the constructor stored stops directly, the class would be immutable on paper and mutable through the caller's reference. List.copyOf also rejects null elements and returns an instance whose add throws — the right behaviour for something you are promising will not change.
Stop must itself be immutable for Itinerary to be. Immutability is transitive; one mutable field anywhere in the graph breaks it.
Under the hood: copies, views, and what final buys
A copy and a view are different promises. List.copyOf(x) builds a new immutable list (and returns x itself if x is already one of the JDK's immutable lists, so repeated copying is free). Collections.unmodifiableList(x) wraps x: the wrapper throws on add, but whoever still holds x can change it, and the wrapper shows the change. A view protects the caller of your getter; a copy protects your object. Store copies; return either.
final is a compile-time promise with a runtime hole. The JVM lets reflection write a final instance field after setAccessible(true); that is how some deserialisers and mocking libraries work. The JIT therefore does not treat instance final fields as constants (it does for static final, which is why static final int LIMIT = 100 is folded into the code that uses it). Records and hidden classes are the exception: their fields are trusted finals, cannot be reflectively written, and the JIT folds them. Project Valhalla and JEP 500 ("Prepare to Make Final Mean Final", Java 26) are closing the hole for everyone, starting with a warning on reflective writes.
Allocation is the cost people fear and the collector is why they are wrong. A Money.plus(Money) allocates 24 bytes in a thread-local buffer with a pointer bump; if the result does not escape the method, escape analysis removes the allocation entirely. A million short-lived immutable objects per second is ordinary for a service. What costs is large structures rebuilt per change: a 10,000-element list copied on every append is quadratic, and the answer is a builder in a local scope, or a persistent collection (Vavr, PCollections) that shares structure between versions.
Records
public record Stop(String station, LocalTime departure) {
public Stop { // compact constructor
Objects.requireNonNull(station, "station");
Objects.requireNonNull(departure, "departure");
}
}A record is final, its components are final, and it has no setters. It is the language's built-in immutable value type. The compact constructor validates without repeating the assignments. A record with a List component still needs lines = List.copyOf(lines); in the compact constructor — records do not copy for you. Because a record's components are trusted finals and every deserialisation path (Java serialisation, Jackson) goes through the canonical constructor, a record is the one Java type whose immutability survives reflection and frameworks.
Why it matters for concurrency
An immutable object can be shared between threads with no locks and no volatile, because there is nothing to race on. The Java Memory Model guarantees that if an object is safely published — handed to another thread through a final field, a volatile, a lock, or a concurrent collection — its final fields are seen fully initialised. For an immutable object, that is all of its fields. This is why String, Integer, LocalDate, BigDecimal and every java.time type are immutable: they are passed around freely and it is never a bug.
Contrast the older java.util.Date, which is mutable. A getExpiry() returning the internal Date let any caller move the expiry with setTime. Every codebase that used it had defensive copies everywhere or a latent bug. java.time fixed it by being immutable.
Walkthrough: the cache that served one customer's discount to everyone
A pricing service cached a PricingRules object per region in a ConcurrentHashMap, loaded once an hour. PricingRules had a List<Rule> rules getter that returned the internal ArrayList. Trace the incident:
- 10:02. A request for a VIP customer calls
rules.getRules().add(vipRule)in a "temporary" tweak insideapplyCustomerOverrides, meaning to add the override for this calculation only. The list it got is the cached object's own. - 10:02 and after. Every request in that region now applies
vipRule: 30% off, for everyone, for the rest of the hour. Revenue drops; no error is logged, because nothing failed. - 10:41. Someone notices the discount in an order export. The pricing code looks correct, the cache looks correct, and the bug is a single
addin a method that ran once per request and was never meant to touch shared state. - 11:00. The hourly reload replaces the object, and the problem vanishes on its own, which makes it look like a data glitch. It returns the next time a VIP orders.
- Fix:
PricingRulesstoresList.copyOf(rules)and returns it; theaddthrowsUnsupportedOperationExceptionon the first VIP order after deploy, in the test suite if there is one, and the override becomesrules.withRule(vipRule), a new object for this request only.
Every cached object that is mutable through any reference is one careless add away from this. The cost of the copy is a few microseconds an hour.
Why it matters for collections
A HashMap locates a key by its hash. If the key mutates after insertion and its hash changes, the map looks in the wrong bucket and reports the key absent, forever, while still holding it. Immutable keys cannot do this. Use records, strings, and value types as keys; never a mutable entity.
The cost, honestly
Each "modification" allocates. For a Money.plus(Money) that is one small object, and the JVM's allocator and young-generation collector handle millions of those per second; escape analysis may remove the allocation entirely. For a 10,000-element list rebuilt on every append, it is quadratic and you should use a builder, a StringBuilder, or a persistent collection library — build mutably, publish immutably. The pattern is: mutable in a local scope you control, immutable at every boundary.
Try it yourself
Immutable or not?
public final class Team {
private final String name;
private final List<Member> members;
private final Date founded;
public Team(String n, List<Member> m, Date f) { name = n; members = Collections.unmodifiableList(m); founded = f; }
public List<Member> members() { return members; }
public Date founded() { return founded; }
}List every way this class can change after construction.
Answer
Three. The caller who passed m still holds the mutable list; the unmodifiable view shows every change they make. founded() returns the internal Date, and Date is mutable: team.founded().setTime(0) changes the team. And Member may be mutable, in which case the members change under the team. Fix: List.copyOf(m), an Instant or LocalDate instead of Date (or new Date(f.getTime()) in and out), and an immutable Member.
Copy or view?
A Report holds List<Row> rows, built once from a query, read by many threads, never changed. Should the constructor store List.copyOf(rows) or Collections.unmodifiableList(rows), and should rows() return the field or a fresh copy?
Answer
Store List.copyOf(rows): the caller's list may be an ArrayList they keep mutating, and only a copy severs that. Return the field: an immutable list is safe to hand out, and copying on every read is waste. The pairing to remember is copy-in, share-out for immutable types; copy-in, copy-out (or view-out) for types you cannot make immutable, like arrays.
Why is the record faster?
Two equivalent types: final class P { final int x, y; ... } and record P(int x, int y). A hot loop reads p.x a billion times. The record version measures faster. Why might that be?
Answer
Record components are trusted finals: the JIT may treat p.x as a constant once p is known and hoist the load out of the loop, because nothing can reflectively change it. A plain class's final field is not trusted (reflection can write it), so the JIT reloads it each iteration unless it can prove otherwise. In most code the difference is invisible; in a hot numeric loop it is measurable, and it is one more reason records are the default for values.
Misconceptions
- "
finalfields make the object immutable." They make the references fixed. Afinal Listis still a mutable list; afinal Datestill hassetTime. - "
Collections.unmodifiableListis a copy." It is a view. The original owner can still mutate it, and the view shows the change. - "Immutable objects are slow because of allocation." Small ones are nearly free and often optimised away. Only large structures rebuilt per change cost, and those get a builder or a persistent collection.
- "Records are just less boilerplate." They are also the one Java type whose finals are trusted by the JIT and whose deserialisation always runs the constructor.
- "An immutable class is thread-safe, so its caller's code is." The object is safe to share. The code that replaces one immutable object with another still needs a
volatileor atomic reference to publish the swap.
Going deeper
- JLS §17.5, final field semantics, and JEP 500, Prepare to Make Final Mean Final.
List.copyOfandCollections.unmodifiableListJavadoc, side by side; note the "if the given collection is already unmodifiable" clause.- Effective Java, item 17 (minimise mutability) and item 50 (defensive copies).
- Aleksey Shipilëv, "JVM Anatomy Quark #15: Just-In-Time constants" for what the JIT folds.
- Vavr's collections documentation for persistent (structure-sharing) immutable collections.