🔒 Section 13 · Question #12

How to Protect a Collection from Modification

Java provides three primary mechanisms to protect collections from unwanted modification, each tailored to a specific use case:


🟢 Junior Level

30-Second Summary

Java provides three primary mechanisms to protect collections from unwanted modification, each tailored to a specific use case:

  1. Collections.unmodifiableList(original) (Java 1.2+): Creates a lightweight wrapper (view) over an existing collection. Calling any mutator method (add(), remove(), clear()) throws UnsupportedOperationException. However, if the underlying original collection is modified, those changes immediately reflect in the wrapper!
  2. List.of(e1, e2, ...) (Java 9+): Factory method creating compact, truly immutable collections from a known set of elements/literals. Strictly prohibits null elements.
  3. List.copyOf(collection) (Java 10+): Creates an independent defensive copy snapshot from an existing collection. Subsequent modifications to the original collection have zero effect on the copied list. Strictly prohibits null elements.

Equivalent methods exist for sets (Set.of(), Set.copyOf(), Collections.unmodifiableSet()) and maps (Map.of(), Map.copyOf(), Collections.unmodifiableMap()).


Practical Example

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class CollectionProtectionDemo {
    public static void main(String[] args) {
        List<String> mutable = new ArrayList<>();
        mutable.add("Java");

        // Mechanism 1: Wrapper view (live connection)
        List<String> view = Collections.unmodifiableList(mutable);

        // Mechanism 2: Defensive copy (complete isolation)
        List<String> copy = List.copyOf(mutable);

        // Mutating protected lists is blocked:
        try {
            view.add("Kotlin"); // Throws UnsupportedOperationException!
        } catch (UnsupportedOperationException e) {
            System.out.println("Mutation via view is blocked!");
        }

        // Mutating the original backing list:
        mutable.add("Scala");

        System.out.println("view: " + view); // [Java, Scala] — THE WRAPPER CHANGED!
        System.out.println("copy: " + copy); // [Java]        — THE SNAPSHOT REMAINED SAFE!
    }
}

🟡 Middle Level

Architectural Comparison of Protection Strategies

| Feature | Collections.unmodifiable* | List.of(...) | List.copyOf(...) | Stream.toList() (Java 16+) | | :— | :— | :— | :— | :— | | Creation Time | $O(1)$ | $O(N)$ (for $N \le 2$: $O(1)$) | $O(N)$ (or $O(1)$ fast-path) | $O(N)$ | | Memory Allocation | Wrapper object only | Minimal (no spare capacity) | Minimal | Minimal | | Backing Connection | Yes (Live View) | None | None | None | | Null Elements | Allowed (if in source) | ❌ Throws NPE | ❌ Throws NPE | ✅ Allowed! | | Optimization on Reuse | None | None | Returns this if immutable | None |


The Danger of Live Views (Collections.unmodifiableList)

Collections.unmodifiableList does not clone any elements. It instantiates a decorator object that maintains a direct pointer to the original ArrayList:

[Client Code] ──► [UnmodifiableList View] ──► [Original ArrayList]
                                                    ▲
[External / Concurrent Code] ───────────────────────┘

If the original caller retains a reference to the Original ArrayList, calling original.add("compromised") immediately alters the data observed through the unmodifiable view, breaking class invariants.


Shallow Immutability Caveat

Standard JDK protection methods protect only the container structure:

public class User {
    private String name;
    public User(String name) { this.name = name; }
    public void setName(String name) { this.name = name; }
    public String getName() { return name; }
}

List<User> list = List.of(new User("Alice"));
// list.add(...) throws UnsupportedOperationException, but:
list.get(0).setName("Bob"); // Internal state of element 0 is mutated!

Protecting mutable element objects requires deep defensive copying across the entire element graph.


🔴 Senior Level

Immutable Collections Architecture (JEP 269)

Collections created via List.of() and List.copyOf() reside in the internal package java.util.ImmutableCollections:

  • Specialized Small Sizes: For 0, 1, and 2 elements, HotSpot instantiates List0 and List12:
    // In List12 there is NO backing array! Elements are stored in direct object fields:
    static final class List12<E> extends AbstractImmutableList<E> {
        private final E e0;
        private final E e1; // null if size is 1
    }
    

    This eliminates array header allocation (16 bytes) and pointer indirection, achieving dramatic memory efficiency.

  • Large Sizes: For 3+ elements, ListN wraps a compact, exact-fit Object[] array with zero spare capacity, saving up to 60% memory compared to a default ArrayList.

