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
// ── 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
// ── 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
// ── 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"); }
}