Immutable Collections in Java: List.of, copyOf, and unmodifiable

1. Overview

Java has many ways to create an “unmodifiable” collection: List.of, List.copyOf, Collections.unmodifiableList, Stream.toList()… At first glance they look similar, but they differ in three key ways: whether they copy data, whether they accept null, and whether they reflect changes to the source collection.

This article walks through each approach with concrete examples. By the end, you’ll know how to pick the right API, avoid unexpected NullPointerExceptions, and write proper defensive copies.

2. Why You Need Immutable Collections

Look at this Order class:

import java.util.List;

public class Order {
    private final List<String> items;

    public Order(List<String> items) {
        this.items = items;
    }

    public List<String> getItems() {
        return items;
    }
}

The final keyword only locks the reference, not the contents of the list. Code outside can still modify it:

List<String> items = new ArrayList<>(List.of("Laptop"));
Order order = new Order(items);

items.add("Mouse");              // modify the source list
order.getItems().add("Keyboard"); // modify via the getter

System.out.println(order.getItems());
[Laptop, Mouse, Keyboard]

Order has lost control of its own state. When the object is shared across threads, this kind of bug becomes even harder to find. Immutable collections solve this: no one can modify them, so you can share freely without needing synchronization.

3. List.of, Set.of, and Map.of

Starting with Java 9, factory methods like of create unmodifiable collections in a single line:

List<String> statuses = List.of("NEW", "PAID", "SHIPPED");
Set<String> currencies = Set.of("VND", "USD", "EUR");
Map<String, Integer> stock = Map.of("Laptop", 5, "Mouse", 20);

statuses.add("CANCELLED");
Exception in thread "main" java.lang.UnsupportedOperationException

Every mutating method like add, remove, set, clear, sort, or put throws UnsupportedOperationException. The exception is always thrown, even if the operation wouldn’t actually change anything, like removing a non-existent element.

Map.of only has overloads up to 10 key–value pairs. For more, use Map.ofEntries:

Map<String, Integer> prices = Map.ofEntries(
        Map.entry("Laptop", 25_000_000),
        Map.entry("Mouse", 300_000)
);

3.1. They reject null

Collections created with of refuse null immediately during initialization:

List<String> names = List.of("An", null);
Exception in thread "main" java.lang.NullPointerException

There’s a trickier gotcha: even querying for null throws an error.

List<String> names = List.of("An", "Bình");
System.out.println(names.contains(null));
Exception in thread "main" java.lang.NullPointerException

With ArrayList, that call would return false. If your existing code has list.contains(input) where input might be null, switching to List.of will break it.

3.2. Set and Map: duplicates and order

Set.of and Map.of don’t silently skip duplicate elements—they throw an error:

Set<String> tags = Set.of("java", "spring", "java");
Exception in thread "main" java.lang.IllegalArgumentException: duplicate element: java

The iteration order of Set.of and Map.of is not guaranteed. In OpenJDK, this order even changes between JVM runs. So a test comparing the toString() of a Set.of might pass one time and fail the next. If you need to preserve order, use LinkedHashSet or LinkedHashMap and wrap it, as shown in section 5.

4. List.copyOf: a defensively copied immutable list

List.of works when you know the elements upfront. But when you already have a collection, like a parameter passed to a constructor, you use List.copyOf (available since Java 10):

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> copy = List.copyOf(source);

source.add("Keyboard");

System.out.println(source);
System.out.println(copy);
[Laptop, Mouse, Keyboard]
[Laptop, Mouse]

copy is an independent copy. Changes to source don’t affect it. Like List.of, copyOf also throws NullPointerException if the source collection contains null.

One neat detail: if the input is already an immutable list created by List.of or List.copyOf, OpenJDK returns that same instance without copying:

List<String> a = List.of("x", "y");
System.out.println(List.copyOf(a) == a);
true

So calling copyOf “just to be safe” is nearly free. However, this is an implementation detail, so don’t write logic that relies on == comparisons.

Similarly, there are also Set.copyOf and Map.copyOf. Notably, Set.copyOf does not throw an error if the source has duplicate elements—it just keeps one copy, since the source might be a List.

5. Collections.unmodifiableList: just a view

Collections.unmodifiableList has existed since Java 1.2 and works very differently. It does not copy, but rather wraps the source collection in a write-blocking layer:

List<String> source = new ArrayList<>(List.of("Laptop", "Mouse"));
List<String> view = Collections.unmodifiableList(source);

source.add("Keyboard");

System.out.println(view);
view.add("Monitor");
[Laptop, Mouse, Keyboard]
Exception in thread "main" java.lang.UnsupportedOperationException

You can’t modify it through view, but anyone holding a reference to source can still modify it, and those changes show up immediately on view. So “unmodifiable” means “can’t be modified through this reference,” not “is immutable.”

This view behavior is actually useful when you want the caller to see the latest data but not be able to modify it:

public class ShoppingCart {
    private final List<String> items = new ArrayList<>();

    public void addItem(String item) {
        items.add(item);
    }

    public List<String> getItems() {
        return Collections.unmodifiableList(items);
    }
}

