Java Defensive Copying Collections: Protect Internal State
Learn how to apply java defensive copying collections to protect internal state, avoid unintended mutation, and choose between copies and unmodifiable views.
When a class exposes one of its internal collections through a getter, callers can modify that collection directly, breaking invariants the class depends on. Java defensive copying collections solves this by creating a copy before handing it out, or by copying caller-provided collections on input. Without this protection, a seemingly simple getter can become a source of subtle bugs.
Why Defensive Copying Matters for Collections
Consider a class that maintains a list of active sessions. If the getter returns the internal ArrayList directly, any caller can call clear() or add() and corrupt the state. The class no longer controls its own data. This is an aliasing problem: the internal reference escapes, and the caller and the class share the same mutable object.
public class SessionManager { private final List<String> sessions = new ArrayList<>(); public List<String> getSessions() { return sessions; // Dangerous: caller can mutate internal list } }
Defensive copying breaks the shared reference. The getter returns a new collection containing the same elements, so mutations by the caller do not affect the original. The cost is a linear-time copy on every call, but the encapsulation is preserved.
Copying vs. Returning Unmodifiable Views
Java provides Collections.unmodifiableList, unmodifiableSet, and unmodifiableMap to create read-only views of a collection. These views throw UnsupportedOperationException if a caller tries to modify them. However, they are not copies. The view still points to the original collection, so if the internal collection changes later, the view reflects those changes.
| Approach | Snapshot? | Reflects internal changes? | Mutation by caller blocked? |
|---|---|---|---|
| Defensive copy | Yes | No | Yes |
| Unmodifiable view | No | Yes | Yes |
Use an unmodifiable view when you want callers to read the current state but not modify it, and when the internal collection may change over time. Use a defensive copy when you need a stable snapshot that will not be affected by future internal mutations.
Copying Common Collection Types
For most collection interfaces, the simplest defensive copy is to create a new instance of the concrete class and pass the original to its constructor.
List<String> copy = new ArrayList<>(originalList); Set<String> copySet = new HashSet<>(originalSet); Map<String, Integer> copyMap = new HashMap<>(originalMap);
These copies preserve the elements but not the collection's internal ordering guarantees unless you use the same concrete type. For example, copying a TreeSet into a HashSet loses sorted order. If ordering matters, use the same concrete type or a LinkedHashSet.
Java 10 introduced List.copyOf, Set.copyOf, and Map.copyOf, which return immutable copies. These are convenient but reject null elements and do not preserve iteration order for Set and Map in all cases.
List<String> immutableCopy = List.copyOf(originalList); Set<String> immutableSet = Set.copyOf(originalSet); Map<String, Integer> immutableMap = Map.copyOf(originalMap);
These methods are useful when you need a truly immutable snapshot and can accept the null restriction.
Defensive Copying on Input
Defensive copying is not only for getters. When a constructor or setter accepts a collection from a caller, the caller can still hold a reference to that collection and modify it after the object is created. Copying the input prevents later changes from leaking into the object.
public class EventLog { private final List<String> entries; public EventLog(List<String> entries) { this.entries = new ArrayList<>(entries); // Copy input } }
Without the copy, a caller could pass a list and then call add() on it, changing the EventLog's state without going through its methods. This is especially important when the collection is stored for a long time or shared across threads.
Performance and Memory Tradeoffs
Defensive copying has a real cost. Every copy allocates a new collection and copies all references, which is O(n) in time and memory. For large collections or frequent getter calls, this can add noticeable overhead. Unmodifiable views are essentially free because they only wrap the original reference.
In practice, the decision depends on how often the collection is accessed and how large it is. If a getter is called in a hot loop and the internal collection rarely changes, an unmodifiable view may be sufficient. If the collection is small or access is infrequent, the copy is safer and simpler to reason about.
Also consider that copying only protects the collection structure, not the elements themselves. If the elements are mutable, both the original and the copy share those element references. A caller can still mutate an element's internal state, which is a separate problem.
Common Mistakes and Edge Cases
One common mistake is using clone() on collection classes. Most collection implementations have a clone() method, but it is not guaranteed to be available on all implementations, and it is not a clean abstraction. Prefer constructor copies or copyOf.
Another issue is null handling. List.copyOf and Set.copyOf throw NullPointerException if any element is null. If your data may contain nulls, use the constructor approach or filter them first.
When copying a Map, remember that only the map structure is copied. The keys and values are shared. If you need to protect against mutation of mutable values, you must deep-copy those values as well.
Finally, be careful with arrays. Arrays are objects, and returning an internal array directly exposes the reference. Use Arrays.copyOf or clone the array before returning it.
Handling Mutable Elements Inside Copied Collections
A defensive copy of a collection does not protect you from mutation of the elements themselves. If the collection contains mutable objects, a caller can retrieve an element and change its fields, affecting the original object as well because both collections reference the same instances.
For example, if you have a List<Date> and you copy the list, the Date objects are still shared. To fully protect internal state, you need to deep-copy each element. This is more expensive and requires that the element types support copying, either through a copy constructor, a copy() method, or serialization.
List<Date> deepCopy = originalDates.stream() .map(date -> new Date(date.getTime())) .collect(Collectors.toList());
Deep copying is only necessary when the elements are mutable and you need to prevent callers from modifying them. If the elements are immutable (like String or Integer), a shallow copy is sufficient.
When designing an API, decide whether you need structural protection, element protection, or both. The choice affects the copy strategy and the performance characteristics. For most cases, a shallow copy of the collection is enough to prevent the most common mutation bugs, but you should document whether the returned collection shares element references with the internal state.