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
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 Collections.unmodifiableList(new ArrayList<>(original)) patterns.
- Intent signaling — using List.of() communicates clearly that the collection is a fixed set of values, not a builder that will be modified later.
- 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");
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:
- No null elements — all factory methods and copyOf() reject null. Passing null throws NullPointerException at creation time.
- Structurally immutable — add(), remove(), put(), set(), clear(), sort() all throw UnsupportedOperationException.
- Iteration order — List preserves insertion order. Set and Map have unspecified iteration order that may differ between JVM runs.
- 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, synchronized) on them.
- Duplicate rejection — Set.of() and Map.of() reject duplicate elements or keys with 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
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(...))).