Coleções imutáveis em Java: List.of, copyOf e unmodifiable

1. Visão geral

Java tem várias maneiras de criar uma coleção “não modificável”: List.of, List.copyOf, Collections.unmodifiableList, Stream.toList()… À primeira vista parecem iguais, mas diferem em três pontos: se fazem cópia de dados, se aceitam null, e se refletem mudanças da coleção original.

Este artigo passa por cada forma com exemplos concretos. Depois de ler, você saberá escolher a API certa, evitar NullPointerException inesperado e escrever defensive copy da forma correta.

2. Por que precisamos de coleções imutáveis

Veja a classe Order abaixo:

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;
    }
}

A palavra-chave final apenas bloqueia a referência, não o conteúdo da lista. Por fora ainda é possível modificar:

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

items.add("Mouse");              // modifica a lista original
order.getItems().add("Keyboard"); // modifica através do getter

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

Order não consegue mais controlar o estado de si mesmo. Quando o objeto é compartilhado entre múltiplas threads, esse tipo de erro fica ainda mais difícil de encontrar. Coleções imutáveis resolvem o problema: ninguém consegue modificar, então você pode compartilhar livremente sem precisar sincronizar.

3. List.of, Set.of e Map.of

A partir do Java 9, os factory methods of criam coleções não modificáveis em uma única linha:

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

Todos os métodos que modificam dados como add, remove, set, clear, sort e put lançam UnsupportedOperationException. A exceção é lançada sempre, mesmo quando a operação não mudaria nada, por exemplo remover um elemento que não existe.

Map.of só tem sobrecarga até 10 pares chave-valor. Se precisar de mais, use Map.ofEntries:

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

3.1. Não aceitam null

As coleções criadas com of rejeitam null assim que são inicializadas:

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

Tem uma pegadinha ainda mais fácil de cair: até mesmo consultar por null lança erro.

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

Com ArrayList, essa chamada retornaria false. Se seu código antigo tem algo como list.contains(input) onde input pode ser null, mudar para List.of vai quebrar isso em runtime.

3.2. Set e Map: duplicatas e ordem

Set.of e Map.of não ignoram silenciosamente elementos duplicados, lançam erro:

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

A ordem de iteração de Set.of e Map.of não é garantida. No OpenJDK, essa ordem até muda entre execuções diferentes da JVM. Por isso, testes que comparam a string toString() de um Set.of podem passar às vezes e falhar outras. Se precisar manter ordem, use LinkedHashSet ou LinkedHashMap e envolva depois, como na seção 5.

4. List.copyOf: cópia não modificável

List.of funciona bem quando você já sabe os elementos. Mas quando você já tem uma coleção, por exemplo um parâmetro passado no constructor, use List.copyOf (disponível desde 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 é uma cópia independente. Mudanças em source não afetam ela. Como List.of, copyOf também lança NullPointerException se a coleção fonte contém null.

Um detalhe interessante: se a entrada já é uma lista imutável criada por List.of ou List.copyOf, o OpenJDK retorna aquela mesma instância sem fazer cópia novamente:

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

Por isso, chamar copyOf “por segurança” praticamente não custa nada. Mas esse é um detalhe de implementação, então não escreva lógica dependendo dessa comparação com ==.

Também existem Set.copyOf e Map.copyOf. Só que Set.copyOf não lança erro se a fonte tiver duplicatas, apenas mantém uma única cópia de cada, porque a fonte pode ser uma List.

5. Collections.unmodifiableList: apenas uma view

Collections.unmodifiableList existe desde Java 1.2 e funciona bem diferente. Ele não copia, apenas envolve a coleção original em uma classe que bloqueia escrita:

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

Você não consegue modificar através da view, mas quem segura a referência source ainda consegue, e as mudanças aparecem imediatamente na view. Por isso unmodifiable significa “não modificável através dessa referência”, não “imutável”.

Esse comportamento de view é bem útil quando você quer que o chamador veja os dados mais recentes mas sem poder modificar:

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);
    }
}

O getter retorna uma view sem copiar, então o custo é O(1). O chamador só consegue ler, enquanto ShoppingCart mantém total controle para modificar internamente.

Uma última diferença: essa view aceita null. Chamar contains(null) retorna false em vez de lançar erro.

6. Outras opções e tabela comparativa

