1. The problem of mutable collections
In Java, collections are like a warehouse of goods: anyone can come in and add, remove, or change something. Sometimes that’s convenient, but in large programs it becomes a headache. Imagine you exposed a list of products from your class, and someone deleted half the items. Or—what’s even more “fun”—in a multithreaded program one thread adds elements while another reads them: the result can be unexpected, and the bugs hard to catch (for example, ConcurrentModificationException).
Here’s an example of why mutability of collections is a source of bugs:
import java.util.*;
public class Inventory {
private List<String> products = new ArrayList<>();
public Inventory() {
products.add("Tea");
products.add("Coffee");
}
public List<String> getProducts() {
// DANGEROUS! Returning a reference to the internal list
return products;
}
}
public class Main {
public static void main(String[] args) {
Inventory inv = new Inventory();
List<String> external = inv.getProducts();
external.remove("Tea"); // Oops! Now the inventory has no tea
System.out.println(inv.getProducts()); // [Coffee]
}
}
Notice the catch? One method returns the internal collection, another changes it. This way you can accidentally destroy data that should be protected.
2. Creating immutable collections: Collections.unmodifiable*
To avoid such mishaps, Java offers effective protection: you can make a collection “immutable” using special wrappers from the Collections class:
- Collections.unmodifiableList(list)
- Collections.unmodifiableSet(set)
- Collections.unmodifiableMap(map)
How does it work? First you create a normal collection, and then you wrap it in an “immutable” shell:
import java.util.*;
public class Main {
public static void main(String[] args) {
List<String> drinks = new ArrayList<>();
drinks.add("Tea");
drinks.add("Coffee");
List<String> immutableDrinks = Collections.unmodifiableList(drinks);
System.out.println(immutableDrinks); // [Tea, Coffee]
// Let's try to add an element
immutableDrinks.add("Cocoa"); // Boom! UnsupportedOperationException
}
}
Any attempt to modify such a collection results in an UnsupportedOperationException being thrown. It’s as if you slapped a huge “DO NOT TOUCH!” sticker on the box — anyone who tries to add or remove something will get slapped on the wrist (or on the call stack).
Example: protecting internal state
Let’s fix our Inventory class from the previous example:
import java.util.*;
public class Inventory {
private List<String> products = new ArrayList<>();
public Inventory() {
products.add("Tea");
products.add("Coffee");
}
public List<String> getProducts() {
// Now we return a wrapper
return Collections.unmodifiableList(products);
}
}
Now, if someone tries to modify the returned list, they will get an exception.
3. Behavior of immutable collections: shallow protection
It’s important to understand: unmodifiableList and its “siblings” only wrap the original collection. They don’t create a copy—any changes to the original collection (the one “inside”) will be visible through the wrapper!
Demonstration
import java.util.*;
public class Main {
public static void main(String[] args) {
List<String> drinks = new ArrayList<>();
drinks.add("Tea");
List<String> immutableDrinks = Collections.unmodifiableList(drinks);
drinks.add("Coffee"); // Changing the original collection
System.out.println(immutableDrinks); // [Tea, Coffee] — the element appeared!
}
}
Conclusion: the wrapper protects only against changes made through the wrapper itself. If someone holds a reference to the original collection, they can still modify it.
4. Deep immutability: myths and reality
The unmodifiable* wrappers make the collection immutable only from the outside. But if the collection contains mutable objects, those objects can still be changed!
Example
import java.util.*;
class Product {
String name;
Product(String name) {
this.name = name;
}
public String toString() {
return name;
}
}
public class Main {
public static void main(String[] args) {
List<Product> products = new ArrayList<>();
products.add(new Product("Tea"));
List<Product> immutableProducts = Collections.unmodifiableList(products);
// Mutating an object inside the collection
immutableProducts.get(0).name = "Coffee";
System.out.println(immutableProducts); // [Coffee]
}
}
Conclusion:
- The collection is “immutable”, but the objects inside are not.
- For full (deep) immutability, use immutable objects (for example, String, Integer, record classes, or make your own classes immutable).
5. When to use immutable collections
To protect internal state
If you write a class that stores a collection and expose it, always return a wrapper so that no one can accidentally (or intentionally) change your data:
public List<String> getProducts() {
return Collections.unmodifiableList(products);
}
In multithreaded programs
In multithreaded applications, mutable collections are a source of problems (race conditions, ConcurrentModificationException, and other “fun surprises”). If a collection doesn’t need to change after creation, make it immutable.
For passing data between layers
If you pass a collection from one layer of the application to another (for example, from DAO to service), pass an immutable copy or a wrapper—this protects against accidental changes.
6. Practical examples
Example 1: protecting a list of students
import java.util.*;
public class Group {
private final List<String> students = new ArrayList<>();
public void addStudent(String name) {
students.add(name);
}
public List<String> getStudents() {
return Collections.unmodifiableList(students);
}
}
Now no one can add or remove a student directly via getStudents().
Example 2: immutable map (Map)
import java.util.*;
public class Main {
public static void main(String[] args) {
Map<String, Integer> grades = new HashMap<>();
grades.put("John", 5);
grades.put("Mary", 4);
Map<String, Integer> immutableGrades = Collections.unmodifiableMap(grades);
// immutableGrades.put("Peter", 3); // UnsupportedOperationException
}
}
7. Useful nuances
Modern alternatives: List.of, Set.of, Map.of
Java 9 introduced even more convenient ways to create immutable collections:
List<String> drinks = List.of("Tea", "Coffee");
Set<String> fruits = Set.of("Apple", "Banana");
Map<String, Integer> ages = Map.of("John", 20, "Mary", 21);
- These collections are immutable (any attempt to modify them throws an exception).
- They have no “backing” mutable collection (unlike Collections.unmodifiable*).
- They do not allow null values.
Also, since Java 10 there are copy methods: List.copyOf, Set.copyOf, Map.copyOf—they create an immutable copy of the provided collection.
Comparing ways to create immutable collections
| Approach | Deep immutability | Can the backing collection be mutated? | Allows null? | Java version |
|---|---|---|---|---|
|
No | Yes | Yes | 1.2 |
|
No | No (no backing collection) | No | 9+ |
8. Common mistakes when working with immutable collections
Mistake #1: Mutating the original collection after creating a wrapper. You created an unmodifiableList, and then someone changes the original list. The wrapper won’t protect against that—the changes will be visible everywhere the wrapper is used.
Mistake #2: Expecting deep immutability. Many people think that if a collection is immutable, then the objects inside it cannot be changed either. In reality, only the structure is protected (adding/removing/changing through the collection), but not the contents of the objects.
Mistake #3: Using immutable collections with a null value in modern factories. Collections created via List.of, Set.of, Map.of do not allow null. Attempting to add or obtain null will result in an exception.
Mistake #4: Exposing references to mutable collections. If you return a reference to an internal collection (without a wrapper), you lose control over your data—a direct path to bugs and invariant leaks.
Mistake #5: Using immutable collections in code that expects mutability. If third-party code tries to modify the collection (for example, add an element), it will get an UnsupportedOperationException. Make sure consumers know the data is immutable.
GO TO FULL VERSION