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)eindexOf(null)lançamNullPointerException. Verifiquenullantes, ou useObjects.equalsao 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 useList.copyOf. - Trocar
collect(Collectors.toList())portoList()mecanicamente.Collectors.toList()retorna umArrayListmodificável. Se depois houver código chamandoaddousortno resultado, vai lançarUnsupportedOperationExceptionem runtime. - Chamar
sortno lugar.List.of(...).sort(...)sempre lança erro. Você precisa copiar paraArrayListprimeiro, ou usarstream().sorted().toList(). - Depender da ordem de
Set.ofouMap.ofem logs, JSON ou testes. - Passar dados de fonte externa que pode conter
null(JSON, resultado de banco de dados) direto emList.copyOf. Filtre antes, ou usestream().toList()senullé 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
@OneToManydeve ser modificável para o Hibernate gerenciar. Nesse caso mantenhaArrayListinternamente 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.ofcria rápido uma lista fixa semnull.List.copyOfcria um snapshot independente. É a escolha padrão para defensive copy em constructor ourecord.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.