The getter returns a view without copying, so the cost is O(1). Callers can only read, while ShoppingCart retains full control to modify internally.

One final difference: this view accepts null. A call to contains(null) returns false instead of throwing an error.

6. Other options and comparison table

There are a few other APIs that are often confused:

// Java 16+: result is unmodifiable, ACCEPTS null
List<String> a = Stream.of("A", null).toList();

// Java 10+: result is unmodifiable, REJECTS null
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());

// Fixed size but still allows set(), mutates the underlying array
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z

Summary table:

API Copies? null Sees source changes? Modifiable?
List.of(...) No source Throws NPE N/A No
List.copyOf(c) Yes Throws NPE No No
Collections.unmodifiableList(l) No (view) Allowed Yes No (via view)
stream.toList() Yes Allowed No No
Collectors.toUnmodifiableList() Yes Throws NPE No No
Arrays.asList(arr) No (view of array) Allowed Yes Only set

7. Shallow immutability

All the APIs above only lock the structure of the collection, not the objects inside:

public class OrderLine {
    private int quantity;

    public OrderLine(int quantity) { this.quantity = quantity; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

List<OrderLine> lines = List.of(new OrderLine(1));
lines.get(0).setQuantity(99);
System.out.println(lines.get(0).getQuantity());
99

You can’t add or remove elements, but you can still modify the contents of each element. For true immutability, the elements themselves must also be immutable. The cleanest way is to use record:

public record OrderLine(String product, int quantity) {}

Putting it together, here’s the standard pattern for a defensive copy in an immutable aggregate:

import java.util.List;

public record Order(String customer, List<OrderLine> lines) {
    public Order {
        lines = List.copyOf(lines);
    }
}

The compact constructor replaces the lines parameter with an immutable copy before assigning it to the field. The accessor lines() returns that same copy, so you don’t need to copy again in the getter. If lines is null or contains null, object creation fails immediately, rather than causing errors somewhere downstream.

8. Testing the behavior

Add JUnit 5 and AssertJ to your pom.xml. If you’re using Spring Boot 3.x, spring-boot-starter-test already includes both:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.11.3</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>

The test below verifies three key behaviors of Order and of views:

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class OrderTest {

    @Test
    void shouldNotReflectChangesToSourceList() {
        List<OrderLine> source = new ArrayList<>();
        source.add(new OrderLine("Laptop", 1));

        Order order = new Order("An", source);
        source.add(new OrderLine("Mouse", 2));

        assertThat(order.lines()).hasSize(1);
    }

    @Test
    void shouldRejectModificationThroughAccessor() {
        Order order = new Order("An", List.of(new OrderLine("Laptop", 1)));

        assertThatThrownBy(() -> order.lines().add(new OrderLine("Mouse", 2)))
                .isInstanceOf(UnsupportedOperationException.class);
    }

    @Test
    void unmodifiableViewShouldReflectSourceChanges() {
        List<String> source = new ArrayList<>(List.of("A"));
        List<String> view = Collections.unmodifiableList(source);

        source.add("B");

        assertThat(view).containsExactly("A", "B");
    }
}

The first test confirms that List.copyOf in the compact constructor has decoupled Order from the source list. The second test ensures the accessor doesn’t open a path for modification. The last test documents the view behavior, so no one mistakes unmodifiableList for a copy.

9. Common mistakes and when not to use it

Heads up: List.of(...).contains(null) and indexOf(null) throw NullPointerException. Check for null first, or use Objects.equals when iterating.

Some other common pitfalls:

  • Thinking unmodifiableList is immutable. It’s just a view. If the source is held elsewhere, the data can still change. For a snapshot, use List.copyOf.
  • Blindly replacing collect(Collectors.toList()) with toList(). Collectors.toList() returns a modifiable ArrayList. If code downstream calls add or sort on the result, it will throw UnsupportedOperationException at runtime.
  • Calling sort in place. List.of(...).sort(...) always throws. You need to first copy to an ArrayList, or use stream().sorted().toList().
  • Relying on the iteration order of Set.of or Map.of in logs, JSON, or tests.
  • Passing data from untrusted sources that might contain null (JSON, database results) directly to List.copyOf. Filter first, or use stream().toList() if null is a valid value.

There are cases where you should not use immutable collections:

  • Collections that change frequently within a short local scope, like a buffer accumulating results in a single method. ArrayList is simpler and faster.
  • Some frameworks require modifiable collections. For example, JPA entities with Hibernate, where @OneToMany fields must be modifiable collections for Hibernate to manage. In that case, keep ArrayList internally and return a view from the getter.
  • Very large collections that are updated frequently. Each “modification” requires copying the entire collection, which is O(n).

10. Conclusion

Three main APIs serve three different purposes:

  • List.of quickly creates a fixed-size list that doesn’t contain null.
  • List.copyOf creates an independent snapshot. This is the default choice for defensive copies in constructors or records.
  • Collections.unmodifiableList is a read-only view over data that’s still changing. It’s cheap, but not immutable.

You should also remember that every immutable collection in the JDK only provides shallow immutability. Combine them with immutable elements like records to have truly thread-safe shared objects.

javacollectionsimmutablejava-21clean-code