Iteration Order Randomization in Set.of() and Map.of()

When the JVM initializes, it generates a per-run random salt for Set.of() and Map.of():

  • The iteration order of elements in Set.of("a", "b", "c") changes across JVM restarts.
  • Architectural Rationale:
    1. Prevents Hidden Dependencies: Discourages developers from writing code that implicitly assumes deterministic ordering in unordered collections.
    2. Hash-DoS Defense: Prevents attackers from exploiting hash collisions via predictable iteration patterns (Hash Flooding Attacks).

Stream.toList() vs. List.copyOf() (Java 16+)

A critical distinction often examined in senior interviews:

  • Stream.toList() (Java 16+) returns an unmodifiable list backed by ImmutableCollections.ListN.
  • Key Difference: List.of() and List.copyOf() are strictly null-hostile (throwing NullPointerException). In contrast, Stream.toList() permits null elements!
    List<String> streamList = Stream.of("A", null, "B").toList(); // ✅ Compiles & executes cleanly!
    List.copyOf(streamList); // ❌ Throws NullPointerException!
    

🎯 Interview Cheat Sheet

Summary of Collection Protection Mechanisms

| Method | When to Use | Advantages | Drawbacks | | :— | :— | :— | :— | | List.of(...) | Fixed constants/literals ($\le 10$) | Minimal memory, direct field storage ($N \le 2$) | Null-hostile, fixed arguments | | List.copyOf(...) | Constructor defensive copy | Completely isolates state, fast-path for immutable input | Null-hostile, $O(N)$ allocation | | Collections.unmodifiable* | Getter returns (when source is private) | $O(1)$ creation, permits null | Live view to source, risk of CME | | Stream.toList() | Terminal Stream operation | Unmodifiable, permits null | $O(N)$ allocation |


4 Tricky Interview Questions

1. Can iterating over a Collections.unmodifiableList(list) throw ConcurrentModificationException?

Answer: Yes, absolutely. Collections.unmodifiableList is merely a view wrapper over the underlying list. If a background thread invokes add() or remove() on the original list while another thread is iterating through the unmodifiable wrapper, the underlying modCount changes. The iterator detects this change and immediately throws a ConcurrentModificationException. The wrapper prevents mutation through its own interface, but is not thread-safe.

2. Why do Set.of() and Map.of() deliberately randomize iteration order across JVM restarts while Collections.unmodifiableSet(new HashSet<>()) does not?

Answer: A standard HashSet iterates in a deterministic order based on standard element hashCode() and bucket capacity. In contrast, Set.of() and Map.of() mix a randomized salt (SALT = new Random().nextLong()) into bucket placement upon JVM startup. This defends against Hash-DoS attacks and prevents brittle code dependencies on hash iteration orders.

3. How does List.of("a", "b") outperform Collections.unmodifiableList(new ArrayList<>(List.of("a", "b"))) in memory footprint on 64-bit HotSpot?

Answer: The memory difference is massive:

  1. unmodifiableList(new ArrayList<>(...)):
    • UnmodifiableList wrapper: 24 bytes.
    • ArrayList object: 32 bytes.
    • Backing Object[10] array: 56 bytes.
    • Total: Over 110 bytes of memory overhead.
  2. List.of("a", "b"):
    • Instantiates ImmutableCollections.List12.
    • Stores elements in fields e0 and e1 with no backing array at all.
    • Total: Exactly 24 bytes in heap memory (>75% memory savings).

4. What happens when invoking List.copyOf(input) on a collection containing null? Why was it designed this way?

Answer: It immediately throws a NullPointerException. Java architects (Brian Goetz, Stuart Marks) intentionally designed modern collection factories to be null-hostile. Allowing null in collections creates ubiquitous ambiguity (does map.get(key) == null mean the key is absent or mapped to null?). Prohibiting null causes code to fail fast at the point of origin rather than propagating corrupt state downstream.


Red Flags (DO NOT Say)

  • ❌ “Using Collections.unmodifiableList() completely isolates the collection from external modifications.” (It is a live view; mutating the original list mutates the view).
  • ❌ “Wrapping a list in List.of() or List.copyOf() makes all objects inside it immutable.” (It only protects collection structure; contained objects remain mutable).
  • ❌ “Collections.unmodifiableList() makes a collection thread-safe.” (Modifications to the backing collection cause ConcurrentModificationException).
  • ❌ “List.copyOf() and Stream.toList() behave identically with respect to null.” (List.copyOf throws NPE on nulls, whereas Stream.toList() permits nulls).