Contents

Immutable collections offer several advantages that make them the preferred choice in modern Java code:

// Before Java 9 — verbose and error-prone List<String> colors = Collections.unmodifiableList( new ArrayList<>(Arrays.asList("red", "green", "blue"))); // Java 9+ — clean and compact List<String> colors = List.of("red", "green", "blue");

The List.of() factory method was added in Java 9. It has overloaded variants for 0 to 10 elements, plus a varargs variant for larger lists. Every returned list is immutable — calling add(), set(), or remove() throws UnsupportedOperationException.

// Empty immutable list List<String> empty = List.of(); // Single element List<String> single = List.of("alpha"); // Multiple elements — overloaded methods up to 10 elements List<String> fruits = List.of("apple", "banana", "cherry"); // More than 10 elements — uses varargs internally List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12); // Preserves insertion order System.out.println(fruits.get(0)); // apple System.out.println(fruits.get(2)); // cherry

The overloaded methods (0-10 args) avoid the array allocation that varargs requires. For hot paths creating small lists, this is a measurable optimization.

// All mutation operations throw UnsupportedOperationException List<String> languages = List.of("Java", "Kotlin", "Scala"); try { languages.add("Groovy"); // UnsupportedOperationException } catch (UnsupportedOperationException e) { System.out.println("Cannot add to immutable list"); } try { languages.set(0, "C++"); // UnsupportedOperationException } catch (UnsupportedOperationException e) { System.out.println("Cannot replace in immutable list"); } try { languages.remove("Java"); // UnsupportedOperationException } catch (UnsupportedOperationException e) { System.out.println("Cannot remove from immutable list"); } // sort also throws — the list is structurally immutable try { languages.sort(Comparator.naturalOrder()); // UnsupportedOperationException } catch (UnsupportedOperationException e) { System.out.println("Cannot sort an immutable list"); } List.of() does not allow null elements. Passing null throws a NullPointerException immediately at creation time, not later during access. This is a deliberate design choice — nulls in collections are a common source of bugs. // null elements are rejected at creation time try { List<String> bad = List.of("a", null, "b"); // NullPointerException } catch (NullPointerException e) { System.out.println("null not allowed in List.of()"); }

Set.of() creates an immutable set with the same factory method pattern. Like List.of(), it has overloaded variants for 0-10 elements plus varargs. Duplicate elements are rejected with an IllegalArgumentException at creation time.

// Empty immutable set Set<String> empty = Set.of(); // Single element Set<Integer> singleton = Set.of(42); // Multiple elements — no duplicates allowed Set<String> vowels = Set.of("a", "e", "i", "o", "u"); // contains() works as expected System.out.println(vowels.contains("a")); // true System.out.println(vowels.contains("z")); // false System.out.println(vowels.size()); // 5 // Duplicate elements throw IllegalArgumentException at creation try { Set<String> bad = Set.of("x", "y", "x"); // IllegalArgumentException } catch (IllegalArgumentException e) { System.out.println("Duplicate element: " + e.getMessage()); } // null elements throw NullPointerException try { Set<String> bad = Set.of("a", null); // NullPointerException } catch (NullPointerException e) { System.out.println("null not allowed in Set.of()"); } The iteration order of Set.of() is deliberately unspecified and may change between JVM runs. Do not depend on the order in which elements are returned by the iterator, forEach(), or toArray(). If you need ordered iteration, collect into a LinkedHashSet or use List.of() instead. // Mutation operations throw UnsupportedOperationException Set<String> protocols = Set.of("HTTP", "HTTPS", "FTP"); try { protocols.add("SSH"); // UnsupportedOperationException } catch (UnsupportedOperationException e) { System.out.println("Cannot add to immutable set"); } try { protocols.remove("FTP"); // UnsupportedOperationException } catch (UnsupportedOperationException e) { System.out.println("Cannot remove from immutable set"); }

Map.of() accepts alternating key-value pairs as arguments. Overloaded variants support 0 to 10 key-value pairs. For larger maps or when constructing entries programmatically, use Map.ofEntries() with Map.entry().

// Empty immutable map Map<String, Integer> empty = Map.of(); // Single entry Map<String, Integer> one = Map.of("key", 1); // Multiple entries — alternating keys and values Map<String, Integer> httpCodes = Map.of( "OK", 200, "Not Found", 404, "Internal Server Error", 500 ); System.out.println(httpCodes.get("OK")); // 200 System.out.println(httpCodes.size()); // 3 System.out.println(httpCodes.containsKey("OK")); // true

