How does Collections.unmodifiableList() work internally
The Collections.unmodifiableList() utility method is implemented via the classic Decorator (Wrapper) Pattern. It does not construct a new list, clone arrays, or copy elements in...
🟢 Junior Level
1. Architectural Foundation
The Collections.unmodifiableList() utility method is implemented via the classic Decorator (Wrapper) Pattern. It does not construct a new list, clone arrays, or copy elements in memory. Instead, it instantiates an instance of an internal package-private class (Collections.UnmodifiableList) that retains a direct reference to the original backing list.
- Read Operations (
get(),size(),contains(),indexOf()) are transparently forwarded to the underlying backing list. - Mutator Operations (
add(),set(),remove(),clear(),sort(),replaceAll()) are explicitly overridden to unconditionally throwUnsupportedOperationException.
List<String> rawList = new ArrayList<>();
rawList.add("A");
List<String> unmodifiable = Collections.unmodifiableList(rawList);
unmodifiable.get(0); // Delegated call: rawList.get(0) -> returns "A"
unmodifiable.add("B"); // Intercepted call: throws new UnsupportedOperationException()
2. Invocation Delegation Schema
Call: get(0)
Caller ────────────────────────► UnmodifiableList (Decorator)
│
│ Delegates: list.get(0)
▼
ArrayList (Backing List)
│
Returns element "A"
Call: add("X")
Caller ────────────────────────► UnmodifiableList (Decorator)
│
▼
throw new UnsupportedOperationException()
🟡 Middle Level
1. OpenJDK Source Code Dissection
Inside java.util.Collections, the decorator hierarchy is structured across nested static classes:
// Base decorator for all collection types:
static class UnmodifiableCollection<E> implements Collection<E>, Serializable {
private static final long serialVersionUID = 1820017757991046078L;
final Collection<? extends E> c; // Reference to backing collection
UnmodifiableCollection(Collection<? extends E> c) {
if (c == null) throw new NullPointerException();
this.c = c;
}
// Read methods delegate directly:
public int size() { return c.size(); }
public boolean isEmpty() { return c.isEmpty(); }
public boolean contains(Object o) { return c.contains(o); }
// Mutator methods are unconditionally blocked:
public boolean add(E e) { throw new UnsupportedOperationException(); }
public boolean remove(Object o) { throw new UnsupportedOperationException(); }
public boolean addAll(Collection<? extends E> coll) { throw new UnsupportedOperationException(); }
public void clear() { throw new UnsupportedOperationException(); }
// Iterators are wrapped in read-only proxies:
public Iterator<E> iterator() {
return new Iterator<E>() {
private final Iterator<? extends E> i = c.iterator();
public boolean hasNext() { return i.hasNext(); }
public E next() { return i.next(); }
public void remove() { throw new UnsupportedOperationException(); }
};
}
}
The specialized class UnmodifiableList extends UnmodifiableCollection and implements the List interface:
static class UnmodifiableList<E> extends UnmodifiableCollection<E> implements List<E> {
final List<? extends E> list;
UnmodifiableList(List<? extends E> list) {
super(list);
this.list = list;
}
public E get(int index) { return list.get(index); }
public int indexOf(Object o) { return list.indexOf(o); }
public int lastIndexOf(Object o) { return list.lastIndexOf(o); }
// Positional mutators blocked:
public E set(int index, E element) { throw new UnsupportedOperationException(); }
public void add(int index, E element) { throw new UnsupportedOperationException(); }
public E remove(int index) { throw new UnsupportedOperationException(); }
// Java 8 default method overrides:
public void replaceAll(UnaryOperator<E> operator) { throw new UnsupportedOperationException(); }
public void sort(Comparator<? super E> c) { throw new UnsupportedOperationException(); }
public List<E> subList(int fromIndex, int toIndex) {
return new UnmodifiableList<>(list.subList(fromIndex, toIndex));
}
}
2. Iterator Protection: UnmodifiableListIterator
Calling listIterator() returns an UnmodifiableListIterator wrapper that blocks positional insertions and modifications:
ListIterator<String> it = unmodifiable.listIterator();
it.next(); // ✅ Allowed (reads next element)
it.set("Z"); // ❌ Throws UnsupportedOperationException!
it.add("W"); // ❌ Throws UnsupportedOperationException!
🔴 Senior Level
1. Dynamic Factory Branching: Preserving RandomAccess
In java.util.Collections, the factory method conditionally selects one of two wrapper classes:
public static <T> List<T> unmodifiableList(List<? extends T> list) {
return (list instanceof RandomAccess ?
new UnmodifiableRandomAccessList<>(list) :
new UnmodifiableList<>(list));
}
static class UnmodifiableRandomAccessList<E> extends UnmodifiableList<E> implements RandomAccess {
UnmodifiableRandomAccessList(List<? extends E> list) {
super(list);
}
@Override
public List<E> subList(int fromIndex, int toIndex) {
return new UnmodifiableRandomAccessList<>(list.subList(fromIndex, toIndex));
}
}
Algorithmic Significance:
The RandomAccess interface is a marker interface. Standard algorithms (like Collections.binarySearch()) inspect whether a target list implements RandomAccess:
- If true (e.g.,
ArrayList), the algorithm uses $O(1)$ index looping viaget(i). - If false (e.g.,
LinkedList), the algorithm falls back to iterator traversal to avoid quadratic $O(N^2)$ slowdowns. By preservingRandomAccessthroughUnmodifiableRandomAccessList, the wrapper ensures standard algorithms maintain optimal performance.
2. JIT Inlining & Zero-Cost Delegation
At the machine-code level, invoking unmodifiableList.get(i) introduces almost zero execution overhead:
- When code executes in a tight loop, the HotSpot C2 JIT Compiler performs Method Inlining.
- Because the method is a single-statement forwarder (
return list.get(index)), the call boundary is eliminated. - Monomorphic call-site profiling resolves the direct memory address of the backing array, compiling
unmodifiableList.get(i)into a direct indexed memory read in 1 CPU clock cycle.
3. Java 9+ Module Encapsulation vs. Legacy Reflection Bypasses
In Java 8 and earlier, untrusted code could bypass unmodifiableList using reflection:
// Reflection bypass vulnerability (Java 8):
Field listField = unmodifiableList.getClass().getDeclaredField("list");
listField.setAccessible(true);
List<String> rawBackingList = (List<String>) listField.get(unmodifiableList);
rawBackingList.add("Hacked"); // Successfully mutated the backing list!
Modern Mitigation in Java 9+ (JPMS):
The Java Platform Module System strictly encapsulates java.base/java.util. Attempting to invoke field.setAccessible(true) on internal collection fields throws an InaccessibleObjectException, preventing unauthorized access to the private list reference.
4 Tricky Questions
1. Why did OpenJDK explicitly override replaceAll() and sort() in UnmodifiableList in Java 8 when default interface methods already existed?
Answer:
In Java 8, List introduced default implementations for sort() and replaceAll(). By default, List.sort() converts the list to an array, sorts the array, and iterates over the list setting elements via listIterator.set().
If UnmodifiableList had not explicitly overridden sort(), invoking unmodList.sort() would successfully perform the entire sort computation and only throw UnsupportedOperationException midway through when the iterator reached set(), leaving the list in a corrupted, partially mutated state.
Explicitly overriding sort() and replaceAll() ensures Fail-Fast rejection before any computation or element access begins.
2. Why does OpenJDK implement two separate wrapper classes: UnmodifiableRandomAccessList and UnmodifiableList?
Answer:
RandomAccess is a marker interface.
- If OpenJDK only provided a single class
UnmodifiableListthat implementedRandomAccess, wrapping aLinkedListwould fraudulently mark it as supporting $O(1)$ random access, causing algorithms likebinarySearch()to degrade to $O(N^2)$. - Conversely, if
UnmodifiableListdid not implementRandomAccess, wrapping anArrayListwould strip its random-access capabilities, degrading downstream algorithms to slow iterator traversals. Having two classes guarantees exact behavioral and algorithmic fidelity with the backing list.
3. Does UnmodifiableList inherit the serializability of the underlying list?
Answer:
UnmodifiableCollection implements java.io.Serializable. However, during serialization, Java’s serialization engine recursively inspects the object graph.
If the underlying backing list does not implement Serializable (for instance, a custom in-memory list implementation), attempting to serialize the UnmodifiableList via ObjectOutputStream.writeObject() will throw a runtime java.io.NotSerializableException.
4. How does JIT compilation optimize calls to unmodifiableList.get(i) in high-throughput hot paths?
Answer: In high-throughput loops, the HotSpot C2 compiler applies Monomorphic Inlining:
- It detects that the call-site for
unmodifiableList.get(i)always resolves toCollections$UnmodifiableList.get(). - It inlines the wrapper’s
get()method body (list.get(index)). - If the backing list is consistently an
ArrayList, it inlinesArrayList.get(index)and bounds-checks directly against the backing arrayelementData[index]. - As a result, the wrapper abstraction is completely eliminated at the assembly instruction level, executing with the exact same machine code performance as a raw array read.
🎯 Interview Cheat Sheet
30-Second Elevator Pitch
“
Collections.unmodifiableList()implements the Decorator Pattern, returning a lightweight wrapper that stores a direct reference to the backing list. Read methods delegate directly to the underlying list, while mutating methods (add,set,remove,sort,replaceAll) unconditionally throwUnsupportedOperationException. The method dynamically selectsUnmodifiableRandomAccessListif the backing list implementsRandomAccess, preserving algorithm performance in utilities likebinarySearch(). At runtime, HotSpot’s C2 JIT compiler inlines delegation calls to achieve near-zero nanosecond overhead. It remains an $O(1)$ View; mutations to the original backing list remain visible through the wrapper.”
High-Impact Architectural Facts
- Pattern: Classic Decorator pattern holding a
final List<? extends E> listreference. - Fail-Fast Mutator Rejection:
sort()andreplaceAll()are explicitly overridden to throwUnsupportedOperationExceptionprior to running default interface logic. - Marker Preservation: Implements
UnmodifiableRandomAccessListto maintain $O(1)$ indexed search optimizations inCollections.binarySearch(). - Zero-Cost Abstraction: C2 JIT monomorphic inlining optimizes wrapper delegations into direct memory reads.
- Module Encapsulation: Java 9 JPMS blocks reflection attacks on the internal
listreference field viaInaccessibleObjectException.
Common Pitfalls & Red Flags
- ❌ Assuming
unmodifiableListcopies data into a new buffer: It is an $O(1)$ view wrapper holding a direct reference to the original list. - ❌ Believing
subList()on an unmodifiable list returns a mutable sublist:subList()returns another nestedUnmodifiableListwrapper. - ❌ Assuming
unmodifiableListprovides thread safety: Concurrent writes to the backing list causeConcurrentModificationExceptionduring view iteration. - ❌ Calling
listIterator.set(): The list iterator is wrapped inUnmodifiableListIterator, which blocksset(),add(), andremove().
Related Topics
- What is Collections.unmodifiableList() — High-level concepts and trade-offs
- What is the difference between Iterator and ListIterator — Iterator interface taxonomy
- How to get a synchronized collection — Synchronized decorator comparison
- What operations does the Collection interface support — Optional operations contract