☕ 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

HashMap
HashMap is the most widely used Map implementation in Java. It stores key-value pairs in a hash table, providing average O(1) performance for get, put, remove, and containsKey operations. Keys are stored in no guaranteed order. HashMap permits one null key and multiple null values. It is not thread-safe. Understanding how HashMap uses hash codes, handles collisions, and resizes is foundational knowledge for writing efficient Java code.
LinkedHashMap
LinkedHashMap extends HashMap and maintains a doubly-linked list running through all its entries, preserving the order in which entries were inserted. Iteration over a LinkedHashMap always returns entries in insertion order. Optionally, it can be constructed in access-order mode where entries are ordered by most-recently accessed, making it the perfect foundation for implementing a Least Recently Used (LRU) cache. LinkedHashMap has slightly higher memory overhead than HashMap due to the extra linked list pointers.
TreeMap
TreeMap is a Red-Black tree implementation of the NavigableMap interface. It stores key-value pairs in sorted order — either the natural ordering of keys (requiring them to implement Comparable) or a custom Comparator provided at construction. All basic operations (get, put, remove, containsKey) are O(log n). TreeMap provides rich navigation operations: finding the closest key, extracting sub-maps, and headMap/tailMap views.
Hashtable
Hashtable is a legacy hash table implementation that predates the Collections Framework. Like Vector for lists, Hashtable was Java's original hash map from Java 1.0. It is thread-safe because all its methods are synchronised, but it does not permit null keys or null values. Hashtable has been retrofitted to implement the Map interface but should never be used in new code — HashMap is the non-concurrent replacement and ConcurrentHashMap is the concurrent replacement.