Java Creating Immutable Class: A Practical Guide
java creating immutable class: Learn how to create immutable classes in Java, including final fields, defensive copying, records, and common pitfalls.
Mutable state is a common source of bugs in Java applications, especially when objects are shared across threads. An immutable class cannot change after construction, which eliminates an entire category of concurrency and aliasing problems. The phrase java creating immutable class refers to the practice of designing classes whose instances are unmodifiable after creation. This article explains the rules, shows working examples, and covers the tradeoffs you should consider.
What Makes a Class Immutable in Java?
A class is immutable when its instances cannot be modified after they are created. In Java, this requires a combination of design choices and language enforcement. The core rules are:
- Declare all fields
privateandfinal. - Do not provide any setter methods that modify fields.
- Ensure that no method can change the state of the object, including methods that return references to mutable fields.
- If a field is a mutable object (like an array or a
List), do not expose it directly. Instead, return a defensive copy or an unmodifiable view. - Make the class
finalto prevent subclasses from overriding methods and introducing mutability.
These rules are not optional. A single mutable field or a leaked reference can break immutability and lead to subtle bugs.
A Minimal Immutable Class Example
Consider a simple Point class that stores x and y coordinates. The following implementation follows the rules:
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; } }
All fields are private final, there are no setters, and the getters return primitive values, so no mutable reference escapes. The class is final, preventing subclassing. This is the simplest possible immutable class.
The constructor initializes the fields once. After construction, the object's state is fixed. This design works well for small value objects with primitive or immutable fields.
Handling Collections and Mutable Fields
When an immutable class contains a mutable field, such as a List, Map, or array, you must take extra steps. Simply declaring the field final does not make the contained object immutable. The reference cannot be reassigned, but the object itself can still be modified.
Consider a Person class with a list of phone numbers:
import java.util.ArrayList; import java.util.Collections; import java.util.List; public final class Person { private final String name; private final List<String> phoneNumbers; public Person(String name, List<String> phoneNumbers) { this.name = name; this.phoneNumbers = new ArrayList<>(phoneNumbers); } public List<String> getPhoneNumbers() { return Collections.unmodifiableList(phoneNumbers); } }
The constructor copies the input list into a new ArrayList. This defensive copy prevents the caller from modifying the list after construction. The getter returns an unmodifiable view, so callers cannot modify the internal list. If you need to return the original list, you would return a copy instead, but an unmodifiable view is usually sufficient and avoids copying on every access.
The same principle applies to arrays. Use Arrays.copyOf in the constructor and return a copy from the getter, or use List.of for a fixed-size list.
Using Records for Immutable Data Carriers
Java 14 introduced records as a concise way to define immutable data carriers. A record automatically generates a constructor, getters, equals, hashCode, and toString based on the declared components. All fields are private final by default, and the class is implicitly final.
public record Point(int x, int y) {}
This single line is equivalent to the manual Point class shown earlier. Records are ideal when you need a simple, immutable data holder without additional behavior. They also provide a compact canonical constructor for validation:
public record Point(int x, int y) { public Point { if (x < 0 || y < 0) { throw new IllegalArgumentException("Coordinates must be non-negative"); } } }
Records handle primitive and immutable fields automatically. For mutable fields like collections, you must still apply defensive copying in the compact constructor and override the accessor to return an unmodifiable view, because the default accessor returns the field directly.
The Builder Pattern for Many Fields
When an immutable class has many fields, the constructor becomes unwieldy and error-prone. The builder pattern provides a readable way to construct instances while preserving immutability. The builder itself is mutable, but the target class remains immutable.
public final class User { private final String username; private final String email; private final int age; private User(Builder builder) { this.username = builder.username; this.email = builder.email; this.age = builder.age; } public static Builder builder() { return new Builder(); } public static final class Builder { private String username; private String email; private int age; public Builder username(String username) { this.username = username; return this; } public Builder email(String email) { this.email = email; return this; } public Builder age(int age) { this.age = age; return this; } public User build() { return new User(this); } } }
The builder pattern is especially useful when some fields are optional or when the construction process involves validation. The build() method can check that required fields are set and throw an exception if they are missing. This keeps the immutable class simple and the construction logic separate.
Performance and Memory Considerations
Immutability has a reputation for causing extra allocation because every change requires a new object. In practice, this cost is often acceptable and can be offset by other benefits. For example, immutable objects can be safely cached and shared without synchronization. A String is immutable, and the JVM interns string literals to reuse instances. Similarly, you can cache immutable objects in a Map or a pool without worrying about concurrent modification.
When using defensive copies, be mindful of the overhead. Copying a large collection on every construction can be expensive. If the input collection is already unmodifiable and known to be safe, you might avoid the copy, but this is risky unless you control the caller. In high-performance code, consider using immutable collection types from libraries like Guava, or use List.copyOf which returns an unmodifiable list without copying if the input is already immutable (though this is an implementation detail).
The JVM can also optimize immutable objects more aggressively. Because their state never changes, they are safe to share across threads, and the JIT compiler can eliminate redundant reads. In concurrent applications, the absence of locks or volatile fields reduces contention and improves scalability.
Common Mistakes When Creating Immutable Classes
One frequent mistake is forgetting to make the class final. If a subclass can override getters or add mutable fields, the object is no longer truly immutable. Always declare the class final unless you have a specific reason to allow subclassing.
Another mistake is exposing a mutable field through a getter. Returning the internal ArrayList directly allows callers to add or remove elements, breaking immutability. Always return an unmodifiable view or a copy.
A third issue is failing to copy mutable inputs in the constructor. If you assign the passed-in list directly to the field, the caller can modify the list after construction, changing the object's state. Defensive copying is essential for any mutable parameter.
Finally, be careful with arrays. Arrays are mutable objects, so you must copy them in the constructor and return a copy from the getter. Using List.of or Set.of for fixed collections is often simpler.
When to Choose Immutability
Immutability is not always the right choice. It works best for value objects, data transfer objects, configuration objects, and keys in collections. If an object represents an entity that changes frequently, such as a mutable session or a live connection, immutability would force you to create a new object on every update, which may be impractical.
Use immutable classes when you need thread safety, when the object is shared across threads, or when aliasing is common. They also simplify reasoning about code because you never have to track who might modify an object. For long-lived objects that are updated often, consider a mutable design or a hybrid approach where an immutable core is replaced atomically.
When you need a simple data holder, records are the best choice. When you need validation or many optional fields, the builder pattern gives you control without sacrificing immutability. In all cases, follow the rules consistently to avoid subtle bugs.