Immutability in Java: Records, Pitfalls, and What's Coming Next
Immutability is one of the cheapest ways to make concurrent code safe — an object that can’t change after construction can be shared across threads without synchronization, cached without defensive copying, and reasoned about without tracing every place it might mutate. Java always supported it, but for years writing an immutable class meant a wall of boilerplate. Records, introduced by JEP 395 in Java 16, made it the path of least resistance instead of the tedious one.
The old way
Before records, an immutable value type looked like this:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int x() { return x; }
public int y() { return y; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() { return Objects.hash(x, y); }
@Override
public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
}
Every field, every accessor, equals, hashCode, toString — all hand-written, and all easy to get subtly wrong (forget to update hashCode after adding a field, and you’ve got objects that violate the equals/hashCode contract).
The modern way: records
The same type as a record:
public record Point(int x, int y) {}
One line. The compiler generates a canonical constructor, x()/y() accessors, and correct equals, hashCode, and toString implementations — all consistent with each other by construction, because there’s only one place the field list is declared.
The pitfall: records aren’t deeply immutable
A record’s fields are final, but that only guarantees the reference can’t be reassigned — not that the referenced object can’t change. This is the trap that catches people moving to records too quickly:
public record Team(String name, List<String> members) {}
var members = new ArrayList<>(List.of("Alice", "Bob"));
var team = new Team("Backend", members);
members.add("Eve"); // mutates the list the record is holding onto
team.members().add("Mallory"); // ...and so does this — the accessor returns the live list
team looks immutable from the outside, but its contents changed twice without ever “reassigning” anything. The fix is a compact constructor that defensively copies any mutable field:
public record Team(String name, List<String> members) {
public Team(String name, List<String> members) {
this.name = name;
this.members = List.copyOf(members); // unmodifiable + decoupled from the caller's list
}
}
Now team.members().add(...) throws UnsupportedOperationException, and the caller’s original list can keep mutating without affecting team at all.
Trade-offs worth knowing before you reach for records
- Less boilerplate, same guarantees — final fields, consistent
equals/hashCode/toString, no accidental field reassignment. - No inheritance — a record implicitly extends
Recordand can’t extend anything else. It can implement interfaces, which covers most practical cases, but it rules out reusing a class hierarchy for shared state. - Defensive copies are still your job — the compiler won’t do it for you; any field holding a mutable type (
List,Map,Date, arrays) needs an explicit copy in a compact constructor, or it’s not actually immutable. - Great fit for pattern matching — records destructure naturally in
switchpattern matching, which is where a lot of their practical value shows up in modern Java code.
What’s next: value classes (Project Valhalla)
Records solve boilerplate; they don’t change how the JVM represents the object in memory — a Point record is still a heap-allocated object with object header overhead, identity, and a pointer indirection every time you touch it.
JEP 401: Value Classes and Objects (Preview), part of Project Valhalla, goes further: a class declared with the value modifier produces value objects with no identity at all. == no longer compares references — it compares field values, the same way equals does on a well-behaved immutable type today. That gives the JVM room to flatten value objects directly into the heap layout of whatever contains them, instead of always paying for a separate allocation and a pointer indirection.
The JEP itself demonstrates this by retrofitting LocalDate — already effectively immutable today — as a value class under preview, and showing what changes:
% jshell --enable-preview
jshell> LocalDate d1 = LocalDate.of(1996, 1, 23)
jshell> LocalDate d2 = d1.plusYears(30)
jshell> LocalDate d3 = d2.minusYears(30)
jshell> d1 == d3
$7 ==> true // identical field values → ==, no identity involved
jshell> Objects.hasIdentity(d1)
$10 ==> false // a value object has no identity to report
Without --enable-preview on a current JDK, that same d1 == d3 evaluates to false — LocalDate is an ordinary identity class today, so == compares references, and d1/d3 are two distinct objects that merely hold equal field values. Under the value-class preview, the two are indistinguishable, by design.
This is still a preview feature as of late 2025 — the syntax and semantics aren’t finalized, and it isn’t something to build on in production yet. But it’s the clearest signal of where Java’s immutability story is heading: from “easier to write” (records) to “the JVM stops paying for it at runtime” (value classes).
For any type that’s a pure value — DTOs, coordinates, money amounts, IDs — records should be the default today. The one thing the compiler won’t do for you is defensive copying: every field holding a collection, array, or mutable type needs an explicit List.copyOf or equivalent in a compact constructor, or “immutable” is just a label. Reach for a regular class only when you genuinely need inheritance. And if your code allocates a lot of small immutable objects in a hot path, value classes are the feature worth watching — they’re the piece that closes the gap between ergonomics and runtime cost.
🔗 Source: JEP 395: Records