For more than 10 entries, or when you want named entries, use Map.ofEntries() with Map.entry():

import static java.util.Map.entry; // Map.ofEntries() — no limit on the number of entries Map<String, String> mimeTypes = Map.ofEntries( entry("html", "text/html"), entry("css", "text/css"), entry("js", "application/javascript"), entry("json", "application/json"), entry("xml", "application/xml"), entry("png", "image/png"), entry("jpg", "image/jpeg"), entry("gif", "image/gif"), entry("svg", "image/svg+xml"), entry("pdf", "application/pdf"), entry("zip", "application/zip"), entry("csv", "text/csv") ); System.out.println(mimeTypes.get("json")); // application/json // Duplicate keys throw IllegalArgumentException try { Map<String, Integer> bad = Map.of("a", 1, "a", 2); // IllegalArgumentException } catch (IllegalArgumentException e) { System.out.println("Duplicate key: " + e.getMessage()); } // null keys or values throw NullPointerException try { Map<String, Integer> bad = Map.of("a", 1, null, 2); // NullPointerException } catch (NullPointerException e) { System.out.println("null key not allowed"); } try { Map<String, Integer> bad = Map.of("a", null); // NullPointerException } catch (NullPointerException e) { System.out.println("null value not allowed"); } Like Set.of(), the iteration order of Map.of() and Map.ofEntries() is unspecified. The order may differ between JVM invocations. If you need predictable iteration order, wrap with new LinkedHashMap<>(Map.of(...)).

While both produce unmodifiable lists, there are important structural differences. Collections.unmodifiableList() returns a read-only view of the original list, while List.of() creates a truly independent immutable instance.

Aspect Collections.unmodifiableList() List.of()
Java version Java 2+ Java 9+
Truly immutable? No — wrapper around original list. Changes to the backing list are visible through the wrapper. Yes — independent copy. No backing list can change it.
Null elements Allowed (if the backing list contains nulls) Not allowed — throws NullPointerException
Serialization Serializable (wrapper preserves backing list type) Serializable (custom compact format)
Identity (==) Always a new wrapper instance May return the same instance for empty collections
Memory overhead Wrapper object + original list + original array Compact field-based for 0-2 elements; array-based for 3+
Iteration order Same as backing list Insertion order (for List)
// Collections.unmodifiableList — a VIEW, not a copy List<String> original = new ArrayList<>(List.of("a", "b", "c")); List<String> view = Collections.unmodifiableList(original); System.out.println(view); // [a, b, c] // Modifying the original list changes the view! original.add("d"); System.out.println(view); // [a, b, c, d] — the "unmodifiable" view changed // List.of — truly immutable, no backing list List<String> immutable = List.of("a", "b", "c"); // There is no reference to a mutable list — nobody can change this To create a truly immutable copy before Java 9, you had to wrap a defensive copy: Collections.unmodifiableList(new ArrayList<>(original)). With Java 9+, List.copyOf(original) does this in a single call.

Java 10 added copyOf() methods that create immutable copies from existing collections. If the source is already an immutable collection created by List.of() / Set.of() / Map.of(), the same instance is returned (no copy is made).

// List.copyOf — creates an immutable copy of any Collection List<String> mutable = new ArrayList<>(); mutable.add("alpha"); mutable.add("beta"); mutable.add("gamma"); List<String> immutableCopy = List.copyOf(mutable); System.out.println(immutableCopy); // [alpha, beta, gamma] // Changes to the original do not affect the copy mutable.add("delta"); System.out.println(mutable.size()); // 4 System.out.println(immutableCopy.size()); // 3 — unaffected // If the source is already immutable, no copy is made List<String> original = List.of("x", "y", "z"); List<String> copy = List.copyOf(original); System.out.println(original == copy); // true — same instance // Set.copyOf — creates an immutable set from any Collection Set<Integer> mutableSet = new HashSet<>(Set.of(1, 2, 3, 4, 5)); Set<Integer> immutableSet = Set.copyOf(mutableSet); // Map.copyOf — creates an immutable map from any Map Map<String, Integer> mutableMap = new HashMap<>(); mutableMap.put("one", 1); mutableMap.put("two", 2); mutableMap.put("three", 3); Map<String, Integer> immutableMap = Map.copyOf(mutableMap); // Modifications to the original map do not affect the copy mutableMap.put("four", 4); System.out.println(mutableMap.size()); // 4 System.out.println(immutableMap.size()); // 3 copyOf() methods reject null elements and null keys/values. If the source collection contains null, a NullPointerException is thrown. Filter out nulls before calling copyOf(). // null elements cause NullPointerException List<String> withNulls = new ArrayList<>(); withNulls.add("a"); withNulls.add(null); withNulls.add("b"); try { List<String> copy = List.copyOf(withNulls); // NullPointerException } catch (NullPointerException e) { System.out.println("Cannot copy list containing null"); } // Filter nulls first List<String> safe = List.copyOf( withNulls.stream().filter(Objects::nonNull).toList() ); System.out.println(safe); // [a, b]

