In Java, the word immutable gets thrown around too casually. That is fine until you deal with shared state, concurrency, or domain objects that must behave predictably. At that point, confusing a read-only view with a truly immutable collection becomes a real problem.
Two APIs usually sit at the center of this confusion:
Collections.unmodifiableList()List.copyOf()
They look similar on the surface, but internally they behave very differently.
1. Collections.unmodifiableList(): Read-Only, Not Immutable
Collections.unmodifiableList() does not create a new list. It wraps an existing one.
Think of it as a guardrail. It stops callers from modifying the list through that reference and nothing more.
What it actually does
- Wraps an existing
List - Implements the
Listinterface - Throws
UnsupportedOperationExceptionfor mutation methods likeadd,remove, andset - Continues to point to the same underlying data
That last point is the important one.
List<String> internalData = new ArrayList<>(List.of("A", "B"));
List<String> publicView = Collections.unmodifiableList(internalData);
internalData.add("C");
// publicView now contains "C"
No rules were broken.
The wrapper behaved exactly as designed.
Why this matters
- The data is still mutable
- Any code holding the original list can change it
- All "unmodifiable" views will reflect those changes
When this is acceptable
- You want to protect a method boundary
- You fully own the backing list
- The list will never be mutated again
When it is a bad idea
- Shared state
- Cached data
- Configuration
- Multi-threaded access
Verdict: This protects the API, not the data. It does not give you immutability, and it does not make your code thread-safe.
2. List.copyOf(): A Real Snapshot
List.copyOf() was added in Java 10 and solves the problem properly.
Instead of wrapping, it copies.
What it actually does
- Creates a new list instance
- Copies all elements into a private internal structure
- Removes any connection to the source list
List<String> internalData = new ArrayList<>(List.of("A", "B"));
List<String> frozenData = List.copyOf(internalData);
internalData.add("C");
// frozenData is still ["A", "B"]
Once created, the list cannot change, no matter what happens to the original.
Why this matters
- No shared mutable state
- Safe to cache
- Safe to share across threads
- Predictable behavior
Verdict: This is actual immutability.
Key Differences That Matter in Practice
| Aspect | unmodifiableList() |
List.copyOf() |
|---|---|---|
| Data copy | No | Yes |
| Backed by source | Yes | No |
| Reacts to source changes | Yes | No |
| Null elements | Allowed | Throws NullPointerException
|
| Thread-safe by default | No | Yes |
| Cost | O(1) | O(n) |
A note on null
List.copyOf() (like List.of()) rejects null values:
List.copyOf(List.of("A", null)); // NullPointerException
This is intentional. Failing early is better than chasing random NullPointerExceptions deep inside business logic.
A Practical Rule of Thumb
Ask yourself these questions:
-
Is this list cached, shared, or read by multiple threads?
Use
List.copyOf(). -
Am I returning internal state from a class?
Use
List.copyOf()to avoid representation exposure. -
Am I only trying to stop callers from modifying a list I fully control?
Collections.unmodifiableList()may be acceptable if you truly understand the risk.
The cost of copying is almost always insignificant compared to the cost of debugging race conditions.
Bottom Line
- Unmodifiable protects the interface
- Immutable protects the data
Most modern Java systems need the second one.
If correctness matters, stop wrapping and start copying.
Top comments (0)