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) e indexOf(null) lanzan NullPointerException. Verifica null antes, o usa Objects.equals al iterar la lista.

Otros errores frecuentes:

  • Pensar que unmodifiableList es inmutable. Es solo una vista. Si la fuente se mantiene en algún lugar, los datos todavía pueden cambiar. Para un snapshot, usa List.copyOf.
  • Cambiar mecánicamente de collect(Collectors.toList()) a toList(). Collectors.toList() devuelve un ArrayList modificable. Si el código posterior llama add o sort en el resultado, lanzará UnsupportedOperationException en tiempo de ejecución.
  • Llamar a sort en el sitio. List.of(...).sort(...) siempre lanza un error. Necesitas copiar a ArrayList primero, o usar stream().sorted().toList().
  • Contar con el orden de Set.of o Map.of en logs, JSON o tests.
  • Pasar datos de una fuente externa que podría contener null (JSON, resultados de base de datos) directamente a List.copyOf. Filtra primero, o usa stream().toList() si null es 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. ArrayList es más simple y rápido.
  • Algunas librerías requieren colecciones modificables, como entidades JPA con Hibernate, donde un campo @OneToMany debe ser una colección modificable para que Hibernate la gestione. En ese caso, mantienes ArrayList internamente 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.of crea rápidamente una lista de tamaño fijo, sin null.
  • List.copyOf crea un snapshot independiente. Esta es la opción predeterminada para copias defensivas en constructores o record.
  • Collections.unmodifiableList es 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.

javacollectionsimmutablejava-21clean-code