Java 10 added Collectors.toUnmodifiableList(), Collectors.toUnmodifiableSet(), and Collectors.toUnmodifiableMap() for collecting stream pipelines directly into immutable collections. Java 16 also added Stream.toList() as a shorthand.

import java.util.stream.Collectors; List<String> names = List.of("Alice", "Bob", "Charlie", "Dave", "Eve"); // Collectors.toUnmodifiableList() — Java 10+ List<String> longNames = names.stream() .filter(name -> name.length() > 3) .collect(Collectors.toUnmodifiableList()); System.out.println(longNames); // [Alice, Charlie, Dave] // Stream.toList() — Java 16+ (shorthand, also returns unmodifiable list) List<String> shortNames = names.stream() .filter(name -> name.length() <= 3) .toList(); System.out.println(shortNames); // [Bob, Eve] // Collectors.toUnmodifiableSet() — Java 10+ Set<Integer> evenNumbers = List.of(1, 2, 3, 4, 5, 6, 7, 8).stream() .filter(n -> n % 2 == 0) .collect(Collectors.toUnmodifiableSet()); System.out.println(evenNumbers); // some order of [2, 4, 6, 8] // Collectors.toUnmodifiableMap() — Java 10+ Map<String, Integer> nameLengths = names.stream() .collect(Collectors.toUnmodifiableMap( name -> name, // key mapper String::length // value mapper )); System.out.println(nameLengths); // {Alice=5, Bob=3, Charlie=7, Dave=4, Eve=3} // With merge function for duplicate keys List<String> words = List.of("hello", "world", "hello", "java"); Map<String, Integer> freq = words.stream() .collect(Collectors.toUnmodifiableMap( w -> w, w -> 1, Integer::sum )); System.out.println(freq); // {hello=2, world=1, java=1} Stream.toList() (Java 16) returns an unmodifiable list but with subtle differences from Collectors.toUnmodifiableList(): it allows null elements in the stream, whereas the collector does not. Both produce lists that throw UnsupportedOperationException on mutation.

The immutable collections returned by List.of(), Set.of(), Map.of(), and their copyOf() counterparts share these characteristics:

// Value-based — do not synchronize on immutable collections List<String> shared = List.of("a", "b", "c"); // BAD — identity-sensitive operation on value-based class synchronized (shared) { // compiler warning in newer JDKs // ... } // Compact internal representations: // List.of() → empty list singleton // List.of("a") → single-element field-based (no array) // List.of("a", "b") → two-element field-based (no array) // List.of("a", "b", "c") → array-based for 3+ elements // equals() and hashCode() work as expected List<String> list1 = List.of("x", "y"); List<String> list2 = List.of("x", "y"); System.out.println(list1.equals(list2)); // true System.out.println(list1.hashCode() == list2.hashCode()); // true // They are equal to mutable lists with the same content List<String> mutable = new ArrayList<>(List.of("x", "y")); System.out.println(list1.equals(mutable)); // true

Immutable collections fit naturally into several common Java patterns:

