Question 24 · Section 4

How does Collections.unmodifiableList() work internally?

Collections.unmodifiableList() returns a wrapper over the original list that prohibits all modifying operations.

Language versions: English Russian Ukrainian

🟢 Junior Level

Collections.unmodifiableList() returns a wrapper over the original list that prohibits all modifying operations.

List<String> mutable = new ArrayList<>();
mutable.add("A");
mutable.add("B");

List<String> unmodifiable = Collections.unmodifiableList(mutable);

unmodifiable.add("C"); // ❌ UnsupportedOperationException!
unmodifiable.get(0);   // ✅ OK — reading works

Key point: unmodifiableList is not a copy, but a wrapper. If you modify the original list, the changes will be visible through the wrapper:

mutable.add("C");
System.out.println(unmodifiable); // [A, B, C] — change is visible!

🟡 Middle Level

Internal implementation

Collections.unmodifiableList() returns an object of the internal class Collections.UnmodifiableList:

public static <T> List<T> unmodifiableList(List<? extends T> list) {
    return (list instanceof RandomAccess)
        ? new UnmodifiableRandomAccessList<>(list)
        : new UnmodifiableList<>(list);
}

Two wrapper variants:

  • UnmodifiableRandomAccessList — if the list supports random access (ArrayList)
  • UnmodifiableList — if it does not (LinkedList)

This is a performance optimization: RandomAccess lists use O(1) index-based access, not iterators.

UnmodifiableList structure

static class UnmodifiableList<E> extends UnmodifiableCollection<E>
                                  implements List<E> {
    private final List<? extends E> list;

    UnmodifiableList(List<? extends E> list) {
        super(list);
        this.list = list;  // reference to the original, not a copy!
    }

    // All modifying methods throw UnsupportedOperationException
    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();
    }

    // Read methods delegate to the original
    public E get(int index) {
        return list.get(index);  // direct call to the original
    }
    public int size() {
        return list.size();
    }
}

UnmodifiableList vs List.copyOf (Java 10+)

Characteristic unmodifiableList() List.copyOf()
Copying No (wrapper) Yes (defensive copy)
Original changes Visible through wrapper No effect
Null elements Allowed Not allowed
Return type UnmodifiableList ImmutableCollections.ListN
Java version Since Java 1.2 Since Java 10

RandomAccess — a marker interface meaning O(1) index-based access. Defensive copy — copying data to protect against external modifications.

List<String> original = new ArrayList<>(List.of("A", "B"));

// unmodifiableList — wrapper
List<String> wrapper = Collections.unmodifiableList(original);
original.add("C");
System.out.println(wrapper);  // [A, B, C] — change is visible!

// List.copyOf — copy
List<String> copy = List.copyOf(original);
original.add("D");
System.out.println(copy);     // [A, B, C] — change is NOT visible

ListIterator wrapper

ListIterator<String> it = unmodifiable.listIterator();
it.next();           // OK
it.set("X");         // ❌ UnsupportedOperationException
it.add("Y");         // ❌ UnsupportedOperationException
it.remove();         // ❌ UnsupportedOperationException

An UnmodifiableListIterator is returned, which also blocks all modifications.

Performance

Operation ArrayList directly unmodifiableList
get(i) O(1) O(1) (one delegation call)
size() O(1) O(1) (delegation)
contains() O(n) O(n) (delegation)
iterator() O(1) O(1) (wrapped iterator)

Overhead is minimal: one level of delegation (~1-2 ns per call).


🔴 Senior Level

Deep JDK source code analysis

// Collections.java (OpenJDK)

static class UnmodifiableCollection<E> implements Collection<E>, Serializable {
    final Collection<? extends E> c;

    UnmodifiableCollection(Collection<? extends E> c) {
        if (c == null) throw new NullPointerException();
        this.c = c;
    }

    // All mutating methods throw an exception:
    public boolean add(E e)     { throw new UnsupportedOperationException(); }
    public boolean remove(Object o) { throw new UnsupportedOperationException(); }
    public void clear()         { throw new UnsupportedOperationException(); }

