Java Immutable Collections: List.of, Map.of, Set.of
Java 9 introduced convenient factory methods for creating immutable collections: List.of(), Set.of(), and Map.of(). These methods produce compact, unmodifiable collection instances that reject null elements and throw UnsupportedOperationException on any mutation attempt. Java 10 extended this with copyOf() methods, and the Collectors class gained toUnmodifiableList/Set/Map. Together, these APIs replace verbose patterns like Collections.unmodifiableList(new ArrayList<>(Arrays.asList(...))) with clean, intent-revealing one-liners.
Contents
Why Immutable Collections List.of(): Creating Immutable Lists Set.of(): Creating Immutable Sets Map.of() and Map.ofEntries() Collections.unmodifiableList vs List.of List.copyOf, Set.copyOf, Map.copyOf Collectors.toUnmodifiableList Characteristics of Immutable Collections Common Patterns Migration Guide
Why Immutable Collections
Immutable collections offer several advantages that make them the preferred choice in modern Java code:
- Safety: once created, the collection cannot be altered. No code path can accidentally add, remove, or replace elements, eliminating an entire class of bugs.
- Thread safety: immutable objects are inherently thread-safe. Multiple threads can read from an immutable list, set, or map without synchronization or defensive copying.
- No defensive copies: when you return an immutable collection from a method, callers cannot mutate your internal state. This removes the need for
patterns.Collections.unmodifiableList(new ArrayList<>(original)) - Intent signaling: using
communicates clearly that the collection is a fixed set of values, not a builder that will be modified later.List.of() - Memory efficiency: the JDK internally uses field-based implementations for small collections (0-2 elements) instead of backing arrays, reducing overhead.
// 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");
List.of(): Creating Immutable Lists
The List.of()add()set()remove()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()nullnullNullPointerException// 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(): Creating Immutable Sets
Set.of()List.of()IllegalArgumentException
// 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()");
}
Set.of()forEach()toArray()LinkedHashSetList.of()// 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() and Map.ofEntries()
Map.of()Map.ofEntries()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()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");
}
Set.of()Map.of()Map.ofEntries()new LinkedHashMap<>(Map.of(...))Collections.unmodifiableList vs List.of
While both produce unmodifiable lists, there are important structural differences. Collections.unmodifiableList()List.of()
| 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
Collections.unmodifiableList(new ArrayList<>(original))List.copyOf(original)List.copyOf, Set.copyOf, Map.copyOf
Java 10 added copyOf()List.of()Set.of()Map.of()
// 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()nullnullnullNullPointerExceptioncopyOf()// 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]
Collectors.toUnmodifiableList
Java 10 added Collectors.toUnmodifiableList()Collectors.toUnmodifiableSet()Collectors.toUnmodifiableMap()Stream.toList()
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()Collectors.toUnmodifiableList()nullUnsupportedOperationExceptionCharacteristics of Immutable Collections
The immutable collections returned by List.of()Set.of()Map.of()copyOf()
- No null elements: all factory methods and
rejectcopyOf() . Passingnull throwsnull at creation time.NullPointerException - Structurally immutable:
,add() ,remove() ,put() ,set() ,clear() all throwsort() .UnsupportedOperationException - Iteration order:
preserves insertion order.List andSet have unspecified iteration order that may differ between JVM runs.Map - Serializable: all immutable collections are serializable, using a compact format that is independent of the implementation class.
- Value-based: these are considered value-based classes. Do not use identity-sensitive operations (
,== ,identityHashCode ) on them.synchronized - Duplicate rejection:
andSet.of() reject duplicate elements or keys withMap.of() .IllegalArgumentException
// 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
Common Patterns
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);
}
Migration Guide
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();
nullnullOptionalArrays.asList()set()new ArrayList<>(List.of(...))