☕ Java

Immutable Collections

Immutable collections in Java are collections that cannot be structurally modified after creation — no elements can be added, removed, or replaced. Java provides immutable collections through three distinct mechanisms: the List.of(), Set.of(), and Map.of() factory methods introduced in Java 9; the Collections.unmodifiableXxx() wrappers introduced in Java 1.2; and the Guava ImmutableList/ImmutableSet/ImmutableMap builders. These mechanisms differ in their depth of immutability, null handling, iteration order guarantees, performance characteristics, and whether they are truly immutable or merely unmodifiable views. This entry covers all three mechanisms in full, the precise contract of each, shallow vs deep immutability, the difference between unmodifiable and truly immutable, serialization considerations, and when to use each.

Java 9 Factory Methods — List.of, Set.of, Map.of

Java 9 introduced static factory methods on List, Set, and Map for creating compact, truly immutable collections. These factories produce implementations that are not part of the existing AbstractList/AbstractMap hierarchy — they are purpose-built, optimized implementations with a fixed, compact memory layout. List.of(E... elements) returns an immutable List containing the specified elements in the given order. Set.of(E... elements) returns an immutable Set. Map.of(K k, V v, ...) returns an immutable Map with up to ten key-value pairs; Map.ofEntries(Map.Entry<K,V>... entries) handles arbitrary sizes using Map.entry(k, v) pairs. All three methods have overloaded versions for 0 through 10 arguments (avoiding varargs array allocation), plus a varargs overload for larger collections. All factory methods share these contracts: null elements/keys/values are not permitted and throw NullPointerException immediately at construction time; calling any mutating method (add, remove, set, put, clear) throws UnsupportedOperationException; duplicate elements in Set.of() or duplicate keys in Map.of() throw IllegalArgumentException at construction time; the returned instances implement Serializable. Iteration order for List.of() is always the insertion order. For Set.of() and Map.of(), the iteration order is intentionally unspecified and may vary between JVM runs — the implementation randomizes it by design to prevent programs from accidentally depending on an order that could change. This is a deliberate design decision to give the JVM freedom to optimize storage layout. Memory usage: the factory implementations are more compact than their mutable counterparts. A List.of() with 2 elements uses a specialized two-element class with two fields rather than an Object array of length 10 (ArrayList's default initial capacity). This makes factory-created collections appropriate even for very short-lived or frequently created collections.
Java
// ── List.of ──────────────────────────────────────────────────────────
List<String> names = List.of("Alice", "Bob", "Charlie");
System.out.println(names);         // [Alice, Bob, Charlie]
System.out.println(names.get(1));  // Bob
System.out.println(names.size());  // 3

// Mutating throws UnsupportedOperationException:
try { names.add("Dave"); }    catch (UnsupportedOperationException e) { System.out.println("No add"); }
try { names.remove(0); }      catch (UnsupportedOperationException e) { System.out.println("No remove"); }
try { names.set(0, "Eve"); }  catch (UnsupportedOperationException e) { System.out.println("No set"); }

// Null throws NullPointerException immediately:
try { List.of("a", null, "c"); } catch (NullPointerException e) { System.out.println("No null"); }

// Empty:
List<String> empty = List.of();

// ── Set.of ───────────────────────────────────────────────────────────
Set<Integer> primes = Set.of(2, 3, 5, 7, 11);
System.out.println(primes.contains(5));  // true
// Iteration order is NOT guaranteed to be insertion order:
System.out.println(primes);  // could be: [11, 2, 7, 3, 5] or any permutation

// Duplicate elements throw IllegalArgumentException:
try { Set.of("a", "b", "a"); } catch (IllegalArgumentException e) {
    System.out.println("Duplicate: " + e.getMessage());  // duplicate element: a
}

// ── Map.of ───────────────────────────────────────────────────────────
Map<String, Integer> ages = Map.of(
    "Alice", 30,
    "Bob",   25,
    "Carol", 35
);
System.out.println(ages.get("Bob"));    // 25
System.out.println(ages.size());        // 3
// Map.of order not guaranteed: ages.entrySet() may iterate in any order

// Duplicate keys throw IllegalArgumentException:
try { Map.of("a", 1, "a", 2); } catch (IllegalArgumentException e) {
    System.out.println("Duplicate key: " + e.getMessage());
}

// ── Map.ofEntries for > 10 entries ────────────────────────────────────
Map<String, Integer> large = Map.ofEntries(
    Map.entry("one",   1),
    Map.entry("two",   2),
    Map.entry("three", 3),
    Map.entry("four",  4)
    // ... any number of entries
);

// ── Map.copyOf, List.copyOf, Set.copyOf ──────────────────────────────
List<String> mutable = new ArrayList<>(List.of("x", "y", "z"));
List<String> immutableCopy = List.copyOf(mutable);  // new immutable copy
mutable.add("w");  // modifying original does NOT affect immutableCopy
System.out.println(immutableCopy);  // [x, y, z]

// If the source is already an immutable List.of() instance, copyOf may return same object:
List<String> alreadyImmutable = List.of("a", "b");
List<String> copy = List.copyOf(alreadyImmutable);
System.out.println(copy == alreadyImmutable);  // true (JVM optimization, not guaranteed)

Unmodifiable Wrappers vs True Immutability

Collections.unmodifiableList(list), Collections.unmodifiableSet(set), and Collections.unmodifiableMap(map) — and their sorted variants unmodifiableSortedSet and unmodifiableSortedMap — return views that throw UnsupportedOperationException for all mutating operations. They are not truly immutable: they are unmodifiable views of a mutable backing collection. If the backing collection is modified, the view reflects those changes immediately. This distinction matters significantly in practice. Returning Collections.unmodifiableList(internalList) from a method prevents callers from modifying the list through the view, but the class itself (or any code holding a reference to the backing list) can still modify it. This is sometimes called a "defensive unmodifiable view" — it protects callers but not against internal mutation. For genuine immutability, you must either wrap a defensive copy (Collections.unmodifiableList(new ArrayList<>(internalList))) or use List.copyOf() / List.of() directly. The unmodifiable wrappers have been available since Java 1.2 and remain the standard mechanism when you want to expose an existing mutable list's contents read-only without copying. The wrapper delegates all read operations (get, size, iterator, contains) to the backing collection, so its iteration order and characteristics exactly match the backing collection's. The iterator of an unmodifiable wrapper is the backing collection's iterator — it is still fail-fast with respect to structural modifications to the backing collection. A common bug: mutating the backing collection while iterating via the unmodifiable wrapper's iterator causes ConcurrentModificationException, because the iterator belongs to the backing collection and still checks modCount. This surprises developers who expect unmodifiable wrappers to provide some isolation from the backing structure.
Java
// ── Unmodifiable wrapper is a VIEW — mutations to backing are visible ─
List<String> backing = new ArrayList<>(List.of("a", "b", "c"));
List<String> view    = Collections.unmodifiableList(backing);

System.out.println(view);   // [a, b, c]
backing.add("d");           // modify through backing reference
System.out.println(view);   // [a, b, c, d] — view reflects the change!

// ── List.of / List.copyOf are truly immutable — no live backing ──────
List<String> truly = List.copyOf(backing);  // snapshot at copy time
backing.add("e");
System.out.println(truly);   // [a, b, c, d] — does NOT reflect "e"

// ── Defensive copy pattern — the correct way to expose internal state ─
public class Department {
    private final List<String> employees = new ArrayList<>();

    public void addEmployee(String name) { employees.add(name); }

    // WRONG — caller can see future changes to employees:
    public List<String> getEmployeesBad() {
        return Collections.unmodifiableList(employees);
    }

    // CORRECT — caller gets a frozen snapshot:
    public List<String> getEmployees() {
        return List.copyOf(employees);
    }
}

// ── CME through an unmodifiable wrapper's iterator ────────────────────
List<String> base = new ArrayList<>(List.of("x", "y", "z"));
List<String> unmod = Collections.unmodifiableList(base);

try {
    for (String s : unmod) {
        base.add("w");   // modify backing — CME on next iterator.next()
    }
} catch (ConcurrentModificationException e) {
    System.out.println("CME through unmodifiable view's iterator");
}

// ── All unmodifiable variants ─────────────────────────────────────────
Set<String>               unmodSet    = Collections.unmodifiableSet(new HashSet<>(Set.of("a","b")));
Map<String,Integer>       unmodMap    = Collections.unmodifiableMap(new HashMap<>(Map.of("k",1)));
SortedSet<String>         unmodSorted = Collections.unmodifiableSortedSet(new TreeSet<>(Set.of("a","b")));
SortedMap<String,Integer> unmodSortedMap = Collections.unmodifiableSortedMap(new TreeMap<>(Map.of("k",1)));
Collection<String>        unmodColl   = Collections.unmodifiableCollection(new ArrayList<>(List.of("a")));

// ── Null handling differences ─────────────────────────────────────────
// Collections.unmodifiableList wraps whatever the backing supports:
List<String> withNulls = new ArrayList<>();
withNulls.add("a");
withNulls.add(null);
withNulls.add("b");
List<String> unmodNulls = Collections.unmodifiableList(withNulls);
System.out.println(unmodNulls.contains(null));   // true — nulls allowed

// List.of rejects nulls at creation time:
try { List.of("a", null, "b"); } catch (NullPointerException e) {
    System.out.println("List.of rejects null");
}

Deep Immutability, Serialization, and Guava

All Java immutable collection mechanisms — List.of(), Collections.unmodifiableList(), and Guava's ImmutableList — provide only shallow immutability. The collection structure itself cannot be changed, but the elements within it can still be mutated if they are mutable objects. A List.of(new StringBuilder("hello")) is an immutable list structure containing a mutable StringBuilder. Modifying the StringBuilder changes what you observe when you access the list element, even though the list itself cannot add or remove elements. True deep immutability requires immutable elements — use immutable value types (String, Integer, record classes) or make defensive copies of mutable elements before adding them. For serialization, List.of(), Set.of(), and Map.of() implementations are Serializable. However, they use a serialization proxy pattern to preserve the compact implementation class across serialization. If you serialize and deserialize a List.of(), you get back a List.of() instance (or an equivalent), not an ArrayList. This is consistent and correct but worth understanding for RPC frameworks that rely on deserialized type identity. Guava's ImmutableList, ImmutableSet, ImmutableMap (com.google.common.collect) predate Java 9's factory methods and remain widely used. They provide a builder API for programmatic construction, explicit copyOf() methods, and comprehensive null-rejection. Guava's immutable collections also guarantee specific iteration orders: ImmutableList preserves insertion order; ImmutableSet preserves insertion order (unlike Set.of() which is unordered); ImmutableMap preserves insertion order. Guava's ImmutableSortedSet and ImmutableSortedMap iterate in sorted order. These ordering guarantees make Guava's collections preferable when you need a predictable, documented iteration order for sets or maps. Guava's ImmutableList.copyOf() and ImmutableSet.copyOf() are intelligent about avoiding unnecessary copies: if the argument is already an ImmutableList, copyOf returns the same instance. For Java 9+ projects without Guava, List.copyOf() behaves similarly.
Java
// ── Shallow immutability — mutable elements are still mutable ────────
List<StringBuilder> builders = List.of(
    new StringBuilder("hello"),
    new StringBuilder("world")
);

// Can't add/remove elements:
try { builders.add(new StringBuilder("!")); } 
catch (UnsupportedOperationException e) { System.out.println("No add"); }

// CAN mutate existing elements:
builders.get(0).append(" modified");
System.out.println(builders.get(0));  // "hello modified" — mutation succeeded

// ── Guava ImmutableList ────────────────────────────────────────────────
// Requires: implementation 'com.google.guava:guava:32.0-jre'
ImmutableList<String> gList = ImmutableList.of("a", "b", "c");
ImmutableList<String> gList2 = ImmutableList.<String>builder()
    .add("x")
    .add("y")
    .addAll(List.of("p", "q", "r"))
    .build();

// ImmutableSet: preserves insertion order (unlike Set.of):
ImmutableSet<String> gSet = ImmutableSet.of("banana", "apple", "cherry");
System.out.println(gSet);  // [banana, apple, cherry] — always insertion order

Set.of("banana", "apple", "cherry");  // order unpredictable

// ── Guava ImmutableMap ────────────────────────────────────────────────
ImmutableMap<String, Integer> gMap = ImmutableMap.of(
    "one", 1, "two", 2, "three", 3);
// Insertion order preserved:
System.out.println(gMap.keySet());  // [one, two, three]

ImmutableMap<String, Integer> gMapBuilt = ImmutableMap.<String, Integer>builder()
    .put("a", 1)
    .put("b", 2)
    .put("c", 3)
    .buildOrThrow();   // throws on duplicate key (buildKeepingLast() for last-wins)

// ── Guava copyOf avoids redundant copies ─────────────────────────────
ImmutableList<String> original = ImmutableList.of("x", "y", "z");
ImmutableList<String> copy     = ImmutableList.copyOf(original);
System.out.println(original == copy);  // true — same instance reused

// ── Serialization round-trip ──────────────────────────────────────────
List<String> immutable = List.of("serialize", "me");
byte[] bytes;
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
     ObjectOutputStream oos = new ObjectOutputStream(bos)) {
    oos.writeObject(immutable);
    bytes = bos.toByteArray();
}
try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
    @SuppressWarnings("unchecked")
    List<String> deserialized = (List<String>) ois.readObject();
    System.out.println(deserialized);                         // [serialize, me]
    System.out.println(deserialized.getClass().getName());   // java.util.ImmutableCollections$List12
    try { deserialized.add("extra"); }
    catch (UnsupportedOperationException e) { System.out.println("Still immutable after deserialization"); }
}