    // Read methods delegate:
    public int size()           { return c.size(); }
    public boolean isEmpty()    { return c.isEmpty(); }
    public boolean contains(Object o) { return c.contains(o); }
    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(); }
        };
    }
}

UnmodifiableRandomAccessList optimization

static class UnmodifiableRandomAccessList<E>
    extends UnmodifiableList<E>
    implements RandomAccess {

    UnmodifiableRandomAccessList(List<? extends E> list) {
        super(list);
    }

    // Overrides subList — also returns unmodifiable
    public List<E> subList(int from, int to) {
        return new UnmodifiableRandomAccessList<>(
            list.subList(from, to)
        );
    }
}

This optimization is important: UnmodifiableList (non-RandomAccess) converts index-based operations to iterator-based ones, which for ArrayList would be O(n) instead of O(1).

Can be bypassed through reflection!

List<String> list = new ArrayList<>(List.of("A", "B"));
List<String> unmod = Collections.unmodifiableList(list);

// Through reflection — access to the `list` field in UnmodifiableList
Field field = unmod.getClass().getDeclaredField("list");
field.setAccessible(true);
List<String> underlying = (List<String>) field.get(unmod);
underlying.add("C");  // Modification bypasses the protection!

System.out.println(unmod); // [A, B, C]

This is not a bug, but a characteristic of the wrapper approach. List.copyOf() (Java 10+) solves this problem by creating a truly immutable collection.

If your project is on Java 8 — List.copyOf() is not available. Use Collections.unmodifiableList(new ArrayList<>(original)) as a defensive copy.

Serialization

UnmodifiableList implements Serializable. On deserialization:

// The wrapper is restored with a reference to the deserialized list
// If the original changed after the wrapper was serialized — it will NOT affect the wrapper

When to use

Scenario Recommendation
Read API, original controlled by you unmodifiableList()
Full immutability, Java 10+ List.copyOf()
Thread-safe immutable list List.copyOf() or unmodifiableList() + synchronize the original
Deep immutability (nested objects) None of these methods — use immutable DTO

Best Practices

// ✅ Return unmodifiable from public API
public List<String> getNames() {
    return Collections.unmodifiableList(names);
}

// ✅ Java 10+ — List.copyOf for full immutability
public List<String> getNames() {
    return List.copyOf(names);
}

// ❌ Do not use unmodifiableList for protection against concurrent modifications
// For that — use CopyOnWriteArrayList or Collections.synchronizedList()

🎯 Interview Cheat Sheet

Must know:

  • unmodifiableList() returns UnmodifiableRandomAccessList or UnmodifiableList depending on RandomAccess
  • All modifying methods throw UnsupportedOperationException
  • Read methods delegate directly to the original — overhead is 1-2 ns
  • subList() also returns an unmodifiable wrapper
  • Through reflection you can reach the original and bypass the protection
  • List.copyOf() (Java 10+) — true immutability, defensive copy, no connection to the original
  • Iterator/ListIterator are also wrapped in unmodifiable versions (remove/set/add are prohibited)

Frequent follow-up questions:

  • Why are there two classes — UnmodifiableList and UnmodifiableRandomAccessList? — Optimization: RandomAccess lists get O(1) index-based access, others — through iterator.
  • Can the unmodifiableList protection be bypassed? — Yes, through reflection (getDeclaredField("list")). List.copyOf() solves this problem.
  • What is the overhead of calling wrapper methods? — One level of delegation, ~1-2 ns — practically zero.
  • What does subList() of an unmodifiable list return? — Another unmodifiable wrapper on the sublist.

Red flags (DO NOT say):

  • “unmodifiableList creates a copy of the collection” — no, it’s the wrapper pattern, stores a reference to the original
  • “Protection against modifications is 100% guaranteed” — reflection allows bypassing it
  • “unmodifiableList is thread-safe” — no, synchronization needs to be handled separately
  • “Iterator from unmodifiableList allows removing elements” — no, UnmodifiableListIterator also blocks modifications

Related topics:

  • [[23. What is Collections.unmodifiableList()]]
  • [[25. What is the difference between Iterator and ListIterator]]
  • [[22. How to get a synchronized collection]]