Colecciones inmutables en Java: List.of, copyOf y unmodifiable
1. Descripción general
Java tiene muchas formas de crear una colección “que no se puede modificar”: List.of, List.copyOf, Collections.unmodifiableList, Stream.toList()… A primera vista parecen iguales, pero difieren en tres puntos: si copian datos, si aceptan null, y si reflejan cambios de la colección original.
Este artículo repasa cada forma con ejemplos concretos. Después de leerlo, sabrás elegir la API correcta, evitarás NullPointerException inesperados y podrás escribir copias defensivas de forma correcta.
2. Por qué necesitamos colecciones inmutables
Mira la clase Order siguiente:
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;
}
}
La palabra clave final solo bloquea la referencia, no el contenido de la lista. El código externo puede modificarla:
List<String> items = new ArrayList<>(List.of("Laptop"));
Order order = new Order(items);
items.add("Mouse"); // modifica la lista original
order.getItems().add("Keyboard"); // modifica a través del getter
System.out.println(order.getItems());
[Laptop, Mouse, Keyboard]
Order ya no puede controlar el estado de sí mismo. Cuando el objeto se comparte entre varios threads, este tipo de error es aún más difícil de encontrar. Las colecciones inmutables resuelven el problema: nadie puede modificarlas, así que puedes compartirlas libremente sin necesidad de sincronización.
3. List.of, Set.of y Map.of
Desde Java 9, los métodos factory of crean colecciones inmutables en una sola línea:
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 los métodos que modifican datos, como add, remove, set, clear, sort o put, lanzan UnsupportedOperationException. La excepción se lanza siempre, incluso cuando la operación no cambiaría nada, por ejemplo al eliminar un elemento que no existe.
Map.of solo tiene sobrecargas hasta 10 pares clave-valor. Si necesitas más, usa Map.ofEntries:
Map<String, Integer> prices = Map.ofEntries(
Map.entry("Laptop", 25_000_000),
Map.entry("Mouse", 300_000)
);
3.1. No aceptan null
Las colecciones creadas con of rechazan null en la inicialización:
List<String> names = List.of("An", null);
Exception in thread "main" java.lang.NullPointerException
Hay un punto más engañoso: incluso consultar null lanza un error.
List<String> names = List.of("An", "Bình");
System.out.println(names.contains(null));
Exception in thread "main" java.lang.NullPointerException
Con ArrayList, la llamada anterior devuelve false. Si tu código legado tiene list.contains(input) donde input podría ser null, cambiar a List.of romperá ese código.
3.2. Set y Map: duplicados y orden
Set.of y Map.of no ignoran silenciosamente elementos duplicados, sino que lanzan un error:
Set<String> tags = Set.of("java", "spring", "java");
Exception in thread "main" java.lang.IllegalArgumentException: duplicate element: java
El orden de iteración de Set.of y Map.of no está garantizado. En OpenJDK, este orden cambia incluso entre ejecuciones distintas de la JVM. Por eso, un test que compara toString() de un Set.of puede pasar una vez y fallar otra. Si necesitas preservar el orden, usa LinkedHashSet o LinkedHashMap y luego envuélvelas, como se ve en la sección 5.
4. List.copyOf: una copia inmutable
List.of es útil cuando sabes los elementos de antemano. Cuando ya tienes una colección, por ejemplo un parámetro del constructor, usas List.copyOf (disponible 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 es una copia independiente. Los cambios en source no afectan a copy. Como List.of, copyOf también lanza NullPointerException si la colección fuente contiene null.
Un detalle interesante: si la entrada ya es una lista inmutable creada por List.of o List.copyOf, OpenJDK devuelve esa misma instancia sin copiar:
List<String> a = List.of("x", "y");
System.out.println(List.copyOf(a) == a);
true
Gracias a eso, llamar a copyOf “para estar seguro” es prácticamente sin costo. Sin embargo, este es un detalle de implementación, así que no escribas lógica que dependa de comparar con ==.
También existen Set.copyOf y Map.copyOf. Especialmente, Set.copyOf no lanza un error cuando la fuente tiene duplicados, sino que solo mantiene uno, porque la fuente podría ser una List.
5. Collections.unmodifiableList: solo una vista
Collections.unmodifiableList existe desde Java 1.2 y funciona muy diferente. No copia, sino que envuelve la colección original en una capa que bloquea escrituras:
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
No puedes modificar a través de view, pero quien tiene la referencia source sigue pudiendo modificar, y los cambios aparecen inmediatamente en view. Por lo tanto, unmodifiable significa “no se puede modificar a través de esta referencia”, no “es inmutable”.
Este comportamiento de vista es útil cuando quieres que el que llama vea los datos más recientes pero sin permitir que los modifique:
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);
}
}
El getter devuelve una vista sin copiar, así que el costo es O(1). El que llama solo puede leer, mientras que ShoppingCart sigue controlando completamente la modificación.
Una última diferencia: esta vista sí acepta null. La llamada contains(null) devuelve false en lugar de lanzar un error.
6. Otras opciones y tabla de comparación
Hay más API que a menudo se confunden:
// Java 16+: resultado inmutable, ACEPTA null
List<String> a = Stream.of("A", null).toList();
// Java 10+: resultado inmutable, RECHAZA null
List<String> b = Stream.of("A", "B").collect(Collectors.toUnmodifiableList());
// Tamaño fijo pero SIGUE SIENDO set() posible, escribe directamente en el array original
String[] arr = {"A", "B"};
List<String> c = Arrays.asList(arr);
c.set(0, "Z");
System.out.println(arr[0]); // Z
Tabla resumen:
| API | ¿Copia? | null |
¿Ve cambios de la fuente? | ¿Se puede modificar? |
|---|---|---|---|---|
List.of(...) |
Sin fuente | Lanza NPE | No aplica | No |
List.copyOf(c) |
Sí | Lanza NPE | No | No |
Collections.unmodifiableList(l) |
No (vista) | Acepta | Sí | No (a través de la vista) |
stream.toList() |
Sí | Acepta | No | No |
Collectors.toUnmodifiableList() |
Sí | Lanza NPE | No | No |
Arrays.asList(arr) |
No (vista del array) | Acepta | Sí | Solo set |
7. Inmutabilidad superficial (shallow immutability)
Todas las API anteriores solo bloquean la estructura de la colección, no los 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
No puedes agregar ni eliminar elementos, pero puedes modificar el contenido de cada elemento. Para inmutabilidad verdadera, incluso los elementos deben ser inmutables. La forma más simple es usar record:
public record OrderLine(String product, int quantity) {}
Juntándolo todo, aquí está el patrón de copia defensiva estándar para un agregado inmutable:
import java.util.List;
public record Order(String customer, List<OrderLine> lines) {
public Order {
lines = List.copyOf(lines);
}
}
El constructor compacto reemplaza el parámetro lines con una copia inmutable antes de asignarlo al campo. El accessor lines() devuelve esa copia, así que no necesitas copiar nuevamente en el getter. Si lines es null o contiene null, el objeto fallará en la creación, en lugar de causar un error en un lugar más lejano.
8. Pruebas del comportamiento
Añade JUnit 5 y AssertJ a pom.xml. Si usas Spring Boot 3.x, spring-boot-starter-test ya incluye 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>
Las pruebas siguientes verifican tres comportamientos principales de Order y de la vista:
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");
}
}
La primera prueba confirma que List.copyOf en el constructor compacto ha separado Order de la lista original. La segunda asegura que el accessor no deja una puerta abierta para modificar datos. La última documenta el comportamiento de la vista, para que nadie confunda unmodifiableList con una copia.
9. Errores comunes y cuándo no usar
Nota:
List.of(...).contains(null)eindexOf(null)lanzanNullPointerException. Verificanullantes, o usaObjects.equalsal iterar la lista.
Otros errores frecuentes:
- Pensar que
unmodifiableListes inmutable. Es solo una vista. Si la fuente se mantiene en algún lugar, los datos todavía pueden cambiar. Para un snapshot, usaList.copyOf. - Cambiar mecánicamente de
collect(Collectors.toList())atoList().Collectors.toList()devuelve unArrayListmodificable. Si el código posterior llamaaddosorten el resultado, lanzaráUnsupportedOperationExceptionen tiempo de ejecución. - Llamar a
sorten el sitio.List.of(...).sort(...)siempre lanza un error. Necesitas copiar aArrayListprimero, o usarstream().sorted().toList(). - Contar con el orden de
Set.ofoMap.ofen logs, JSON o tests. - Pasar datos de una fuente externa que podría contener
null(JSON, resultados de base de datos) directamente aList.copyOf. Filtra primero, o usastream().toList()sinulles un valor válido.
Hay algunos casos donde no debes usar colecciones inmutables:
- Una colección que cambia constantemente en un ciclo de vida corto y local, como un buffer que acumula resultados en un método.
ArrayListes más simple y rápido. - Algunas librerías requieren colecciones modificables, como entidades JPA con Hibernate, donde un campo
@OneToManydebe ser una colección modificable para que Hibernate la gestione. En ese caso, mantienesArrayListinternamente y solo devuelves una vista en el getter. - Una colección muy grande que se actualiza frecuentemente. Cada “modificación” requiere copiar todo, lo que es O(n).
10. Conclusión
Las tres API principales sirven tres propósitos diferentes:
List.ofcrea rápidamente una lista de tamaño fijo, sinnull.List.copyOfcrea un snapshot independiente. Esta es la opción predeterminada para copias defensivas en constructores orecord.Collections.unmodifiableListes una vista de solo lectura sobre datos que siguen cambiando. Es eficiente, pero no es inmutable.
También debes recordar que toda colección inmutable en la JDK solo tiene inmutabilidad superficial. Combínalas con elementos inmutables como record para tener objetos que sean realmente seguros de compartir entre threads.