How to Properly Work with Collections in Immutable Classes
Collections represent the most vulnerable and nuanced component in immutable class design. A Java collection is a mutable reference type; therefore, declaring a collection field...
🟢 Junior Level
30-Second Summary
Collections represent the most vulnerable and nuanced component in immutable class design. A Java collection is a mutable reference type; therefore, declaring a collection field as final is not sufficient.
To guarantee 100% immutability, you must follow the 3 Golden Rules:
- Never Retain External References (Ingress): In constructors, always extract an isolated defensive copy (Defensive Copy) via
List.copyOf(). - Never Expose Mutable References (Egress): If the internal field is already immutable (
List.copyOf()), return it directly; if the field is a mutable collection, wrap it inCollections.unmodifiableList(). - Enforce Deep Immutability: If elements inside the collection are mutable objects, protecting the list container is not enough — you must perform a Deep Copy of every contained element.
Code Example: Vulnerable vs. Secure Immutable Class
// ❌ VULNERABLE DESIGN:
public final class VulnerableGroup {
private final List<String> members; // final protects only the list reference!
public VulnerableGroup(List<String> members) {
this.members = members; // FLAW 1: Retains raw external reference!
}
public List<String> getMembers() {
return members; // FLAW 2: Returns direct pointer to mutable list!
}
}
// ✅ SECURE IMMUTABLE DESIGN (Java 10+):
public final class ImmutableGroup {
private final List<String> members;
public ImmutableGroup(List<String> members) {
// Defensive copy isolates state from external mutations and forbids alteration:
this.members = (members == null) ? List.of() : List.copyOf(members);
}
public List<String> getMembers() {
// Safe: List.copyOf already produces an unmodifiable, thread-safe collection
return members;
}
}
🟡 Middle Level
Evolution of Collection Defense Across Java Releases
- Legacy Java (Java 8 and earlier):
// Required two distinct steps: copy raw data and wrap in unmodifiable decorator this.list = Collections.unmodifiableList(new ArrayList<>(inputList)); - Modern Java (Java 10+ Standard):
this.list = List.copyOf(inputList); // Creates a compact, true immutable snapshot! - Java 16+ (Stream API):
this.list = inputList.stream().map(Item::copy).toList(); // Produces unmodifiable listEquivalent factory methods exist for sets and maps:
Set.copyOf(set)andMap.copyOf(map).
The Shallow Immutability Trap with Mutable Elements
List.copyOf() guarantees that container mutation methods (add(), remove(), clear()) throw UnsupportedOperationException. However, if the list contains standard mutable objects, their internal state can still be modified:
public record Department(String name, List<Employee> staff) {
public Department {
staff = List.copyOf(staff); // Freezes list structure, NOT Employee objects!
}
}
// State corruption exploit:
Department dept = getDepartment();
// dept.staff().add(...) fails with UnsupportedOperationException, but:
dept.staff().get(0).setSalary(1_000_000); // 💥 Department internal state mutated!
The Deep Copy Solution
When contained elements are mutable, clone each element individually inside the constructor:
public record Department(String name, List<Employee> staff) {
public Department {
staff = (staff == null) ? List.of() : staff.stream()
.map(Employee::new) // Uses copy constructor to clone each employee
.toList();
}
}
Note: If list elements are already immutable (String, Long, record, LocalDate), deep cloning is redundant.
🔴 Senior Level
Records and Collections: The Compact Constructor Rule
In Java records, component accessors are generated automatically by the compiler and return direct references to underlying fields. Therefore, the compact constructor is the sole defensive barrier for collections:
public record SecurityPolicy(String name, Set<String> permissions) {
public SecurityPolicy {
Objects.requireNonNull(name, "Name required");
// Mandatory defensive copy:
permissions = (permissions == null) ? Set.of() : Set.copyOf(permissions);
}
}
JMM §17.5 Final Field Freeze and Collections
Senior engineers distinguish between freezing an object reference and freezing memory contents:
- The
finalmodifier onprivate final List<String> listguarantees a Freeze Action under JMM §17.5 only for the referencelistitself. - If the field references an
ArrayList, the internalObject[] elementDataarray andint sizefield insideArrayListlack JMM final freeze guarantees. - Another CPU core reading the newly published object could read the list pointer but observe partially initialized array contents or
size = 0. - Senior Insight:
List.copyOf()is essential not only for immutability, but for Safe Publication under the Java Memory Model, because OpenJDK’sImmutableCollectionsimplementations use strictlyfinalfields for their backing storage arrays.
The $O(1)$ Fast-Path Optimization in List.copyOf()
The OpenJDK implementation includes an efficient runtime short-circuit:
static <E> List<E> copyOf(Collection<? extends E> coll) {
if (coll instanceof ImmutableCollections.AbstractImmutableList) {
return (List<E>) coll; // Returns existing reference in O(1) with zero allocation!
}
return (List<E>) List.of(coll.toArray());
}
If an immutable list created via List.of() or List.copyOf() is passed into List.copyOf(), the JVM does not duplicate the array; it returns the instance itself in $O(1)$ time with zero GC allocation overhead.
4 Tricky Questions
1. What happens if you pass a collection containing null elements to List.copyOf()? How should this be handled?
Answer:
List.copyOf() immediately throws a NullPointerException, because modern immutable collections introduced in Java 9+ are strictly null-hostile.
If the domain model genuinely permits null values:
- Use the Java 8 approach:
Collections.unmodifiableList(new ArrayList<>(input)), which toleratesnullelements. - Or use
Stream.toList()(Java 16+), which produces an unmodifiable list that safely permitsnullelements:this.list = (input == null) ? List.of() : input.stream().toList();
2. Does calling Set.copyOf(sortedSet) properly protect a SortedSet or TreeSet? What hidden hazards occur?
Answer:
While Set.copyOf(sortedSet) prevents modifications, it completely destroys element ordering and the Comparator contract:
Set.copyOf()returns anImmutableCollections.SetNinstance, which is an unordered hash set with randomized iteration order.- Natural sorting and custom comparator logic are discarded.
- Correct Protection: For ordered sets, use the dedicated unmodifiable wrappers:
this.sortedSet = Collections.unmodifiableSortedSet(new TreeSet<>(sortedSet)); // or Collections.unmodifiableNavigableSet(new TreeSet<>(navigableSet));
3. Why is creating a defensive copy via new ArrayList<>(input) followed by Collections.unmodifiableList() prone to TOCTOU and concurrency exceptions?
Answer:
If the incoming input collection is concurrently modified by another thread during construction:
new ArrayList<>(input)invokesinput.toArray().- If the other thread calls
input.remove()simultaneously, the array length and element count desynchronize, causingArrays.copyOf()to throw anIndexOutOfBoundsExceptionor store trailingnulls. - Furthermore, if validation checks (
if (input.size() > 10) throw ...) occur before the copy, a concurrent thread can alter the size after the check (Time-of-Check to Time-of-Use — TOCTOU). - Rule: Always perform validation strictly AFTER the local defensive copy has been captured.
4. When is the Vavr library preferred over standard List.copyOf() when designing immutable domain classes?
Answer: Vavr is preferred when immutable domain objects frequently evolve (i.e. elements are added or removed during business flows):
- With
List.copyOf(), appending an element requires creating an entirely new list and copying all $N$ elements in $O(N)$ time. For large collections, this creates severe memory churn and GC pressure. - Vavr’s persistent data structures use Structural Sharing: prepending or appending to an
io.vavr.collection.Listtakes $O(1)$ time and memory by reusing the unchanged tail. - For high-frequency updates on large collections, Vavr eliminates GC churn and preserves throughput.
🎯 Interview Cheat Sheet
Summary: 3 Rules for Collections in Immutable Classes
- Ingress (Constructor): Always capture an isolated snapshot (
List.copyOf(input)). - Egress (Getter): Return unmodifiable collections directly (
List.copyOfresults are already safe). - Depth (Elements): If contained elements are mutable, perform a Deep Copy of every element.
Comparison of Collection Defense Methods
| Protection Method | Java Version | Copies Data? | Permits null? |
Decoupled from Original? |
|---|---|---|---|---|
List.copyOf(list) |
Java 10+ | ✅ Yes ($O(N)$ or $O(1)$) | ❌ Throws NPE | 🟢 Completely decoupled |
Collections.unmodifiableList(list) |
Java 1.2+ | ❌ No (Wrapper view) | ✅ Yes | 🔴 Linked to original |
Collections.unmodifiableList(new ArrayList<>(list)) |
Java 1.2+ | ✅ Yes ($O(N)$) | ✅ Yes | 🟢 Completely decoupled |
stream.toList() |
Java 16+ | ✅ Yes ($O(N)$) | ✅ Yes | 🟢 Completely decoupled |
Red Flags to Avoid
- ❌ “Declaring
private final List<String> listprevents modifications.” (False;finalonly protects the reference, elements can still be added vialist.add()). - ❌ “Wrapping an input list in
Collections.unmodifiableList(input)inside the constructor is safe.” (False; the caller retains the original list reference and can mutate it). - ❌ “
List.copyOf()makes mutable objects inside the list immutable.” (False; it provides shallow immutability for the container only).
Related Topics
- What to Do If Class Field References a Mutable Object — Handling mutable fields
- What is Defensive Copy — Defensive copying tenets
- How to Protect a Collection from Modification — Protection strategies
- Difference Between Shallow Copy and Deep Copy — Copy depth
- What is Record and How Does It Help Create Immutable Classes — Java Records