Java Immutable Collections: Creating and Using Unmodifiable Data
java immutable collections: Learn how to create immutable collections in Java using List.of, Set.of, and Map.of, and understand the difference between immutable and un...
java immutable collections requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
A common source of bugs in Java applications is a mutable collection that escapes its intended scope. Consider a class that exposes a private list through a getter:
public class Order { private List<String> items = new ArrayList<>(); public List<String> getItems() { return items; } }
Any caller of getItems() can call add() or remove() on the returned list, mutating the internal state of the Order object. This becomes a maintenance hazard when multiple parts of the codebase share the same list. Java immutable collections solve this by making the collection itself reject modification, so the data structure cannot be changed after creation.
The Problem: Mutable Collections Leak State
When a collection is mutable and shared, the original owner loses control over its contents. A simple getter that returns the internal list directly is a classic design flaw. Even if the getter wraps the list in Collections.unmodifiableList(), the underlying list can still be modified if the reference is held elsewhere. For example:
List<String> original = new ArrayList<>(); original.add("A"); List<String> unmodifiable = Collections.unmodifiableList(original); original.add("B"); // unmodifiable now also contains "B"
The wrapper only prevents changes through the wrapper reference; the original list remains mutable. This is why the Java 9 collection factory methods (List.of, Set.of, Map.of) were introduced: they create collections that are genuinely immutable, not just wrapped views.
Creating an Immutable List with List.of
The simplest way to create an immutable list in Java is to use List.of. This static factory method returns a list that cannot be modified, and it is available since Java 9.
List<String> fruits = List.of("apple", "banana", "cherry");
Any attempt to call add, remove, set, or clear on this list throws an UnsupportedOperationException. The list also does not allow null elements. Trying to pass null to List.of throws a NullPointerException at creation time. This behavior is intentional: immutable collections should not contain null because a null value often indicates an uninitialized or missing element, which is incompatible with a fixed, fully specified data set.
List.of has overloads for zero to ten elements, plus a varargs version for larger lists. The varargs version creates a copy of the input array, so later changes to the original array do not affect the immutable list.
String[] array = {"a", "b"}; List<String> list = List.of(array); array[0] = "z"; // list still contains "a"
This copy-on-creation behavior is important when you receive data from an external source and want to freeze it.
Creating Immutable Sets and Maps
Set.of and Map.of work the same way. Set.of returns an immutable set that rejects duplicates and null elements. Map.of accepts key-value pairs as arguments and returns an immutable map. The map also rejects null keys and values, and duplicate keys cause an IllegalArgumentException at creation time.
Set<String> colors = Set.of("red", "green", "blue"); Map<String, Integer> scores = Map.of( "alice", 90, "bob", 85 );
For maps with more than ten key-value pairs, use Map.ofEntries, which takes Map.Entry objects. This avoids the overhead of a varargs array of alternating keys and values, and it also prevents accidental key-value misalignment.
Map<String, Integer> largeMap = Map.ofEntries( Map.entry("a", 1), Map.entry("b", 2), Map.entry("c", 3) // ... );
These factory methods are deliberately concise. They are the preferred way to define constant collections in your code, such as configuration defaults or lookup tables.
What Happens When You Try to Modify an Immutable Collection
All immutable collections created by List.of, Set.of, and Map.of throw UnsupportedOperationException when you attempt any structural modification. This includes methods like add, remove, clear, put, and replaceAll. The exception is thrown immediately, so the collection never enters a partially modified state.
List<String> list = List.of("a", "b"); try { list.add("c"); } catch (UnsupportedOperationException e) { System.out.println("Cannot modify immutable list"); }
This behavior is a contract, not an implementation detail. The collection is not just wrapped; it is a distinct implementation that does not support mutation. This makes the immutability reliable even when the collection is passed through multiple layers of code.
Unmodifiable View vs. Immutable Copy
It is easy to confuse Collections.unmodifiableList() with List.of(), but they serve different purposes. An unmodifiable view is a wrapper around an existing mutable collection. The view itself rejects modifications, but the underlying collection can still change if you hold a reference to it. An immutable collection, by contrast, has no mutable backing structure. Once created, its contents are fixed for the lifetime of the object.
| Aspect | Unmodifiable View | Immutable Collection |
|---|---|---|
| Backing collection | Mutable, external reference | None, self-contained |
| Null elements | Depends on backing collection | Always rejected |
| Modification attempt | Throws UnsupportedOperationException | Throws UnsupportedOperationException |
| Memory overhead | Small wrapper object | May be more compact |
| Use case | Exposing a mutable internal list | Defining fixed constants |
If you need to expose a collection that might still be updated internally, an unmodifiable view is appropriate. If you want a snapshot that cannot change, use an immutable collection. For example, a service that returns a list of supported currencies should use List.of to guarantee the list never changes, while a class that maintains a dynamic list of listeners might return an unmodifiable view to prevent external modification while still allowing internal updates.
Version and Compatibility Considerations
The factory methods List.of, Set.of, and Map.of were introduced in Java 9. If you are working with Java 8 or earlier, these methods are not available. In that case, you can use Collections.unmodifiableList(new ArrayList<>(input)) to create a copy that is effectively immutable, but the copy is still an unmodifiable view of a list that no one else holds a reference to. This is a common workaround, but it is less efficient and does not reject null elements by default.
Another compatibility concern is serialization. Immutable collections created by the factory methods are serializable if their elements are serializable. However, the serialized form is not guaranteed to be compatible across Java versions. If you rely on serialization for long-term storage, consider using a standard ArrayList or HashMap and then wrapping it, or use a library like Guava's ImmutableList which has a more stable serialization contract.
Performance and Memory Characteristics
Immutable collections are often more memory-efficient than their mutable counterparts. Because they cannot be modified, they can share internal structures. For example, List.of() (empty list) returns a singleton instance, and List.of("a") may use a specialized single-element implementation. The exact memory layout is not specified by the Java API, but the implementation is free to optimize for the number of elements.
Creating an immutable collection from an existing collection requires copying the elements. This is an O(n) operation, but it happens once at creation time. Subsequent reads are fast because there is no need for defensive copies. If you frequently pass collections between components, using immutable collections can reduce the need for copying and reduce the risk of accidental modification.
In a multi-threaded environment, immutable collections are inherently thread-safe. Since no thread can modify the collection, you do not need synchronization or concurrent collection classes. This simplifies reasoning about shared state. However, the elements themselves may still be mutable if they are objects with setters. Immutability of the collection does not guarantee immutability of its elements.
Choosing Between Immutable and Unmodifiable Collections
The decision depends on whether you need a snapshot or a live view. Use List.of, Set.of, or Map.of when:
- You are defining constants that should never change.
- You want to protect a collection from being modified by any caller.
- You are creating a collection from data that is already fully known and will not be updated.
Use Collections.unmodifiableList or similar wrappers when:
- You need to expose a collection that is backed by a mutable structure that may still be updated internally.
- You are working with Java 8 or earlier and cannot use the factory methods.
- You want to avoid copying a large collection for performance reasons, and you can guarantee that the backing collection will not be modified after the wrapper is created.
In practice, immutable collections are the safer default for most API designs. They make the contract explicit and prevent a whole class of bugs related to unintended state changes.
Common Pitfalls and Edge Cases
One common mistake is trying to sort or reverse an immutable list. Methods like Collections.sort() and list.sort() require a mutable list and will throw UnsupportedOperationException. If you need a sorted version, create a new mutable list from the immutable one, sort it, and then optionally wrap it again.
List<String> sorted = new ArrayList<>(List.of("b", "a")); Collections.sort(sorted); List<String> immutableSorted = List.copyOf(sorted);
List.copyOf is another factory method that creates an immutable list from any collection, preserving the iteration order. It also rejects null elements. Similarly, Set.copyOf and Map.copyOf exist for sets and maps. These are useful when you receive a collection from an untrusted source and want to make a defensive copy.
Another edge case is the interaction with contains and indexOf. Immutable collections implement these methods normally, but they may use specialized algorithms because the collection size is fixed. For example, List.of with a small number of elements may use a simple array, so contains is O(n). This is not a performance problem for typical constant collections.
Finally, remember that immutability applies to the collection structure, not the objects stored in it. If you store a StringBuilder in an immutable list, you can still modify the StringBuilder instance. For true deep immutability, you must ensure the elements themselves are immutable or use defensive copying when reading them.
When designing an API, prefer returning immutable collections from getters and constructors. This communicates that the caller cannot change the data and reduces the cognitive load of tracking who might mutate a shared collection. The Java immutable collections introduced in Java 9 are the cleanest way to achieve this, and they are now the standard tool for this purpose.