Returning immutable collections from APIs
public class UserService { private final Map<String, User> usersById = new ConcurrentHashMap<>(); // Return an immutable snapshot — callers cannot modify internal state public List<User> getActiveUsers() { return usersById.values().stream() .filter(User::isActive) .collect(Collectors.toUnmodifiableList()); } // Return an immutable map — safe to expose public Map<String, User> getUserSnapshot() { return Map.copyOf(usersById); } }
Defining constants
public class HttpHeaders { // Immutable constant sets — no risk of accidental modification public static final Set<String> HOP_BY_HOP_HEADERS = Set.of( "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "TE", "Trailers", "Transfer-Encoding", "Upgrade" ); public static final Map<Integer, String> STATUS_PHRASES = Map.ofEntries( Map.entry(200, "OK"), Map.entry(201, "Created"), Map.entry(204, "No Content"), Map.entry(301, "Moved Permanently"), Map.entry(400, "Bad Request"), Map.entry(401, "Unauthorized"), Map.entry(403, "Forbidden"), Map.entry(404, "Not Found"), Map.entry(500, "Internal Server Error") ); public static final List<String> ALLOWED_METHODS = List.of("GET", "POST", "PUT", "DELETE", "PATCH"); }
Creating test data
@Test void shouldFilterExpiredItems() { // Concise test fixtures — no mutable list ceremony List<Item> items = List.of( new Item("A", LocalDate.of(2025, 1, 1)), new Item("B", LocalDate.of(2024, 6, 15)), new Item("C", LocalDate.of(2026, 12, 31)) ); List<Item> active = itemService.filterActive(items, LocalDate.now()); assertEquals(List.of(items.get(2)), active); } @Test void shouldHandleEmptyInput() { // Empty immutable list — lightweight, reused singleton List<Item> result = itemService.filterActive(List.of(), LocalDate.now()); assertEquals(List.of(), result); }
Method parameters and switch/pattern matching
// Immutable collections as method defaults public List<String> getTags(String articleId) { Map<String, List<String>> tagMap = loadTags(); return tagMap.getOrDefault(articleId, List.of()); } // Using Set.of for membership checks public boolean isWeekend(DayOfWeek day) { return Set.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY).contains(day); }

Common patterns from pre-Java 9 code and their modern replacements:

Replacing Arrays.asList()
// OLD — Arrays.asList returns a fixed-size list backed by the array // It allows set() but not add()/remove() — confusing middle ground List<String> old1 = Arrays.asList("a", "b", "c"); old1.set(0, "z"); // works — modifies backing array // old1.add("d"); // throws UnsupportedOperationException // NEW — List.of is truly immutable, no modification at all List<String> new1 = List.of("a", "b", "c"); // new1.set(0, "z"); // UnsupportedOperationException // new1.add("d"); // UnsupportedOperationException
Replacing Collections.unmodifiableX()
// OLD — wrapping a defensive copy in an unmodifiable view public List<String> getRolesOld() { return Collections.unmodifiableList(new ArrayList<>(this.roles)); } public Set<String> getPermissionsOld() { return Collections.unmodifiableSet(new HashSet<>(this.permissions)); } public Map<String, String> getConfigOld() { return Collections.unmodifiableMap(new HashMap<>(this.config)); } // NEW — single call, truly immutable public List<String> getRoles() { return List.copyOf(this.roles); } public Set<String> getPermissions() { return Set.copyOf(this.permissions); } public Map<String, String> getConfig() { return Map.copyOf(this.config); }
Replacing Collections.singletonX() and Collections.emptyX()
// OLD List<String> empty = Collections.emptyList(); Set<String> emptySet = Collections.emptySet(); Map<String, String> emptyMap = Collections.emptyMap(); List<String> single = Collections.singletonList("only"); Set<String> singleSet = Collections.singleton("only"); Map<String, String> singleMap = Collections.singletonMap("key", "value"); // NEW — uniform API List<String> empty = List.of(); Set<String> emptySet = Set.of(); Map<String, String> emptyMap = Map.of(); List<String> single = List.of("only"); Set<String> singleSet = Set.of("only"); Map<String, String> singleMap = Map.of("key", "value");
Replacing stream collection into unmodifiable
// OLD — collect to mutable, then wrap List<String> old = Collections.unmodifiableList( items.stream() .filter(Item::isActive) .map(Item::getName) .collect(Collectors.toList()) ); // NEW (Java 10) — collect directly to unmodifiable List<String> modern = items.stream() .filter(Item::isActive) .map(Item::getName) .collect(Collectors.toUnmodifiableList()); // NEW (Java 16) — even shorter List<String> shortest = items.stream() .filter(Item::isActive) .map(Item::getName) .toList(); When migrating, watch for code that relies on null elements in collections. The new immutable collection factories reject null, so you may need to filter nulls or use Optional before switching. Also note that Arrays.asList() allowed set() — code relying on that behavior needs a different approach (e.g., new ArrayList<>(List.of(...))).