In Java, an immutable class is a class whose objects can’t be changed after they’re created. For example, String
is an immutable class and, once instantiated, the value of a String
object never changes. This is useful because it makes your programs easier to understand, safer for use in multi-threaded code, and less prone to bugs. When you use immutable objects, you don’t have to worry about their state changing unexpectedly. Immutability is a common pattern in modern Java development, especially in use cases such as configuration objects, data transfer objects (DTOs), and cache keys.
This article will show you how to create immutable classes in Java, step by step. You’ll learn the rules to follow, see real code examples, and understand why certain mistakes can break immutability. We’ll also cover more advanced topics like handling collections, working with native memory, using libraries like Guava and Vavr, and common best practices. By the end, you’ll know how to build clean, safe, and reliable classes that can’t be accidentally changed.
Key Takeaways:
Immutable classes prevent state changes after object creation. Once constructed, an immutable object’s internal state cannot be modified. This makes code more predictable and easier to understand.
Immutability enhances thread safety without synchronization. Because immutable objects can’t be changed, they can be safely shared between threads without requiring locks or synchronized
blocks.
To make a class immutable, follow strict design rules. Use final
for the class and fields, avoid setters, copy mutable inputs, and never expose internal mutable fields directly.
Returning or storing mutable objects without copying breaks immutability. Even with final
fields, exposing collections or mutable objects via getters or constructors without copying allows external modification.
Common pitfalls include shallow copying, leaking this
, and mutable interfaces. Errors like wrapping without copying, unsafe ByteBuffer
access, and assuming interface immutability can compromise the class’s immutability.
Use libraries like Guava, Vavr, or Java’s Map.copyOf()
to simplify immutable design. These libraries provide safer, more efficient ways to work with immutable collections and reduce boilerplate code.
Immutability improves code quality, maintainability, and debugging. Immutable objects lead to lower cyclomatic complexity, easier profiling, better testability, and more reliable system behavior.
To create an immutable class in Java, you need to follow these general principles:
final
so it can’t be extended.private
so that direct access is not allowed.final
so that a field’s value can be assigned only once.The following class is an example that illustrates the basics of immutability. The FinalClassExample
class defines the fields and provides the constructor method that uses deep copy to initialize the object. The code in the main
method of the FinalClassExample.java
file tests the immutability of the object.
Create a new file called FinalClassExample.java
and copy in the following code:
import java.util.HashMap;
import java.util.Iterator;
public final class FinalClassExample {
// fields of the FinalClassExample class
private final int id;
private final String name;
private final HashMap<String, String> testMap;
public int getId() {
return id;
}
public String getName() {
return name;
}
// Getter function for mutable objects
public HashMap<String, String> getTestMap() {
return (HashMap<String, String>) testMap.clone();
}
// Constructor method performing deep copy
public FinalClassExample(int i, String n, HashMap<String, String> hm){
System.out.println("Performing Deep Copy for Object initialization");
// "this" keyword refers to the current object
this.id=i;
this.name=n;
HashMap<String, String> tempMap=new HashMap<String, String>();
String key;
Iterator<String> it = hm.keySet().iterator();
while(it.hasNext()){
key=it.next();
tempMap.put(key, hm.get(key));
}
this.testMap=tempMap;
}
// Test the immutable class
public static void main(String[] args) {
HashMap<String, String> h1 = new HashMap<String, String>();
h1.put("1", "first");
h1.put("2", "second");
String s = "original";
int i=10;
FinalClassExample ce = new FinalClassExample(i,s,h1);
// print the ce values
System.out.println("ce id: "+ce.getId());
System.out.println("ce name: "+ce.getName());
System.out.println("ce testMap: "+ce.getTestMap());
// change the local variable values
i=20;
s="modified";
h1.put("3", "third");
// print the values again
System.out.println("ce id after local variable change: "+ce.getId());
System.out.println("ce name after local variable change: "+ce.getName());
System.out.println("ce testMap after local variable change: "+ce.getTestMap());
HashMap<String, String> hmTest = ce.getTestMap();
hmTest.put("4", "new");
System.out.println("ce testMap after changing variable from getter methods: "+ce.getTestMap());
}
}
Compile and run the program:
- javac FinalClassExample.java
- java FinalClassExample
Note: You might get the following message when you compile the file: Note: FinalClassExample.java uses unchecked or unsafe operations
because the getter method is using an unchecked cast from HashMap<String, String>
to Object
. You can ignore the compiler warning for the purposes of this example.
You get the following output:
OutputPerforming Deep Copy for Object initialization
ce id: 10
ce name: original
ce testMap: {1=first, 2=second}
ce id after local variable change: 10
ce name after local variable change: original
ce testMap after local variable change: {1=first, 2=second}
ce testMap after changing variable from getter methods: {1=first, 2=second}
The output shows that the HashMap values didn’t change because the constructor uses deep copy and the getter function returns a clone of the original object.
To demonstrate what happens when you use a shallow copy instead of a deep copy, you can modify the FinalClassExample.java
file. If you return the object directly instead of a clone, the object is no longer immutable. Make the following changes to the example file (or copy and paste from the code example):
return (HashMap<String, String>) testMap.clone();
and add return testMap;
.The example file should now look like this:
import java.util.HashMap;
import java.util.Iterator;
public final class FinalClassExample {
// fields of the FinalClassExample class
private final int id;
private final String name;
private final HashMap<String, String> testMap;
public int getId() {
return id;
}
public String getName() {
return name;
}
// Getter function for mutable objects
public HashMap<String, String> getTestMap() {
return testMap;
}
//Constructor method performing shallow copy
public FinalClassExample(int i, String n, HashMap<String, String> hm){
System.out.println("Performing Shallow Copy for Object initialization");
this.id=i;
this.name=n;
this.testMap=hm;
}
// Test the immutable class
public static void main(String[] args) {
HashMap<String, String> h1 = new HashMap<String,String>();
h1.put("1", "first");
h1.put("2", "second");
String s = "original";
int i=10;
FinalClassExample ce = new FinalClassExample(i,s,h1);
// print the ce values
System.out.println("ce id: "+ce.getId());
System.out.println("ce name: "+ce.getName());
System.out.println("ce testMap: "+ce.getTestMap());
// change the local variable values
i=20;
s="modified";
h1.put("3", "third");
// print the values again
System.out.println("ce id after local variable change: "+ce.getId());
System.out.println("ce name after local variable change: "+ce.getName());
System.out.println("ce testMap after local variable change: "+ce.getTestMap());
HashMap<String, String> hmTest = ce.getTestMap();
hmTest.put("4", "new");
System.out.println("ce testMap after changing variable from getter methods: "+ce.getTestMap());
}
}
Compile and run the program:
- javac FinalClassExample.java
- java FinalClassExample
You get the following output:
OutputPerforming Shallow Copy for Object initialization
ce id: 10
ce name: original
ce testMap: {1=first, 2=second}
ce id after local variable change: 10
ce name after local variable change: original
ce testMap after local variable change: {1=first, 2=second, 3=third}
ce testMap after changing variable from getter methods: {1=first, 2=second, 3=third, 4=new}
The output shows that the HashMap values got changed because the constructor method uses shallow copy there is a direct reference to the original object in the getter function.
While you can build immutable classes in Java using final
fields, defensive copies, and unmodifiable views, these techniques can be verbose and susceptible to error, especially when working with complex or nested collections.
To simplify this process, several libraries offer immutable collections out of the box. These libraries ensure that once a collection is created, it can never be modified. They handle all the defensive copying, deep immutability, and fail-fast protections for you.
Google Guava provides a set of collection classes prefixed with Immutable
, such as ImmutableList
, ImmutableSet
, and ImmutableMap
. These collections are:
UnsupportedOperationException
.Example:
import com.google.common.collect.ImmutableMap;
public final class GuavaExample {
private final ImmutableMap<String, String> config;
public GuavaExample(Map<String, String> input) {
this.config = ImmutableMap.copyOf(input); // Creates an immutable copy
}
public ImmutableMap<String, String> getConfig() {
return config; // Safe to return directly
}
}
Benefits:
input
map is changed after construction.Vavr is a functional programming library for Java. It offers persistent collections like List
, Set
, Map
, and Tuple
, which are immutable by design.
Persistent means that operations like adding or removing elements return new collections, without modifying the original.
Example:
import io.vavr.collection.HashMap;
import io.vavr.collection.Map;
public final class VavrExample {
private final Map<String, String> settings;
public VavrExample(Map<String, String> input) {
this.settings = input; // Vavr maps are immutable
}
public Map<String, String> getSettings() {
return settings;
}
}
Benefits:
map()
, filter()
, and pattern matching.Use immutable collection libraries when:
Newer versions of Java also include support for immutable collections:
Map<String, String> map = Map.of("key", "value"); // Java 9+
List<String> list = List.of("a", "b"); // Java 9+
However, these built-in collections are shallowly immutable, they protect the collection itself, but not the objects inside.
In advanced Java applications, especially those dealing with high-performance computing, graphics, or native integrations, you may use native memory through the Java Native Interface (JNI) or allocate memory off the Java heap using ByteBuffer
, Unsafe
, or external libraries. These techniques can help you reduce garbage collection pressure or integrate with system-level APIs, but they also introduce new risks when working with immutable classes.
Even if your class is final
and its fields are declared private final
, using native memory or off-heap data can compromise the immutability of your objects.
When you use JNI to pass or receive data between Java and native code, it’s common to store a native memory address in a long
or jlong
field. Even if this field is final, the underlying native data can still be changed from outside the Java class.
public final class NativeWrapper {
private final long nativePtr;
public NativeWrapper(long ptr) {
this.nativePtr = ptr;
}
public String getNativeValue() {
return NativeLibrary.getValue(nativePtr);
}
}
This class looks immutable, but if native code modifies the memory at nativePtr
, the Java-visible value changes, breaking immutability.
If your class uses ByteBuffer.allocateDirect()
to store off-heap data, calling .asReadOnlyBuffer()
only creates a read-only view, not a copy. Any other part of the code that holds a reference to the original buffer can still change its contents.
public final class BufferWrapper {
private final ByteBuffer readOnlyBuffer;
public BufferWrapper(ByteBuffer buffer) {
this.readOnlyBuffer = buffer.asReadOnlyBuffer(); // Still backed by the original memory
}
public byte getByte(int index) {
return readOnlyBuffer.get(index);
}
}
If another reference to the same buffer exists elsewhere, changes made through it will be visible through this supposedly immutable class.
Copy on Construction: Always copy the native or off-heap data into an immutable on-heap structure during object construction. Avoid holding onto raw pointers or mutable buffers.
public final class NativeWrapper {
private final long nativePtr;
private final String cachedValue;
public NativeWrapper(long ptr) {
this.nativePtr = ptr;
this.cachedValue = NativeLibrary.getValue(ptr); // Defensive copy
// Optional: make native data read-only if API allows
// NativeLibrary.makeReadOnly(ptr);
}
public String getNativeValue() {
return cachedValue;
}
@Override
protected void finalize() throws Throwable {
NativeLibrary.cleanup(nativePtr); // RAII pattern
}
}
Validate Pointers and Bounds: If you must use native memory, validate that the pointer is within a valid range and hasn’t been freed or reused before use.
Use RAII Pattern (Resource Acquisition Is Initialization): Free any native resources (memory, file handles, etc.) when the object is no longer needed. You can do this in finalize()
or preferably with Cleaner
(Java 9+) or external libraries like JNA or Panama.
Use Read-Only Buffers Carefully: If you’re using ByteBuffer
, consider copying its contents into a new buffer or array and never expose the original buffer via public APIs.
Encapsulate All Native Access: Never expose native handles, pointers, or buffers through getter methods. Always abstract them behind safe APIs that enforce immutability.
Document Immutability Assumptions: If immutability depends on external behavior (e.g., native data not being changed), document this explicitly in your code and API contracts.
Memory-Mapped Files: Used for handling large datasets or logs in read-only mode using FileChannel.map()
. Even if the file content is not modified, the mapped memory region can be.
Networking Buffers: In Netty or other high-performance frameworks, off-heap memory is often used for I/O buffers. Immutable wrappers must ensure no shared access.
Native Libraries: When passing data to native APIs, make sure any backing memory isn’t modified outside your control.
Using defensive copies ensures immutability but may introduce:
Use defensive copying when:
Avoid copying when:
To ensure that your immutable classes remain truly immutable:
Designing an immutable class in Java isn’t just about using final
and removing setters. One of the most common mistakes developers make, especially when dealing with collections or complex objects, is exposing internal state through getter methods or public APIs.
This section explains, with step-by-step examples, how interface design choices can compromise immutability, even when the class appears to follow all the right rules.
Let’s revisit our FinalClassExample
and see what happens if we return a reference to a mutable field like HashMap
.
import java.util.HashMap;
public final class FinalClassExample {
private final int id;
private final String name;
private final HashMap<String, String> testMap;
public FinalClassExample(int id, String name, HashMap<String, String> map) {
this.id = id;
this.name = name;
this.testMap = new HashMap<>(map); // Deep copy input for safety
}
public int getId() {
return id;
}
public String getName() {
return name;
}
public HashMap<String, String> getTestMap() {
return testMap; // Returning internal object directly
}
public static void main(String[] args) {
HashMap<String, String> map = new HashMap<>();
map.put("1", "first");
map.put("2", "second");
FinalClassExample obj = new FinalClassExample(10, "original", map);
System.out.println("Before external modification: " + obj.getTestMap());
HashMap<String, String> reference = obj.getTestMap();
reference.put("3", "third"); // Modifies internal state!
System.out.println("After external modification: " + obj.getTestMap());
}
}
Output:
Before external modification: {1=first, 2=second}
After external modification: {1=first, 2=second, 3=third}
Even though testMap
is marked final
and copied in the constructor, the getter leaks the reference to the internal map. Anyone who calls getTestMap()
receives the original map, not a copy, and can change its contents, thus breaking immutability.
In many designs, developers return interfaces such as Map<String, String>
or List<T>
instead of concrete types like HashMap
or ArrayList
. This seems safe, but it’s not always enough.
public Map<String, String> getTestMap() {
return testMap;
}
This can still break immutability because:
Map<String, String> exposed = obj.getTestMap();
((HashMap<String, String>) exposed).put("4", "value"); // Still modifies internal state
Even though the method signature looks safe, you’re still exposing a live reference to a mutable object.
One way to fix this is by returning a copy of the map every time the getter is called:
public HashMap<String, String> getTestMap() {
return new HashMap<>(testMap); // Defensive copy
}
Now, any changes made to the returned map do not affect the internal state.
Before external modification: {1=first, 2=second}
After external modification: {1=first, 2=second}
The trade-off here is that this approach is safe but potentially expensive if the object is large or accessed frequently.
If you want to avoid copying each time, you can return a read-only view using the Collections API:
import java.util.Collections;
public Map<String, String> getTestMap() {
return Collections.unmodifiableMap(testMap);
}
Now, any attempt to mutate the returned map will throw an UnsupportedOperationException
.
Map<String, String> readOnly = obj.getTestMap();
readOnly.put("new", "value"); // Throws exception
Important Caveat: This approach is only effective if the internal map is not shared elsewhere. You must still perform a deep copy in the constructor to prevent the original input from being modified externally.
Even if you return a defensive copy, if you don’t copy the input in the constructor, the internal state might still change.
Example:
public FinalClassExample(int id, String name, HashMap<String, String> map) {
this.id = id;
this.name = name;
this.testMap = map; // Unsafe: stores external reference
}
Now any external changes to map
after object construction will affect the object’s internal state.
HashMap<String, String> map = new HashMap<>();
map.put("1", "first");
FinalClassExample obj = new FinalClassExample(10, "data", map);
map.put("2", "second"); // Breaks immutability
Always copy input collections in the constructor:
this.testMap = new HashMap<>(map); // Defensive constructor copy
Even if you wrap your collection in Collections.unmodifiableMap()
or use Map.copyOf()
, it doesn’t make the contents immutable if the values themselves are mutable.
Map<String, List<String>> map = new HashMap<>();
List<String> list = new ArrayList<>();
list.add("a");
map.put("key", list);
Map<String, List<String>> immutableMap = Collections.unmodifiableMap(map);
immutableMap.get("key").add("b"); // Still modifies internal list
Map.copyOf()
and List.copyOf()
.The table below summarizes common interface design mistakes that can compromise immutability, the risks they introduce, and practical solutions to avoid them.
Mistake | Consequence | Solution |
---|---|---|
Returning internal mutable objects | Breaks immutability via exposed reference | Return a defensive copy or unmodifiable view |
Failing to copy input collections | External references can mutate internal state | Always copy inputs in constructor |
Using interfaces without protecting implementation | Callers may cast or mutate | Combine interface return with internal immutability |
Wrapping without deep copy | Inner data may still be mutable | Use deep copies or immutable libraries |
In addition to helping with thread safety and state predictability, immutability also leads to simpler, more maintainable code. This is not just theory; recent research shows that using immutable patterns can significantly reduce complexity in real-world projects.
One of the most important findings comes from the paper The Impact of Mutability on Cyclomatic Complexity in Java, which analyzed thousands of Java projects and found a strong correlation between immutability and lower cyclomatic complexity, as well as improved maintainability metrics.
Cyclomatic complexity is a measure of how many independent execution paths a piece of code contains. The more branching (if
, switch
, while
, etc.) or state-dependent logic you add, the higher the complexity.
In mutable code, objects often contain flags or switch their internal state depending on external conditions. For example:
if (order.isPaid()) {
if (order.isShipped()) {
order.setStatus("closed");
} else {
order.setStatus("processing");
}
} else {
order.setStatus("pending");
}
This logic introduces multiple code paths and a tight coupling between the object’s behavior and its changing state. The arXiv study shows that these patterns increase the cyclomatic complexity of individual methods and classes, making them more difficult to test and refactor, and more prone to bugs.
When you use immutable objects, you don’t manage internal state changes. Instead, each version of the object represents a fixed snapshot of its data. There’s no need for conditional logic to switch modes inside the class.
Here’s the same concept, using immutability:
Order closedOrder = new Order("closed");
Rather than checking internal state and branching, your model changes by creating new object instances that represent the updated state.
According to the study, this pattern leads to:
if
and else
branches inside methodsThe result is lower average cyclomatic complexity, especially in domain-layer and controller-level code.
The study measured maintainability using several metrics, such as:
Classes designed with immutable principles consistently showed lower maintenance overhead:
The reason is simple: immutable classes are easier to reason about. They expose fewer moving parts and are more likely to behave consistently over time.
By eliminating internal state transitions and enforcing object consistency, immutability helps:
Immutability doesn’t just make your code safer, it also makes it simpler and easier to manage in the long run. These benefits are especially valuable in large or fast-changing codebases, where complexity can grow quickly.
For deeper insights, you can read the full study: The Impact of Mutability on Cyclomatic Complexity in Java (arXiv:2410.10425).
One of the biggest advantages of immutable classes is that they make debugging and performance profiling much easier.
With mutable objects, the internal state can change at any time, sometimes in unexpected ways. That means a value you printed earlier may not match the value later in the program, which makes it harder to trace bugs.
Here’s a simple example:
System.out.println("User status: " + user.getStatus());
// ... other code that might change user status
System.out.println("User status again: " + user.getStatus());
If User
is mutable, the second log could show a different value than the first, depending on what happened in between. But if User
is immutable, both values will always be the same, because the object never changes after it’s created.
That consistency also helps when you:
Immutable objects are especially helpful in tools like:
Since immutable objects never change, you don’t have to worry about tracking how their state evolved. You can trust that what you see is what was created.
Immutable objects also help with profiling memory usage. Because each object is a standalone snapshot, memory analyzers can group and track them more easily. You also avoid the risk of shared mutable references, which can cause hard-to-detect memory leaks.
In short, debugging and profiling with immutable objects is simpler, faster, and more reliable.
Even when you understand the key principles like marking fields private final
, copying input data, and avoiding setters, it’s easy to introduce subtle mistakes that break immutability. These issues often go unnoticed until they lead to confusing bugs or unexpected behavior, especially in multi-threaded environments.
Below are some of the most common errors developers encounter when trying to create immutable classes in Java, along with detailed explanations, fixes, and performance considerations.
Mistake: Exposing internal mutable state through public accessors.
public HashMap<String, String> getData() {
return data; // Leaks internal state
}
Problem: External code can change the object’s state by modifying the returned reference.
Fix: Return a defensive copy or immutable version:
public Map<String, String> getData() {
return new HashMap<>(data);
}
Or, better:
public ImmutableMap<String, String> getData() {
return ImmutableMap.copyOf(data); // Guava
}
Mistake: Storing externally mutable objects directly.
public FinalClass(Map<String, String> config) {
this.config = config; // Unsafe
}
Problem: Modifying the input map after construction affects internal state.
Fix: Always make a defensive copy:
this.config = new HashMap<>(config);
Mistake: Wrapping the input collection without copying it first.
this.config = Collections.unmodifiableMap(config); // Still references external data
Problem: External code with a reference to config
can still mutate it.
Fix: Wrap a copy, not the original:
this.config = Collections.unmodifiableMap(new HashMap<>(config));
Mistake: Copying only the outer collection.
Map<String, List<String>> copy = new HashMap<>(input);
Problem: Inner lists are still shared and mutable.
Fix: Perform a deep copy:
Map<String, List<String>> deepCopy = new HashMap<>();
for (Map.Entry<String, List<String>> entry : input.entrySet()) {
List<String> originalList = entry.getValue();
List<String> copiedList = originalList != null
? new ArrayList<>(originalList)
: null;
deepCopy.put(entry.getKey(), copiedList);
}
Performance Note: Defensive copying can be expensive for large collections. Consider using immutable collection libraries like Guava’s ImmutableMap
or Java’s Map.copyOf()
(Java 10+) for better performance and safety.
final
but Still Mutating ThemMistake: Marking fields final
but calling mutating methods.
private final List<String> items;
public void addItem(String item) {
items.add(item); // Still mutates internal state
}
Problem: final
prevents reassignment, not mutation.
Fix: Prevent mutation altogether. Avoid exposing methods that change internal data.
this
During ConstructionMistake: Registering this
or exposing the object before construction completes.
public final class ProblematicClass {
private final String value;
public ProblematicClass(String val) {
this.value = val;
// BAD: Another thread might access incomplete object
Registry.register(this);
// Any exception after this point leaves a partial object registered
performExpensiveValidation();
}
}
Problem: Other threads or components may see a partially constructed object, leading to race conditions or inconsistent state.
Fix: Avoid exposing this
during construction. Use a factory or builder pattern to register fully constructed instances.
Mistake: Returning a Map
or List
and assuming the caller won’t modify it.
public Map<String, String> getData() {
return data;
}
Problem: Interface types don’t guarantee immutability. Callers may cast and mutate.
Fix: Return a copy or a truly immutable implementation:
return Collections.unmodifiableMap(new HashMap<>(data));
Or:
return ImmutableMap.copyOf(data);
Mistake: Exposing a ByteBuffer
or memory pointer directly.
public ByteBuffer getBuffer() {
return buffer; // May expose mutable memory
}
Problem: duplicate()
creates a new buffer that shares the same backing memory. asReadOnlyBuffer()
makes it read-only but still refers to the same data. Neither approach guarantees immutability.
Fix: Copy the data before exposing it:
public byte[] getBufferData() {
byte[] copy = new byte[buffer.remaining()];
buffer.duplicate().get(copy);
return copy;
}
Or, to return a read-only ByteBuffer
safely:
public ByteBuffer getBuffer() {
ByteBuffer copy = ByteBuffer.allocate(buffer.remaining());
copy.put(buffer.duplicate());
copy.flip();
return copy.asReadOnlyBuffer();
}
These approaches ensure that external code cannot modify the internal buffer content.
Mistake: Leaving your immutable class non-final
.
public class ImmutableClass {
private final String value;
// ...
}
Problem: A subclass could override methods or introduce mutability.
Fix: Mark the class final
to prevent subclassing:
public final class ImmutableClass {
private final String value;
// ...
}
The table below highlights common immutability mistakes, the risks they introduce, and the recommended fixes to ensure your classes remain truly immutable.
Error | Risk | Fix |
---|---|---|
Returning mutable fields | Breaks encapsulation | Return defensive copies or immutable wrappers |
Not copying inputs | External mutation affects internal state | Copy mutable constructor arguments |
Wrapping without copying | Still references external mutable objects | Copy before wrapping |
Shallow copying nested structures | Inner objects remain mutable | Perform deep copy manually or use libraries |
Mutating final fields |
Field is final but object is not | Avoid mutable structures entirely |
Leaking this in constructor |
Race conditions and invalid state | Avoid exposing this early or use factories |
Relying on interfaces | Caller may mutate or cast | Return immutable implementations |
Exposing off-heap memory | Mutable memory shared externally | Copy buffer data before exposing |
Class not marked final |
Subclasses may introduce mutability | Use final keyword |
By recognizing and avoiding these common mistakes, you can write immutable classes that behave as expected, even in complex, concurrent, or performance-critical Java applications.
Designing immutable classes is about more than marking fields final
. It requires careful handling of object construction, state exposure, and memory safety. Below are proven best practices that help you write truly immutable, thread-safe, and maintainable Java classes.
final
Always declare your class as final
unless you have a compelling reason not to.
public final class User { ... }
Prevents subclassing, which could introduce mutability or override methods that break immutability guarantees.
private final
Make every field private
to enforce encapsulation, and final
to ensure it can only be assigned once.
private final String name;
private final Map<String, String> config;
Prevents reassignment and accidental modification of fields outside the constructor.
Always create a copy of any mutable object passed into the constructor.
public User(Map<String, String> input) {
this.config = new HashMap<>(input);
}
Prevents callers from modifying internal state by holding onto external references. For nested collections or custom objects, perform deep copies where necessary.
Avoid returning mutable fields directly from getter methods. Return a defensive copy or an unmodifiable/immutable wrapper.
public Map<String, String> getConfig() {
return Collections.unmodifiableMap(new HashMap<>(config));
}
Or use:
return ImmutableMap.copyOf(config); // Guava
Returning internal references allows external code to mutate your object’s state.
Prefer libraries like:
ImmutableMap
, ImmutableList
, etc.)Map
, List
, Set
, etc. with persistent structures)Map.copyOf()
, List.copyOf()
)These libraries provide safe, deeply immutable collections and remove boilerplate from defensive copying.
this
in ConstructorsDon’t register or pass this
during object construction. The object may not be fully initialized, and other threads may see inconsistent state.
public User(Map<String, String> input) {
this.config = Map.copyOf(input);
Registry.register(this); // Don't do this in constructor
}
Use a factory method or register after construction is complete.
Check for null values, invalid keys, or improper state in the constructor.
public User(String name) {
this.name = Objects.requireNonNull(name, "Name cannot be null");
}
If invalid data slips through, you can’t fix it later; immutable objects don’t change.
Use Javadoc to clearly specify that the class is immutable and what guarantees it offers.
/**
* Immutable configuration holder. Thread-safe and read-only.
*/
Communicates intent to other developers and helps maintain API contracts.
For classes with multiple constructors or optional fields, use a builder pattern or static factory method.
public static User of(String name, Map<String, String> config) {
return new User(name, config);
}
Keeps the constructor private, avoids accidental misuse, and enables validation logic in a single place.
Use tools like:
Immutability introduces object creation overhead. Use profiling to ensure defensive copies or immutable wrappers aren’t causing performance regressions in hot paths.
An immutable class is a class whose instances cannot be modified after they are created. All of the object’s fields remain constant, and no method can alter the state.
Characteristics of an immutable class:
final
(cannot be subclassed)private final
Example:
public final class UserProfile {
private final String name;
private final int age;
public UserProfile(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
Once a UserProfile
object is created, its name
and age
cannot be changed.
Immutable classes are inherently thread-safe because their state cannot change after construction. This eliminates:
synchronized
, Lock
, volatile
, etc.)Since multiple threads can read immutable objects concurrently without coordination, they’re ideal for use in:
Example:
ImmutableConfig config = new ImmutableConfig("host", 8080);
// Safe to share across threads — no locks needed
To prevent modification of fields in a class, follow these guidelines:
private final
: Ensures the field is assigned only once and cannot be accessed directly.ImmutableMap
or Java’s Map.copyOf()
.final
: Prevents subclassing which can override or introduce mutability.A Data Transfer Object (DTO) is a simple object used to carry data between layers. When designing an immutable DTO, you want to ensure that:
Recommended Pattern:
public final class UserDTO {
private final String name;
private final List<String> roles;
public UserDTO(String name, List<String> roles) {
this.name = name;
this.roles = List.copyOf(roles); // Java 10+ or use ImmutableList.copyOf() from Guava
}
public String getName() {
return name;
}
public List<String> getRoles() {
return roles; // Already unmodifiable
}
}
This approach guarantees that UserDTO
can safely be shared, cached, serialized, or passed between threads without fear of state mutation.
Yes, but only under strict control. If your class must hold a reference to a mutable object (like a List
, Map
, or custom class), you need to:
Example:
private final Map<String, List<String>> data;
public ImmutableWrapper(Map<String, List<String>> input) {
Map<String, List<String>> copy = new HashMap<>();
for (Map.Entry<String, List<String>> entry : input.entrySet()) {
copy.put(entry.getKey(), List.copyOf(entry.getValue()));
}
this.data = Map.copyOf(copy); // Java 10+
}
This ensures that both the outer and inner structures are immutable, even though List
and Map
themselves are mutable by nature.
Yes, Java records (introduced in Java 14 as a preview and made stable in Java 16) are immutable by default.
All fields in a record are:
private final
Example:
public record User(String name, int age) {}
This is functionally equivalent to:
public final class User {
private final String name;
private final int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public String name() { return name; }
public int age() { return age; }
}
However, if a record contains a mutable object (like List<String>
), it won’t automatically perform a defensive copy. You still need to handle deep immutability manually.
If you don’t deep copy mutable fields in the constructor (or return a copy in getters), your class may appear immutable but still allow internal state changes through external references.
Example of the problem:
public FinalClassExample(int id, String name, HashMap<String, String> map) {
this.id = id;
this.name = name;
this.testMap = map; // reference leak
}
Modifying map
outside the class after construction will also change testMap
, violating immutability.
Correct approach:
this.testMap = new HashMap<>(map); // defensive copy
Deep copying ensures that the internal state is isolated from external mutations.
Here are a few strategies to test immutability:
private final
.@Immutable
from tools like Checker Framework or JSR-305 to enforce and document immutability at compile time.Immutable classes play an important role in writing safe, reliable, and easy-to-maintain Java code. In this article, you learned what immutability means, why it matters, especially in multi-threaded programs, and how to create your own immutable classes step by step. We covered the key rules like using final
fields, copying mutable inputs, and not exposing internal state.
You also saw common mistakes developers make when trying to write immutable classes, and how to fix them. We explored advanced topics such as immutable collections, handling off-heap memory and native code, and protecting against issues like shallow copying and leaking this
during construction. We explored how to use libraries like Guava, Vavr, and Java’s built-in Map.copyOf()
to make things easier and safer.
Finally, we shared best practices, and answered frequently asked questions to help you apply these concepts in your own projects. With this knowledge, you’re now better equipped to write clean, predictable, and truly immutable Java classes.
For a deeper understanding of Java fundamentals and concurrency, explore these related articles on Strings, constructors, and multithreading:
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
Java and Python Developer for 20+ years, Open Source Enthusiast, Founder of https://www.askpython.com/, https://www.linuxfordevices.com/, and JournalDev.com (acquired by DigitalOcean). Passionate about writing technical articles and sharing knowledge with others. Love Java, Python, Unix and related technologies. Follow my X @PankajWebDev
With over 6 years of experience in tech publishing, Mani has edited and published more than 75 books covering a wide range of data science topics. Known for his strong attention to detail and technical knowledge, Mani specializes in creating clear, concise, and easy-to-understand content tailored for developers.
… and here is a translation of the entire exercise into Scala, except without the wasteful copying and cloning, and with correct equals and hashCode methods: case class FinalClassExample(id: Int, name: String, testMap: Map[String,String])
- Ken
Shouldn’t it be “shallow copy” instead of “swallow copy” unless I am missing something?
- Shantanu Kumar
why don’t you just do this: import static java.util.Collections.unmodifiableMap; public final class FinalClassExample { ... private final Map testMap; public FinalClassExample(int i, String n, Map m){ id = i; name = n; testMap = unmodifiableMap(new HashMap (m)); } public Map getTestMap() { return testMap; } ... }
- John
In Groovy you can annotate the class as @Immutable and get /almost/ similar results to the scala example without all the boilerplate. IMHO Scala is better for it’s immutable support though. Also, don’t forget that Java Date, Dimension, and other JDK classes are not immutable as well, so you need to make defensive copies of those classes as well.
- Hamlet D’Arcy
Out of curiosity, why the requirement to have the class be marked as final so as not to be extended? What does disallowing subclasses actually provide in terms of allowing objects of this type to be immutable? Further, you don’t have to mark fields as private only just so long as you can guarantee that all constructor’s of the class properly initialize all of the fields. As a side note, you *can* have setters, but with the nuance that instead of changing an internal field, what the setter really does is specify a return type of the class the method is on, and then behind the scenes creates a new object using a constructor that accepts all internal fields, using the internally held state in for all params with the exception of the field represented by the setter called since you want the new object to have that field updated.
- whaley
Thanks, you know it and you know how to explain it too! I will definitely read more of your articles :)
- Mirey
Thanks for the detailed tutorial, well written and the flow goes exactely to showing up almost the need of every instruction in the code :) One side question, even if I know we are talking about Objects immutability,but what about the other instance variables you introucted in the FinalClassExample
(id, name)? Is there any way to make them immutable?
- Marwen
Should not be String name declared as a not final? Its not mutable anyway.
- Ramakant
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.
Full documentation for every DigitalOcean product.
The Wave has everything you need to know about building a business, from raising funding to marketing your product.
Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.
New accounts only. By submitting your email you agree to our Privacy Policy
Scale up as you grow — whether you're running one virtual machine or ten thousand.
Sign up and get $200 in credit for your first 60 days with DigitalOcean.*
*This promotional offer applies to new accounts only.