Java Immutable Object: Implementation and Use Cases
java immutable object: Learn how to design immutable objects in Java, including final fields, defensive copying, and records, and why they improve concurrency safety.
Mutable state is a common source of bugs in Java applications, especially when objects are shared across threads. A Java immutable object is one whose state cannot be changed after construction, and it eliminates an entire class of concurrency and aliasing problems. This article explains how to design one correctly, covering final fields, defensive copying, records, and the tradeoffs involved.
What Makes an Object Immutable in Java?
An object is immutable when its state cannot be observed to change after it is created. In Java, this requires more than just declaring fields final. The class must also prevent any external code from modifying the internal state through methods or by obtaining references to mutable fields. The classic rules are:
- All fields are
finaland set in the constructor. - The class is
finalor the constructor is private to prevent subclassing, because a subclass could introduce mutable state. - No setter methods exist.
- Any field that references a mutable object (like an array,
List,Map, orDate) must be defensively copied on input and on output.
These rules are not optional. A single mutable field or a leaked reference can break the immutability guarantee and reintroduce the exact problems you were trying to avoid.
Building an Immutable Class with Final Fields
Start with the simplest case: a class whose fields are all primitive types or references to other immutable objects. Here is a basic example:
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 getX() { return x; } public int getY() { return y; } }
The final keyword on the class prevents subclassing, and the final fields guarantee that the fields are assigned exactly once. The getters return primitive values, so there is no way to modify the internal state. This class is immutable.
If you need to provide a default value or a factory method, you can add a static factory that delegates to the constructor. For example:
public static Point origin() { return new Point(0, 0); }
This keeps the class immutable while offering convenient creation paths.
Defensive Copying for Mutable Fields
When a field references a mutable object, you must copy it both when it enters the object and when it leaves it. Consider a class that wraps a Date, which is mutable:
public final class Event { private final Date start; public Event(Date start) { this.start = new Date(start.getTime()); } public Date getStart() { return new Date(start.getTime()); } }
The constructor takes a copy of the passed-in Date so that later changes to the original do not affect the Event. The getter returns a copy so that callers cannot mutate the internal Date. Without these copies, the Event would not be truly immutable.
For collections, the same principle applies. If you store a List<String>, you should copy it into an unmodifiable list. Java provides List.copyOf() since Java 10, which returns an unmodifiable list and rejects nulls:
public final class Playlist { private final List<String> tracks; public Playlist(List<String> tracks) { this.tracks = List.copyOf(tracks); } public List<String> getTracks() { return tracks; } }
List.copyOf() performs a defensive copy and wraps the result in an unmodifiable view. The getter can return the reference directly because the list is already unmodifiable. This is a common pattern for immutable collections.
Using Java Records for Immutable Data Carriers
Java 16 introduced records, which are designed specifically for immutable data carriers. A record automatically generates final fields, a constructor, equals(), hashCode(), and toString() based on the components. Here is the same Point class as a record:
public record Point(int x, int y) {}
That single line gives you an immutable class with the same semantics as the manual implementation. Records are implicitly final, and their fields are private and final. However, records do not automatically handle defensive copying for mutable components. If a record component is a List or a Date, you must override the canonical constructor to copy the input:
public record Playlist(List<String> tracks) { public Playlist { tracks = List.copyOf(tracks); } }
The compact constructor reassigns the component to a defensive copy. The accessor method tracks() returns the stored reference, which is unmodifiable because of the copy. This keeps the record immutable without sacrificing the concise syntax.
Records are ideal for DTOs, configuration values, and other data-focused classes. They are not a replacement for classes with behavior, but they reduce boilerplate significantly.
Immutability and Thread Safety
One of the strongest reasons to use a Java immutable object is thread safety. An immutable object can be safely shared between threads without synchronization because no thread can modify its state. This eliminates race conditions, lost updates, and visibility issues that plague mutable shared objects.
Consider a configuration object that is read by multiple worker threads. If it is immutable, each thread can safely call getters without locks. The Java Memory Model guarantees that final fields are safely published when the object is properly constructed, so other threads will see the fully initialized state.
This does not mean that all concurrency problems disappear. If the immutable object references a mutable object, you must ensure that the mutable object is never exposed. The defensive copying rules from earlier are essential for maintaining thread safety. Also, if you need to update the value, you must create a new object rather than modifying the existing one. This can lead to allocation overhead, which we examine next.
Performance Tradeoffs of Immutable Objects
Immutability has a cost: every change requires creating a new object. For example, updating a single field in a large configuration object means copying the entire structure. This can be wasteful in hot paths where updates are frequent. However, the cost is often acceptable because immutable objects are typically read-heavy, and the allocation overhead can be mitigated by caching or using structural sharing.
Structural sharing, as used in persistent data structures, allows new versions of a collection to share most of their internal nodes. Java does not have built-in persistent collections, but libraries like Vavr provide them. For simple objects, the allocation cost is usually small compared to the benefits of thread safety and simpler reasoning.
Another performance consideration is that immutable objects can be safely cached and reused. For example, you can cache frequently used instances and return them from a factory method, avoiding repeated construction. This is a common optimization for value objects like Integer or String.
Choosing Between Immutable Objects and Builders
When an object has many fields or requires complex construction logic, a builder pattern can be a good complement to immutability. The builder itself is mutable, but the object it produces is immutable. This gives you the flexibility of incremental construction without sacrificing the safety of the final object.
public final class User { private final String name; private final int age; private User(String name, int age) { this.name = name; this.age = age; } public static Builder builder() { return new Builder(); } public static class Builder { private String name; private int age; public Builder name(String name) { this.name = name; return this; } public Builder age(int age) { this.age = age; return this; } public User build() { return new User(name, age); } } }
The builder pattern is useful when you have optional parameters or when you want to validate the combination of fields before constructing the immutable object. Use a builder when the constructor would have too many parameters or when the construction logic is non-trivial. For simple data carriers, a record or a plain constructor is sufficient.
Common Pitfalls in Immutable Object Design
Even experienced developers make mistakes when implementing immutable objects. One common issue is allowing inheritance. If a class is not final, a subclass can add mutable fields or override methods to change behavior. Always declare the class final or use a private constructor with a static factory.
Another pitfall is returning a reference to a mutable field without copying. This violates immutability because the caller can modify the internal state. Always return defensive copies or unmodifiable views.
Reflection and serialization can also break immutability. Reflection can set final fields, and serialization can bypass constructors. If you rely on immutability for security or correctness, you need to account for these mechanisms. For example, you can override readResolve() in a serializable immutable class to return a canonical instance.
Finally, be careful with arrays. An array is mutable, so storing an array directly and returning it without copying is a common mistake. Use Arrays.copyOf() or convert to an unmodifiable List instead.
Immutability is a powerful design choice, but it requires discipline. By following the rules for final fields, defensive copying, and preventing subclassing, you can create Java immutable objects that are safe to share across threads and easy to reason about. The tradeoff in allocation cost is often worth the reduction in bugs and the simplification of concurrent code.