Immutable Collections
Create unmodifiable lists, sets and maps with of and copyOf, know how they differ from unmodifiable views, and avoid the null and ordering surprises.
List<String> roles = List.of("admin", "viewer");
Set<String> scopes = Set.copyOf(input);
Map<String, Integer> limits = Map.of("free", 100, "team", 1000);
List<String> names = users.stream().map(User::name).toList();The unmodifiable collections built by of, copyOf and Stream.toList() cannot be changed after they are created: add, put, remove, set and clear throw UnsupportedOperationException. That makes it safe to return from a service, store in a record, cache in a static field and share between threads without locks.
Factory methods
List.of(...),Set.of(...)andMap.of(k1, v1, ...)(Java 9) build small fixed collections.Map.oftakes up to ten pairs; beyond that useMap.ofEntries(Map.entry(k, v), ...).List.copyOf,Set.copyOfandMap.copyOf(Java 10) take a snapshot of an existing collection.stream.toList()(Java 16) andCollectors.toUnmodifiableList()(Java 10) collect a stream into an unmodifiable list.
Rules that catch people out
- No nulls. The
ofandcopyOfmethods throwNullPointerExceptionfor a null element, key or value. Evencontains(null)andcontainsKey(null)throw on these collections.Stream.toList()is the exception: it allows nulls. - No duplicates at creation.
Set.of("a", "a")andMap.ofwith a repeated key throwIllegalArgumentException. If the input may contain duplicates, useSet.copyOf, which keeps one of each. - No stable order for sets and maps.
Set.ofandMap.ofdeliberately vary their iteration order from one JVM run to the next. Code that passes a test once and fails the next run often depends on that order. Use aList, or copy into aTreeMaporLinkedHashMap, when order matters. - Shallow. An unmodifiable list of mutable objects still lets you change the objects. Hold records with immutable fields, or copy the inner collections as well.
Copy versus view
Collections.unmodifiableList(list) does not copy anything. It returns a read-only view: callers cannot change it, but they see every change the owner makes to the underlying list. List.copyOf(list) takes an independent snapshot. Use a view when callers should follow live data and you control all writes; use a copy for values that must not change, such as the components of a record or a response body. List.copyOf on a list that is already unmodifiable, such as one from List.of, usually returns the same instance, so defensive copies cost little.
Back-end angle
Configuration such as allowed CORS origins, lookup tables of status codes and the lists inside DTO records are natural fits. Code that collects with Collectors.toList() and later adds to the result works only because that collector currently returns an ArrayList; its documentation makes no promise about mutability.
Example
When to use it
- Keep the allowed CORS origins in a Set.copyOf built from configuration at start-up.
- Make the line items inside an order DTO record unmodifiable with List.copyOf in the compact constructor.
- Return Stream.toList() results from a service so controllers cannot change cached data.
More examples
Unmodifiable is shallow
List.of only stops you adding to or removing from the outer list. Copying each inner list makes the whole structure read-only.
void main() {
List<List<String>> teams = List.of(
new ArrayList<>(List.of("ada")),
new ArrayList<>(List.of("linus")));
teams.get(0).add("grace"); // the outer list is fixed, the inner ones are not
IO.println(teams);
List<List<String>> frozen = teams.stream().map(List::copyOf).toList();
try {
frozen.get(0).add("ken");
} catch (UnsupportedOperationException e) {
IO.println("inner lists are read-only too");
}
IO.println(frozen);
}
// Output:
// [[ada, grace], [linus]]
// inner lists are read-only too
// [[ada, grace], [linus]]Stream.toList, copyOf and nulls
Stream.toList is the one unmodifiable factory that accepts null elements. When you need to add to the result, ask for an ArrayList explicitly instead of relying on what Collectors.toList happens to return.
void main() {
List<String> raw = Arrays.asList("ada", null, "linus");
List<String> collected = raw.stream().toList();
IO.println("toList keeps null: " + collected);
try {
collected.add("grace");
} catch (UnsupportedOperationException e) {
IO.println("Stream.toList is unmodifiable");
}
try {
List.copyOf(raw);
} catch (NullPointerException e) {
IO.println("List.copyOf rejects null");
}
List<String> mutable = new ArrayList<>(raw.stream().filter(Objects::nonNull).toList());
mutable.add("grace");
IO.println("explicit ArrayList: " + mutable);
}
// Output:
// toList keeps null: [ada, null, linus]
// Stream.toList is unmodifiable
// List.copyOf rejects null
// explicit ArrayList: [ada, linus, grace]A constant lookup table with Map.ofEntries
Lookups are safe to print, but the map's own iteration order is not, so the program copies it into a TreeMap first. A null lookup throws instead of returning false, so check request input for null before using it as a key.
static final Map<Integer, String> REASONS = Map.ofEntries(
Map.entry(200, "OK"),
Map.entry(201, "Created"),
Map.entry(400, "Bad Request"),
Map.entry(404, "Not Found"),
Map.entry(409, "Conflict"));
void main() {
IO.println("404 -> " + REASONS.get(404));
IO.println("418 -> " + REASONS.getOrDefault(418, "Unknown"));
IO.println("sorted: " + new TreeMap<>(REASONS));
try {
REASONS.containsKey(null);
} catch (NullPointerException e) {
IO.println("containsKey(null) throws on Map.ofEntries");
}
}
// Output:
// 404 -> Not Found
// 418 -> Unknown
// sorted: {200=OK, 201=Created, 400=Bad Request, 404=Not Found, 409=Conflict}
// containsKey(null) throws on Map.ofEntries
Discussion