Related Topics in Collections Framework

Collections Overview
The Java Collections Framework (JCF) is a unified architecture for representing and manipulating groups of objects. It provides a set of interfaces that define the operations a collection must support, a set of abstract classes that provide partial implementations, and a set of concrete implementations optimised for different use cases. Every Java developer uses collections daily — lists for sequences, sets for uniqueness, maps for key-value pairs, and queues for ordering — and choosing the right implementation for the right use case is one of the most fundamental practical skills in Java.
Iterable
Iterable<E> is the root interface of the Java Collections hierarchy. Any class that implements Iterable can be used in a for-each loop. It declares a single abstract method: iterator(), which returns an Iterator<E> that the for-each loop uses to traverse the elements. Implementing Iterable is all that is required to make a custom data structure work with Java's enhanced for loop, the Stream API, and any method that accepts an Iterable.
Collection Interface
Collection<E> is the root interface of the main collection hierarchy, extending Iterable<E>. It defines the common operations that all collection types must support: adding elements, removing elements, checking containment, querying size, clearing, converting to an array, and bulk operations. List, Set, and Queue all extend Collection. Map does not extend Collection because a map operates on key-value pairs rather than individual elements.
ArrayList
ArrayList is a resizable-array implementation of the List interface. It is the most commonly used collection in Java, providing dynamic sizing on top of a standard array. Elements are stored in contiguous memory, enabling O(1) random access by index. When the internal array is full, ArrayList automatically allocates a larger array and copies all elements. ArrayList is not thread-safe and preserves insertion order, allowing duplicates.