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.

SyntaxList<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(...) and Map.of(k1, v1, ...) (Java 9) build small fixed collections. Map.of takes up to ten pairs; beyond that use Map.ofEntries(Map.entry(k, v), ...).
  • List.copyOf, Set.copyOf and Map.copyOf (Java 10) take a snapshot of an existing collection.
  • stream.toList() (Java 16) and Collectors.toUnmodifiableList() (Java 10) collect a stream into an unmodifiable list.

Rules that catch people out

  • No nulls. The of and copyOf methods throw NullPointerException for a null element, key or value. Even contains(null) and containsKey(null) throw on these collections. Stream.toList() is the exception: it allows nulls.
  • No duplicates at creation. Set.of("a", "a") and Map.of with a repeated key throw IllegalArgumentException. If the input may contain duplicates, use Set.copyOf, which keeps one of each.
  • No stable order for sets and maps. Set.of and Map.of deliberately 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 a List, or copy into a TreeMap or LinkedHashMap, 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.