Existem mais algumas APIs que costumam ser confundidas:

// Java 16+: resultado não modificável, ACEITA null
List<String> a = Stream.of("A", null).toList();

// Java 10+: resultado não modificável, NÃO aceita null
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());

// Tamanho fixo mas ainda permite set(), escreve diretamente no array original
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z

Tabela resumida:

API Copia? null Vê mudanças da fonte? Modificável?
List.of(...) Sem fonte Lança NPE Não aplica Não
List.copyOf(c) Sim Lança NPE Não Não
Collections.unmodifiableList(l) Não (view) Aceita Sim Não (via view)
stream.toList() Sim Aceita Não Não
Collectors.toUnmodifiableList() Sim Lança NPE Não Não
Arrays.asList(arr) Não (view do array) Aceita Sim Apenas set

7. Imutabilidade superficial (shallow immutability)

Todas as APIs acima apenas travam a estrutura da coleção, não os elementos dentro:

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

Você não consegue adicionar ou remover elementos, mas ainda consegue modificar o conteúdo de cada elemento. Para verdadeira imutabilidade, os elementos também precisam ser imutáveis. A forma mais concisa é usar record:

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

Juntando tudo, aqui está o padrão de defensive copy correto para um aggregate imutável:

import java.util.List;

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

O compact constructor substitui o parâmetro lines por uma cópia não modificável antes de atribuir ao field. O accessor lines() retorna aquela mesma cópia, então você não precisa copiar novamente no getter. Se lines for null ou contiver null, o objeto falha logo na criação em vez de causar erro em um lugar distante.

8. Testando o comportamento

Adicione JUnit 5 e AssertJ ao pom.xml. Se usar Spring Boot 3.x, spring-boot-starter-test já inclui ambos:

<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>

O teste abaixo verifica três comportamentos principais de Order e da view:

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");
    }
}

O primeiro teste confirma que List.copyOf no compact constructor separou Order da lista original. O segundo garante que o accessor não abre caminho para modificação de dados. O último documenta o comportamento de view, para que ninguém confunda unmodifiableList com uma cópia.

9. Erros comuns e quando não usar

Atenção: List.of(...).contains(null) e indexOf(null) lançam NullPointerException. Verifique null antes, ou use Objects.equals ao iterar a lista.

Alguns outros erros comuns:

  • Achar que unmodifiableList é imutável. É apenas uma view. Se a fonte ainda for referenciada em algum lugar, os dados ainda podem mudar. Para um snapshot use List.copyOf.
  • Trocar collect(Collectors.toList()) por toList() mecanicamente. Collectors.toList() retorna um ArrayList modificável. Se depois houver código chamando add ou sort no resultado, vai lançar UnsupportedOperationException em runtime.
  • Chamar sort no lugar. List.of(...).sort(...) sempre lança erro. Você precisa copiar para ArrayList primeiro, ou usar stream().sorted().toList().
  • Depender da ordem de Set.of ou Map.of em logs, JSON ou testes.
  • Passar dados de fonte externa que pode conter null (JSON, resultado de banco de dados) direto em List.copyOf. Filtre antes, ou use stream().toList() se null é um valor válido.

Existem alguns casos onde não deveria usar coleção imutável:

  • A coleção precisa mudar constantemente em um ciclo de vida curto e local, por exemplo um buffer acumulando resultados em um método. ArrayList é mais simples e rápido.
  • Algumas bibliotecas esperam coleção modificável, como entidades JPA com Hibernate, onde field @OneToMany deve ser modificável para o Hibernate gerenciar. Nesse caso mantenha ArrayList internamente e retorne apenas uma view no getter.
  • Coleção muito grande atualizada frequentemente. Cada “modificação” precisaria copiar tudo, custando O(n).

10. Conclusão

As três APIs principais servem três propósitos diferentes:

  • List.of cria rápido uma lista fixa sem null.
  • List.copyOf cria um snapshot independente. É a escolha padrão para defensive copy em constructor ou record.
  • Collections.unmodifiableList é uma view somente leitura sobre dados que ainda mudam. É barato, mas não é imutável.

Você também deve lembrar que toda coleção imutável no JDK só tem imutabilidade superficial. Combine-as com elementos imutáveis como record para ter objetos verdadeiramente seguros ao compartilhar entre threads.

javacollectionsimmutablejava-21clean-code