Table of Contents
Introduction
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
synchronizedblocks.
- To make a class immutable, follow strict design rules. Use
finalfor 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
finalfields, 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, unsafeByteBufferaccess, 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.
Creating an Immutable Class in Java
To create an immutable class in Java, you need to follow these general principles:
- Declare the class as
finalso it can't be extended. - Make all of the fields
privateso that direct access is not allowed. - Don't provide setter methods for variables.
- Make all mutable fields
finalso that a field's value can be assigned only once. - Initialize all fields using a constructor method that performs a deep copy.
- Clone objects in the getter methods to return a copy rather than returning the actual object reference.
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:
[label FinalClassExample.java]
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:
[secondary_label Output]
Performing 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.
What happens when you don't use deep copy and cloning
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):
- Delete the constructor method providing deep copy and add the constructor method providing shallow copy that is highlighted in the following example.
- In the getter function, delete
return (HashMap<String, String>) testMap.clone();and addreturn testMap;.
The example file should now look like this:
[label FinalClassExample.java]
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:
[secondary_label Output]
Performing 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.
How to use Immutable Collection Libraries
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.
Guava's Immutable Collections
Google Guava provides a set of collection classes prefixed with Immutable, such as ImmutableList, ImmutableSet, and ImmutableMap. These collections are:
- Deeply immutable: They cannot be modified after creation.
- Safe to expose via getters: You can return them without worrying about leaks.
- Fail-fast: Any attempt to modify them results in an immediate
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:
- No need to clone the map in the getter.
- The class stays immutable even if the original
inputmap is changed after construction.
Vavr’s Persistent Data Structures
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:
- Every update creates a new map; the original stays untouched.
- Vavr collections integrate well with functional idioms like
map(),filter(), and pattern matching.
When to Use These Guava/Vavr
Use immutable collection libraries when:
- You need to return collection fields from your public API
- You want to simplify defensive copying and boilerplate
- You’re working in multi-threaded environments and want to guarantee immutability
- You’re adopting functional programming practices
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.
How to work with native memory and off-heap data in immutable classes
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.
What are the risks of using native memory or off-heap data?
1. Native Code Can Mutate "Final" Data
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.
2. Off-Heap Buffers May Be Mutated Through Shared References
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.
What are the best practices for safely using native or off-heap data?
Memory Management Patterns
- 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 withCleaner(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.
Real-World Scenarios Where This Matters
- 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.
Performance Considerations
Using defensive copies ensures immutability but may introduce:
- Memory Overhead: Copying large buffers or native memory can be expensive.
- Reduced Throughput: Creating new on-heap structures for every object can impact latency in high-performance systems.
Use defensive copying when:
- You're exposing data to external classes
- Native memory may be shared
- You're caching or sharing the object across threads
Avoid copying when:
- You control the full memory lifecycle
- The data is guaranteed to be read-only and short-lived
Testing Immutability with Native and Off-Heap Code
To ensure that your immutable classes remain truly immutable:
- Use Concurrency Tests: Run access from multiple threads to ensure no data races or visibility issues.
- Simulate Native Mutation: If possible, test with native code that tries to change memory after object construction.
- Assert Value Stability: Validate that values returned by the object never change once it’s created, even under stress conditions.
Interface Design Pitfalls That Break Immutability
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.
Pitfall 1: Returning a Mutable Object from a Getter
Let’s revisit our FinalClassExample and see what happens if we return a reference to a mutable field like HashMap.
[label FinalClassExample.java]
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.
Pitfall 2: Exposing Mutable Structures Through Public APIs
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:
- The returned interface is still backed by a mutable implementation.
- The caller may cast the object back to its concrete, mutable type.
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.
Solution: Return a Defensive Copy
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.
Alternative Solution: Return an Unmodifiable View
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.
Pitfall 3: Accepting External Mutable Objects Without Copying
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
Pitfall 4: Mutable Objects Within Immutable Wrappers
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
Solution: Deep Copy the Entire Structure
- Copy the outer map and the inner lists.
- Or return fully immutable structures using libraries like Guava or Java 10+
Map.copyOf()andList.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 |
Developer Advantages of Immutability
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.
Reduced Cyclomatic Complexity
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.
How Immutability Simplifies Logic
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:
- Fewer
ifandelsebranches inside methods - Shallower class hierarchies
- Reduced coupling between components
The result is lower average cyclomatic complexity, especially in domain-layer and controller-level code.
Easier Maintainability
The study measured maintainability using several metrics, such as:
- Code churn (lines of code changed per commit)
- Number of bugs per class
- Developer survey responses
Classes designed with immutable principles consistently showed lower maintenance overhead:
- Fewer fixes required after initial implementation
- Fewer breaking changes during refactoring
- Shorter onboarding times for new developers
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:
- Flatten control flow (fewer conditionals)
- Avoid state-dependent bugs
- Make objects easier to test and reuse
- Simplify refactoring and reduce maintenance costs
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).
Better Profiling and Debugging with Immutable Classes
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:
- View logs from production
- Inspect object state in a debugger
- Analyze memory snapshots or heap dumps
Immutable objects are especially helpful in tools like:
- VisualVM
- [Java Flight Recorder (JFR)](ttps://docs.oracle.com/javacomponents/jmc-5-4/jfr-runtime-guide/about.htm#JFRUH170)
- YourKit
- IntelliJ Memory Profiler
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.
What are the common errors when creating immutable classes?
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.
Returning Mutable Objects from Getters
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
}
Not Copying Constructor Arguments
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);
Using Unmodifiable Views Without Copying
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));
Shallow Copies for Nested Collections
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.
Declaring Fields as final but Still Mutating Them
Mistake: 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.
Leaking this During Construction
Mistake: 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.
Assuming Interface Immutability
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);
Mishandling Off-Heap or Native Memory
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.
Not Preventing Inheritance
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.
Best Practices for Writing Immutable Classes in Java
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.
Mark the Class as 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.
Declare All Fields as 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.
Perform Defensive Copies of Mutable Inputs
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.
Never Return Mutable Internal State
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.
Use Immutable Collection Libraries
Prefer libraries like:
- Guava (
ImmutableMap,ImmutableList, etc.) - Vavr (
Map,List,Set, etc. with persistent structures) - Java 10+ (
Map.copyOf(),List.copyOf())
These libraries provide safe, deeply immutable collections and remove boilerplate from defensive copying.
Avoid Exposing this in Constructors
Don’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.
Sanitize and Validate Constructor Inputs
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.
Document Immutability Clearly
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.
Use Factory Methods for Complex Object Construction
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.
Benchmark and Profile for Performance
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.
FAQs
1. What is an immutable class in Java with example?
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:
- The class is marked
final(cannot be subclassed) - All fields are
private final - No setters or mutator methods
- Constructor copies mutable inputs defensively
- Exposed fields are either immutable themselves or returned via safe copies
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.
2. Why are immutable classes important in multithreading?
Immutable classes are inherently thread-safe because their state cannot change after construction. This eliminates:
- The need for synchronization (
synchronized,Lock,volatile, etc.) - Race conditions caused by shared mutable state
- Bugs due to visibility issues in the Java Memory Model (JMM)
Since multiple threads can read immutable objects concurrently without coordination, they’re ideal for use in:
- Caches
- Configuration objects
- Keys in maps or sets
- Message passing and DTOs in concurrent systems
Example:
ImmutableConfig config = new ImmutableConfig("host", 8080);
// Safe to share across threads — no locks needed
3. How do I prevent modification of Java class fields?
To prevent modification of fields in a class, follow these guidelines:
- Mark fields
private final: Ensures the field is assigned only once and cannot be accessed directly. - Do not provide setters or mutator methods
- Copy mutable constructor inputs: Prevent external references from being held.
- Avoid returning mutable fields directly: Use defensive copies or immutable collections.
- Use immutable collection libraries: Like Guava’s
ImmutableMapor Java’sMap.copyOf(). - Mark the class
final: Prevents subclassing which can override or introduce mutability.
4. What’s the best way to create an immutable DTO in Java?
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:
- All fields are final and private
- The class has a constructor with all required fields
- No setters are included
- Collections are either deeply copied or immutable
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.
5. Can I still use mutable objects inside an immutable class?
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:
- Make a deep copy of the object in the constructor
- Never expose the original reference in a getter
- Avoid methods that modify internal state
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.
6. Are Java records immutable?
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- Initialized via the canonical constructor
- Not modifiable after creation
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.
7. What happens if I skip deep copying in an immutable class?
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.
8. How can I test if my class is truly immutable?
Here are a few strategies to test immutability:
- Check for field reassignments: Ensure all fields are
private final. - Try mutating returned objects: If a getter returns a collection or object, try modifying it and check if the internal state changes.
- Use multi-threaded tests: Run concurrent reads and ensure the state remains consistent across threads.
- Automated tools: Use static analysis tools like:
- Error Prone
- SonarQube
- IntelliJ inspections
- Immutable annotations (optional): Use annotations like
@Immutablefrom tools like Checker Framework or JSR-305 to enforce and document immutability at compile time.
Conclusion
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:
- Why String is Immutable in Java? – Covers the reasons behind the immutability of the String class in Java.
- String vs StringBuffer vs StringBuilder – Compares and contrasts the String, StringBuffer, and StringBuilder classes in Java.
- Java String – Provides an in-depth look at the Java String class, its methods, and usage.
- Java Constructor Tutorial: Learn Basics and Best Practices – Teaches the basics of constructors in Java, including their syntax, types, and best practices.
- Multithreading in Java: Concepts, Examples, and Best Practices – Covers the fundamentals of multithreading in Java, including thread creation, synchronization, and best practices.
- Java Multithreading Concurrency Interview Questions and Answers – Prepares developers for common multithreading and concurrency interview questions in Java.