How Does Guava's ImmutableList Differ from Collections.unmodifiableList()? When to Use Each
TLDR: Guava's ImmutableList provides true immutability with a copied, null-safe internal array, while Collections.unmodifiableList() creates a read-only view of an existing mutable list that reflects external changes—use the former for guaranteed immutability and thread-safety, the latter when you need a temporary read-only wrapper without copying.
Both ImmutableList and Collections.unmodifiableList() expose list interfaces that reject modification attempts, yet they differ fundamentally in architecture, guarantees, and performance. Understanding these distinctions helps you choose the right tool for defensive programming, API design, and concurrent systems.
True Immutability vs. Unmodifiable Views
The core distinction lies in what happens under the surface.
ImmutableList: Deep Immutability with Copied State
In guava/src/com/google/common/collect/ImmutableList.java, the constructor establishes true immutability by ensuring the internal array never changes after construction. The class does not retain a reference to any mutable backing collection (lines 54-68). Once built, the ImmutableList exists as a standalone, immutable artifact.
// Source: ImmutableList.java#L54-L68
// The constructor copies elements and discards the source reference
This design eliminates the possibility of external mutation affecting your collection. Any attempt to modify the list—add(), remove(), set()—throws UnsupportedOperationException at runtime, and these methods don't even appear in the fluent API surface.
Collections.unmodifiableList(): Shallow Wrapper Semantics
The JDK's approach in java.util.Collections creates a lightweight wrapper object that forwards all read operations to an underlying mutable list. The wrapper itself prevents modification attempts, but it holds a live reference to the original list. If that original list changes elsewhere, the "unmodifiable" view silently reflects those changes.
This distinction matters critically for defensive copying and API contracts.
Key Architectural Differences
| Aspect | ImmutableList |
Collections.unmodifiableList() |
|---|---|---|
| Backing storage | Private, immutable array | Live reference to mutable list |
| Null handling | NullPointerException on construction |
Permits null elements |
| Defensive copying | Copies on construction (unless source already immutable) | No copying—pure wrapper |
| Thread safety | Unconditionally safe | Depends on underlying list |
| Memory overhead | Single array, no wrapper | Wrapper object + original list |
| Empty list optimization | Singleton reused instance | New wrapper each call |
Null Safety Enforcement
ImmutableList enforces non-null elements at construction time. In ImmutableList.java, the of(E e1) factory and builder both invoke Preconditions.checkNotNull (lines 94-105):
// Attempting this throws NullPointerException immediately
ImmutableList<String> broken = ImmutableList.of("a", null, "b"); // fails fast
By contrast, Collections.unmodifiableList() inherits null handling from its backing list with no validation layer.
Thread Safety Guarantees
ImmutableList requires no synchronization for concurrent reads—its state is fixed. The Collections.unmodifiableList() wrapper offers no thread safety guarantees beyond what the backing list provides. If the underlying list is an unsynchronized ArrayList, concurrent reads are unsafe.
Performance Characteristics
Read Operations and Indirection
ImmutableList stores elements in a compact array with O(1) random access and no indirection. The class implements RandomAccess and performs direct array indexing.
In ImmutableList.java, the subList implementation (lines 46-70) returns another ImmutableList that may share the original backing array as a partial view, maintaining the same performance profile:
ImmutableList<String> full = ImmutableList.of("a", "b", "c", "d");
ImmutableList<String> slice = full.subList(1, 3); // shares array, O(1)
The JDK unmodifiable wrapper adds a method dispatch layer for every operation—minor overhead, but measurable in tight loops over large collections.
Construction Costs
ImmutableList.copyOf(Collection) attempts optimization: if the source is already an ImmutableCollection, it may share the internal array without copying (lines 62-69). For regular collections, it performs a defensive copy—trading construction time for read-time safety and sharing safety.
Collections.unmodifiableList() construction is always O(1) with no copying, making it attractive when you need immediate read-only access to a large, already-existing mutable list.
Serialization Behavior
ImmutableList implements custom serialization via writeReplace() (lines 58-64), returning a SerializedForm that captures only logical contents. Deserialization reconstructs a fresh ImmutableList without preserving implementation class details.
The JDK wrapper serializes the entire Collections$UnmodifiableList class together with its backing list, preserving view semantics across serialization boundaries.
Practical Usage Scenarios
Choose ImmutableList When:
- Building constant data for caching, configuration, or public API returns
- Enforcing null-free contracts at the collection level
- Sharing across threads without synchronization overhead
- Defensive programming where you must guarantee callers cannot affect internal state
- Performance-critical read paths with large, frequently-accessed data
import com.google.common.collect.ImmutableList;
// Constant data with builder API
ImmutableList<String> httpMethods = ImmutableList.<String>builder()
.add("GET")
.addAll(Arrays.asList("POST", "PUT", "DELETE"))
.build();
// Factory methods for small lists
ImmutableList<Integer> primes = ImmutableList.of(2, 3, 5, 7, 11);
// Safe to expose directly—no defensive copying needed by callers
public ImmutableList<String> getSupportedProtocols() {
return supportedProtocols; // truly immutable
}
Choose Collections.unmodifiableList() When:
- Temporarily exposing an existing mutable list you continue to own and modify
- Wrapping third-party returns where copying would be prohibitively expensive
- Transitioning legacy code where full immutability migration is impractical
import java.util.Collections;
import java.util.List;
import java.util.ArrayList;
List<String> mutableBuffer = new ArrayList<>();
// ... populate buffer ...
// Temporary read-only view for method call
processReadOnly(Collections.unmodifiableList(mutableBuffer));
// Original remains mutable for further work
mutableBuffer.clear();
Critical warning: the view reflects mutations:
List<String> base = new ArrayList<>(Arrays.asList("x", "y"));
List<String> view = Collections.unmodifiableList(base);
base.add("z"); // modifies the "unmodifiable" view!
System.out.println(view); // [x, y, z] — visible to all view holders
Builder API and Construction Patterns
Guava provides ImmutableList.Builder for efficient bulk construction (lines 66-84):
ImmutableList<String> built = ImmutableList.<String>builder()
.add("first")
.add("second")
.addAll(existingCollection)
.build(); // final, immutable result
The JDK offers no equivalent—you must construct a mutable list first, then wrap it.
Summary
ImmutableListdelivers true immutability with copied state, null-safety, thread-safety, and optimal read performance—ideal for constants, caches, and defensive API designCollections.unmodifiableList()provides a lightweight read-only view of existing mutable state—useful for temporary exposure without copying costs, but offers no mutation guarantees- Key differentiators: defensive copying vs. view semantics, null enforcement vs. permissiveness, standalone thread safety vs. dependent safety
Frequently Asked Questions
Can I modify an ImmutableList after creation?
No. ImmutableList provides no mutation methods, and any attempt through the List interface throws UnsupportedOperationException. The internal array is fixed at construction time and never exposed for modification. According to the Guava source code, the constructor establishes this invariant by copying elements and discarding the source reference.
Why does my unmodifiable list still change when I modify the original?
Collections.unmodifiableList() creates a view, not a copy. The wrapper holds a live reference to your original list and forwards all read operations to it. Any modification to the underlying list—add, remove, set, or even clear—is immediately visible through the view. This is by design: it offers zero-copy read-only access at the cost of no immutability guarantees.
Does ImmutableList always copy its input?
Not necessarily. ImmutableList.copyOf() checks if the source is already an ImmutableCollection; if so, it may return the existing instance or share the internal array. For regular collections like ArrayList or HashSet, it performs a defensive copy. This optimization preserves memory and construction time when converting between Guava immutable types.
Is ImmutableList thread-safe for concurrent reads?
Yes, unconditionally. Because the internal state cannot change after construction, no synchronization is required for any read operation. Multiple threads can safely iterate, access by index, or call contains concurrently. This is a fundamental advantage over Collections.unmodifiableList(), whose thread safety depends entirely on the synchronization of its backing list.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →