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?
UseList.copyOf().
Am I returning internal state from a class?
UseList.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.
SOCIAL SHARE CARD GENERATOR