Report this

What is the reason for this report?

How to Create an Immutable Class in Java

Updated on August 7, 2025
How to Create an Immutable Class in Java

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 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.

Creating an Immutable Class in Java

To create an immutable class in Java, you need to follow these general principles:

  1. Declare the class as final so it can’t be extended.
  2. Make all of the fields private so that direct access is not allowed.
  3. Don’t provide setter methods for variables.
  4. Make all mutable fields final so that a field’s value can be assigned only once.
  5. Initialize all fields using a constructor method that performs a deep copy.
  6. 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:

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:

  1. javac FinalClassExample.java
  2. 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:

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 add return testMap;.

The example file should now look like this:

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:

  1. javac FinalClassExample.java
  2. java FinalClassExample

You get the following output:

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 input map 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 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.

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.

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() 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

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 if and else branches 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:

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 ImmutableMap or Java’s Map.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:
  • Immutable annotations (optional): Use annotations like @Immutable from 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:

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the author(s)

Pankaj Kumar
Pankaj Kumar
Author
See author profile

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

Andrea Anderson
Andrea Anderson
Editor
Technical Editor
See author profile
Manikandan Kurup
Manikandan Kurup
Editor
Senior Technical Content Engineer I
See author profile

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.

Category:
Tags:

Still looking for an answer?

Was this helpful?

… 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

It’s super webpage, I was looking for something like this

- Łomża Zuhlke

Thanks mate, great details… – Anish Sneh

- Anish Sneh

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

Creative CommonsThis work is licensed under a Creative Commons Attribution-NonCommercial- ShareAlike 4.0 International License.
Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.