Part 6 of 16

Collection Factory Methods (JEP 269): Immutable List, Set, and Map

Why Collection Factory Methods?

Before Java 9, creating a small immutable collection was tedious:

// Java 8 — three lines, two classes, mutable intermediate
List<String> names = Collections.unmodifiableList(
    Arrays.asList("Alice", "Bob", "Charlie")
);

// Java 8 — even worse for Map
Map<String, Integer> scores = new HashMap<>();
scores.put("Alice", 90);
scores.put("Bob", 85);
Map<String, Integer> immutableScores = Collections.unmodifiableMap(scores);

JEP 269 (Java 9) introduced static factory methods that create truly immutable collections with a single expression:

var names  = List.of("Alice", "Bob", "Charlie");
var scores = Map.of("Alice", 90, "Bob", 85);

List.of()

List<String> empty    = List.of();
List<String> one      = List.of("Alice");
List<String> two      = List.of("Alice", "Bob");
List<String> many     = List.of("Alice", "Bob", "Charlie", "Dave");

// Iteration
for (var name : List.of("Alice", "Bob")) {
    System.out.println(name);
}

// Usage as method argument
process(List.of(1, 2, 3, 4, 5));

Guarantees

  1. Immutable: add(), remove(), set(), and clear() throw UnsupportedOperationException.
  2. Null-free: any null element throws NullPointerException immediately.
  3. Serialisable: all implementations are Serializable.
  4. Order preserved: iteration order matches the factory argument order.
  5. No identity guarantee: List.of("a") == List.of("a") may be false — do not rely on reference equality.
var list = List.of("a", "b");
list.add("c");            // UnsupportedOperationException
list.set(0, "x");         // UnsupportedOperationException
list.get(0);              // "a" — reads are fine

List.of("a", null);       // NullPointerException — null not allowed

Performance

List.of() uses compact internal representations:

  • 0 elements: singleton List12 or ListN instance (shared)
  • 1–2 elements: specialised List12 class (no array allocation)
  • 3+ elements: ListN backed by a single array

This is more memory-efficient than Arrays.asList() (which wraps the original array with a full AbstractList wrapper) or ArrayList (which allocates an array with initial capacity 10).


Set.of()

Set<String> roles = Set.of("ADMIN", "USER", "MODERATOR");
Set<Integer> primes = Set.of(2, 3, 5, 7, 11);

roles.contains("ADMIN")   // true
roles.size()              // 3

Guarantees

  1. Immutable: mutation throws UnsupportedOperationException.
  2. Null-free: NullPointerException if any element is null.
  3. No duplicates: duplicate elements throw IllegalArgumentException at creation time.
  4. Iteration order not guaranteed: unlike List.of(), set iteration order is undefined and may vary between JVM runs.
Set.of("a", "a");           // IllegalArgumentException — duplicate element
Set.of("a", null);          // NullPointerException — null not allowed

Map.of()

Up to 10 key-value pairs, specified as alternating key and value arguments:

Map<String, Integer> httpCodes = Map.of(
    "OK",           200,
    "Created",      201,
    "BadRequest",   400,
    "NotFound",     404,
    "ServerError",  500
);

httpCodes.get("OK")        // 200
httpCodes.size()           // 5

Guarantees

  1. Immutable: put(), remove(), putAll() throw UnsupportedOperationException.
  2. Null-free: NullPointerException if any key or value is null.
  3. No duplicate keys: IllegalArgumentException at creation time.
  4. Iteration order not guaranteed.
Map.of("a", 1, "a", 2);       // IllegalArgumentException — duplicate key
Map.of(null, 1);               // NullPointerException — null key not allowed
Map.of("a", null);             // NullPointerException — null value not allowed

Map.ofEntries() — More Than 10 Pairs

Map.of() is limited to 10 key-value pairs. For more, use Map.ofEntries():

import static java.util.Map.entry;

Map<String, String> countryCodes = Map.ofEntries(
    entry("GB", "United Kingdom"),
    entry("US", "United States"),
    entry("DE", "Germany"),
    entry("FR", "France"),
    entry("JP", "Japan"),
    entry("CN", "China"),
    entry("IN", "India"),
    entry("BR", "Brazil"),
    entry("AU", "Australia"),
    entry("CA", "Canada"),
    entry("RU", "Russia"),    // 11th pair — not possible with Map.of()
    entry("ZA", "South Africa")
);

Map.entry(K, V) creates an immutable Map.Entry. The static import makes the syntax clean.


copyOf() — Defensive Copies (Java 10)

Java 10 added List.copyOf(), Set.copyOf(), and Map.copyOf() which create immutable copies of existing collections:

// Defensive copy: accept a list but store an immutable snapshot
public class Config {
    private final List<String> allowedHosts;

    public Config(List<String> hosts) {
        this.allowedHosts = List.copyOf(hosts);  // immutable snapshot
    }

    public List<String> getAllowedHosts() {
        return allowedHosts;  // safe to return — caller cannot mutate it
    }
}

// Usage
var hosts = new ArrayList<>(List.of("host1", "host2"));
var config = new Config(hosts);
hosts.add("malicious-host");  // modifying original list
config.getAllowedHosts();      // still ["host1", "host2"]

copyOf() does not copy if the input is already an unmodifiable list created by List.of() or copyOf() — it returns the same instance for efficiency.


Collectors.toUnmodifiableList/Set/Map (Java 10)

These collectors integrate the immutable collection pattern into streams:

// Java 8 — mutable result
var names = users.stream()
    .map(User::getName)
    .collect(Collectors.toList());  // mutable ArrayList

// Java 10 — immutable result
var names = users.stream()
    .map(User::getName)
    .collect(Collectors.toUnmodifiableList());

var lookup = users.stream()
    .collect(Collectors.toUnmodifiableMap(
        User::getId,
        User::getName
    ));

Common Patterns

Constants and configuration

public class AppConstants {
    public static final Set<String> SUPPORTED_METHODS =
        Set.of("GET", "POST", "PUT", "DELETE", "PATCH");

    public static final Map<Integer, String> HTTP_REASONS = Map.ofEntries(
        entry(200, "OK"),
        entry(201, "Created"),
        entry(204, "No Content"),
        entry(400, "Bad Request"),
        entry(401, "Unauthorized"),
        entry(403, "Forbidden"),
        entry(404, "Not Found"),
        entry(500, "Internal Server Error")
    );
}

Test data setup

@Test
void shouldFilterAdults() {
    var users = List.of(
        new User("Alice", 25),
        new User("Bob", 16),
        new User("Charlie", 30)
    );

    var adults = users.stream()
        .filter(u -> u.getAge() >= 18)
        .collect(Collectors.toList());

    assertEquals(List.of("Alice", "Charlie"),
        adults.stream().map(User::getName).collect(Collectors.toList()));
}

Lookup table

var priorityMap = Map.of(
    "CRITICAL", 1,
    "HIGH",     2,
    "MEDIUM",   3,
    "LOW",      4
);

var sorted = tickets.stream()
    .sorted(Comparator.comparingInt(t ->
        priorityMap.getOrDefault(t.getPriority(), Integer.MAX_VALUE)))
    .collect(Collectors.toList());

Comparison: Factory Methods vs Alternatives

List.of()Arrays.asList()new ArrayList<>()Collections.unmodifiableList()
ImmutableYes (fully)No (set() works)NoYes (wrapper)
Null elementsNoYesYesDepends on wrapped list
MemoryCompactArray wrapperArray + capacityWrapper + original
Use caseConstants, argsFixed-size mutableGeneral mutableDefensive immutable copy

What’s Next

Next: Stream & Optional API Enhancements (Java 9–11)