Mutabilidad, orden e igualdad: lo que tu colección promete
Parte 2 de 3 de la serie sobre colecciones en Java. Viene de la parte 1: List no es ArrayList y sigue con la parte 3: streams.
Las promesas que no se ven en el código
En la parte 1 aprendimos a elegir una colección por lo que hace: posiciones, repetidos, claves. Pero hay una segunda capa de decisiones que no se ve en la línea List<Persona> personas = new ArrayList<>(), y que es la que separa un programa que funciona de uno que funciona siempre:
- ¿Alguien puede modificar esta lista después de que la devuelvo?
- ¿Cómo sabe un
HashSetque dosPersonason "la misma"? - ¿Qué significa "ordenar" una lista de
Persona? - ¿Qué pasa si dos partes del programa tocan la misma colección al mismo tiempo?
Cada una de esas preguntas tiene una respuesta precisa en Java, y cada respuesta tiene un error típico que vale la pena ver fallar antes de verlo corregido. Ese es el plan de este artículo.
Como en la parte 1, todos los ejemplos van dentro de un main con import java.util.*;, y las salidas son las reales de ejecutarlos en Java 21. Esta vez necesitamos además import java.util.concurrent.*; y import java.util.concurrent.atomic.*; para la segunda mitad. Seguimos con el mismo record Persona(String nombre, int edad, String ciudad) {}.
1. Tres grados de "no se puede cambiar"
Qué es. Una colección mutable se puede modificar: agregar, quitar, reemplazar. Hasta ahora casi todo lo que usamos era mutable. Pero Java ofrece dos grados de protección contra los cambios, y se confunden todo el tiempo porque los dos lanzan la misma excepción.
- Vista no modificable (
Collections.unmodifiableList): una "ventana" sobre la lista original. A través de la ventana no se puede modificar, pero si alguien modifica la lista original, la ventana lo refleja. - Inmutable (
List.of,List.copyOf): una copia que no cambia nunca, haga lo que haga el resto del programa.
Ejemplo.
// 1. Una lista normal, mutable
List<Integer> mutable = new ArrayList<>(List.of(2, 3, 5));
// 2. Una vista no modificable SOBRE esa lista
List<Integer> vista = Collections.unmodifiableList(mutable);
// 3. Una copia inmutable DE esa lista
List<Integer> inmutable = List.copyOf(mutable);
// 4. Modificamos la original
mutable.add(7);
System.out.println("mutable = " + mutable);
System.out.println("vista = " + vista);
System.out.println("inmutable = " + inmutable);Qué pasó.
mutable = [2, 3, 5, 7]
vista = [2, 3, 5, 7]
inmutable = [2, 3, 5]La vista "vio" el 7 que se agregó a la original. La copia inmutable no, porque es otra lista. Y ninguna de las dos acepta cambios directos:
vista.add(11);Exception in thread "main" java.lang.UnsupportedOperationExceptioninmutable.add(11);Exception in thread "main" java.lang.UnsupportedOperationExceptionLa vista no tiene datos propios: apunta a la lista original, y por eso ve sus cambios. La copia inmutable tiene los suyos y no apunta a nadie.
Un detalle más de List.of y List.copyOf que te va a morder si no lo sabes: no aceptan null.
List.of(1, null);Exception in thread "main" java.lang.NullPointerExceptionUn ArrayList sí acepta null como elemento. Es una de las pocas diferencias de contrato entre los dos.
Cuándo usar cada uno. List.of/List.copyOf cuando entregas datos a alguien y no quieres que los cambie (valores de retorno, constantes, parámetros que guardas). Collections.unmodifiableList cuando quieres exponer una colección interna como "solo lectura" pero que siga reflejando los cambios que tú le haces por dentro.
Lo que debes llevarte: la vista no modificable sigue a la original; la copia inmutable no. Las dos lanzan la misma excepción, pero no son lo mismo.
2. Por qué querrías que algo no cambie
Decir "inmutable" suena a limitación. En la práctica es lo contrario: es la propiedad que te permite dejar de preocuparte.
El problema. Tienes un record Equipo con una lista de miembros. Lo construyes con una lista que tenías a mano, y después sigues usando esa lista para otra cosa:
record Equipo(String nombre, List<Persona> miembros) {}
List<Persona> gente = new ArrayList<>(List.of(new Persona("Ana", 31, "San José")));
Equipo equipo = new Equipo("Backend", gente);
gente.add(new Persona("Luis", 45, "Heredia")); // seguimos usando "gente" para otra cosa
System.out.println("equipo = " + equipo.miembros().size());Sin protección, esto imprime 2: el equipo "cambió" sin que nadie llamara a un método del equipo. Es el tipo de error que aparece lejos de donde se causó y cuesta horas encontrar.
La solución: copia defensiva. El record guarda su propia copia inmutable en el constructor. En un record, el "constructor compacto" se escribe sin parámetros y sirve justo para esto:
record Equipo(String nombre, List<Persona> miembros) {
Equipo {
miembros = List.copyOf(miembros); // copia inmutable, propia del equipo
}
}Qué pasó.
gente = 2
equipo = 1Ahora equipo tiene un miembro, aunque gente tenga dos. Y si alguien intenta modificar la lista del equipo desde afuera:
equipo.miembros().add(new Persona("X", 1, "Y"));Exception in thread "main" java.lang.UnsupportedOperationExceptionLa analogía que conserva la estructura: un documento firmado. Una vez firmado no se edita; si hace falta otra versión, se hace otro documento. Lo que la analogía no cubre: en Java, crear "otro documento" es barato (List.copyOf de una lista ya inmutable ni siquiera copia; devuelve la misma).
Tres razones para preferir inmutable, en orden de importancia:
- Puedes compartirlo sin miedo. Si nadie puede cambiarlo, da igual cuántas partes del programa lo tengan.
- Puedes usarlo como clave de un
Mapo elemento de unSet. La sección siguiente explica por qué un objeto que cambia rompe esas colecciones. - Es seguro entre hilos sin hacer nada. Lo que no cambia no necesita sincronización. Volvemos a esto en la sección 7.
Lo que debes llevarte: cuando guardes una colección que te pasaron, guarda una copia inmutable. Lo que no puede cambiar no puede romperse a distancia.
3. equals y hashCode: cómo sabe un Set que dos cosas son iguales
Qué es. Toda clase en Java hereda dos métodos de Object: equals(otro), que responde "¿este objeto es igual a aquel?", y hashCode(), que devuelve el número que las colecciones Hash... usan para ubicar el objeto (lo vimos en la parte 1). La versión heredada de equals responde "sí" solo si son el mismo objeto en memoria, no si tienen el mismo contenido.
El error típico: un HashSet con "repetidos"
Una clase escrita a mano, sin equals ni hashCode:
static class PersonaClase {
String nombre;
int edad;
PersonaClase(String nombre, int edad) { this.nombre = nombre; this.edad = edad; }
public String toString() { return nombre + "(" + edad + ")"; }
}Set<PersonaClase> sinEquals = new HashSet<>();
sinEquals.add(new PersonaClase("Ana", 31));
sinEquals.add(new PersonaClase("Ana", 31));
System.out.println(sinEquals + " size=" + sinEquals.size());
System.out.println(new PersonaClase("Ana", 31).equals(new PersonaClase("Ana", 31)));Qué pasó.
[Ana(31), Ana(31)] size=2
falseEl conjunto tiene dos "Ana de 31". Para Java son objetos distintos: dos new producen dos objetos en memoria, y el equals heredado solo compara eso. El Set cumplió su contrato; el que no definió qué significa "igual" fuiste tú.
La solución: definir igualdad por contenido. Hay que sobrescribir los dos métodos juntos, siempre. El contrato de Java dice: si dos objetos son equals, deben tener el mismo hashCode. Si rompes eso, un HashSet puede guardar el objeto en una casilla y buscarlo en otra, y no encontrarlo nunca. Un record genera los dos por ti, a partir de todos sus campos:
Set<Persona> conRecord = new HashSet<>();
conRecord.add(new Persona("Ana", 31, "San José"));
conRecord.add(new Persona("Ana", 31, "San José"));
System.out.println(conRecord + " size=" + conRecord.size());[Persona[nombre=Ana, edad=31, ciudad=San José]] size=1Si no puedes usar un record (por ejemplo, la clase tiene que ser mutable, o extiende de otra), tu editor genera equals y hashCode por ti; lo importante es generarlos a partir de los mismos campos los dos.
El segundo error: mutar una clave después de guardarla
Si una clase sí tiene equals/hashCode por contenido, pero su contenido puede cambiar, aparece un error todavía más raro de encontrar:
static class Clave {
String valor;
Clave(String valor) { this.valor = valor; }
@Override public boolean equals(Object o) { return o instanceof Clave c && c.valor.equals(valor); }
@Override public int hashCode() { return valor.hashCode(); }
public String toString() { return valor; }
}Map<Clave, Integer> mapa = new HashMap<>();
Clave clave = new Clave("ana");
mapa.put(clave, 31);
System.out.println(mapa.get(clave));
clave.valor = "ANA"; // 1. cambiamos la clave DESPUÉS de guardarla
System.out.println(mapa.get(clave)); // 2. ¿la encuentra?
System.out.println(mapa.containsKey(clave) + " " + mapa.size() + " " + mapa);Qué pasó.
31
null
false 1 {ANA=31}El mapa dice que tiene una entrada, la imprime, y a la vez dice que la clave no está. Lo que pasó: al guardar, el HashMap calculó el hash de "ana" y puso la entrada en esa casilla. Al cambiar el valor a "ANA", el hash cambió, y ahora get busca en otra casilla, donde no hay nada. La entrada sigue ahí, pero es inalcanzable.
Por eso la regla es simple: las claves de un Map y los elementos de un Set deben ser inmutables. String, Integer, los record y los enum lo son. Si usas una clase propia, que no tenga setters.
Lo que debes llevarte:
equalsyhashCodese definen juntos y a partir de los mismos campos. Y lo que va dentro de unSeto como clave de unMapno debe cambiar.
4. Comparable y Comparator: qué significa "ordenar"
Qué es. Para ordenar números o textos, Java ya sabe cuál va antes: es el orden natural (menor a mayor, alfabético). Para tus clases, no lo sabe, y hay dos formas de decírselo:
Comparable: la clase misma declara su orden natural, implementando el métodocompareTo. Solo puede haber uno.Comparator: un objeto aparte que sabe comparar dos elementos. Puedes tener tantos como quieras, y se pasan asort,TreeSet,TreeMap,PriorityQueue...
El error típico: ordenar algo que no sabe ordenarse
List<Persona> personas = new ArrayList<>(List.of(
new Persona("Ana", 31, "San José"),
new Persona("Luis", 45, "Heredia"),
new Persona("Marta", 28, "Alajuela"),
new Persona("Luis", 29, "Cartago")));
Collections.sort(personas);Esto ni siquiera compila: Collections.sort exige que los elementos sean Comparable, y Persona no lo es. (Si lo fuerzas a compilar quitando los genéricos, falla al ejecutar con ClassCastException: Persona cannot be cast to class java.lang.Comparable.) Java no va a adivinar si una persona "va antes" por nombre, por edad o por ciudad. Tienes que decidirlo.
Comparable: un orden natural
Un Alumno con nota, donde "menor" significa "menor nota":
record Alumno(String nombre, int nota) implements Comparable<Alumno> {
public int compareTo(Alumno otro) {
return Integer.compare(nota, otro.nota);
}
}compareTo devuelve un número negativo si this va antes, cero si son equivalentes, positivo si va después. Integer.compare hace eso por ti con dos enteros; para textos, String ya tiene compareTo.
List<Alumno> alumnos = new ArrayList<>(List.of(
new Alumno("Ana", 88), new Alumno("Luis", 95), new Alumno("Marta", 72)));
Collections.sort(alumnos);
System.out.println(alumnos);
System.out.println(new TreeSet<>(alumnos));
System.out.println(Collections.max(alumnos));[Alumno[nombre=Marta, nota=72], Alumno[nombre=Ana, nota=88], Alumno[nombre=Luis, nota=95]]
[Alumno[nombre=Marta, nota=72], Alumno[nombre=Ana, nota=88], Alumno[nombre=Luis, nota=95]]
Alumno[nombre=Luis, nota=95]Con un solo compareTo, sort, TreeSet, max, min y PriorityQueue ya saben qué hacer con un Alumno.
Comparator: tantos órdenes como necesites
Para Persona no hay un orden "natural" obvio, así que no le ponemos Comparable. Le damos un Comparator distinto a cada uso:
// 1. Por edad
personas.sort(Comparator.comparing(Persona::edad));
System.out.println(personas);
// 2. Por nombre y, si empatan, por edad
personas.sort(Comparator.comparing(Persona::nombre).thenComparing(Persona::edad));
System.out.println(personas);
// 3. Por edad, de mayor a menor
personas.sort(Comparator.comparing(Persona::edad).reversed());
System.out.println(personas);[Persona[nombre=Marta, edad=28, ciudad=Alajuela], Persona[nombre=Luis, edad=29, ciudad=Cartago], Persona[nombre=Ana, edad=31, ciudad=San José], Persona[nombre=Luis, edad=45, ciudad=Heredia]]
[Persona[nombre=Ana, edad=31, ciudad=San José], Persona[nombre=Luis, edad=29, ciudad=Cartago], Persona[nombre=Luis, edad=45, ciudad=Heredia], Persona[nombre=Marta, edad=28, ciudad=Alajuela]]
[Persona[nombre=Luis, edad=45, ciudad=Heredia], Persona[nombre=Ana, edad=31, ciudad=San José], Persona[nombre=Luis, edad=29, ciudad=Cartago], Persona[nombre=Marta, edad=28, ciudad=Alajuela]]Comparator.comparing(Persona::edad) se lee "compara por lo que devuelve edad()". Persona::edad es una referencia al método; equivale a p -> p.edad(). thenComparing agrega el criterio de desempate, reversed invierte.
Si la lista puede tener null, el orden natural falla con NullPointerException. nullsFirst (o nullsLast) los acomoda:
List<String> conNulos = new ArrayList<>(Arrays.asList("Luis", null, "Ana"));
conNulos.sort(Comparator.nullsFirst(Comparator.naturalOrder()));
System.out.println(conNulos);[null, Ana, Luis]El tercer error: un TreeSet que "pierde" elementos
TreeSet y TreeMap no usan equals. Usan solo el Comparator (o compareTo): si la comparación da cero, consideran que es el mismo elemento. Mira lo que pasa con un comparador que solo mira la edad:
Set<Persona> porEdad = new TreeSet<>(Comparator.comparing(Persona::edad));
porEdad.add(new Persona("Ana", 31, "San José"));
porEdad.add(new Persona("Luis", 31, "Heredia"));
System.out.println(porEdad + " size=" + porEdad.size());[Persona[nombre=Ana, edad=31, ciudad=San José]] size=1Luis desapareció. Tiene la misma edad que Ana, el comparador dijo "cero", y el TreeSet entendió "ya está". Para un TreeSet el comparador debe distinguir todo lo que equals distingue. La corrección es desempatar:
Set<Persona> porEdadYNombre = new TreeSet<>(
Comparator.comparing(Persona::edad).thenComparing(Persona::nombre));
porEdadYNombre.add(new Persona("Ana", 31, "San José"));
porEdadYNombre.add(new Persona("Luis", 31, "Heredia"));
System.out.println(porEdadYNombre.size());2Cuándo usar cada uno. Comparable cuando la clase tiene un orden evidente y único (fechas, montos, notas). Comparator para todo lo demás, y siempre que el orden dependa del contexto.
Lo que debes llevarte:
Comparablees el orden natural de la clase;Comparatores un orden que eliges al usarla. Y unTreeSetsolo ve lo que su comparador distingue.
5. NavigableMap y NavigableSet: buscar "el más cercano"
Qué es. TreeMap y TreeSet mantienen todo ordenado, y eso permite preguntas que las versiones Hash... no pueden responder: ¿cuál es la clave más cercana a este valor? ¿Cuáles están en este rango? Esas preguntas viven en las interfaces NavigableMap y NavigableSet, que TreeMap y TreeSet implementan. Hay que declarar con esas interfaces para ver los métodos (la lección de la parte 1).
Ejemplo. Un catálogo por precio, y queremos saber "qué puedo comprar con 4000":
NavigableMap<Integer, String> porPrecio = new TreeMap<>();
porPrecio.put(1200, "Teclado");
porPrecio.put(4500, "Monitor");
porPrecio.put(800, "Mouse");
porPrecio.put(25000, "Laptop");
porPrecio.put(3000, "Silla");
System.out.println(porPrecio);
System.out.println(porPrecio.floorEntry(4000)); // 1. el mayor precio <= 4000
System.out.println(porPrecio.ceilingEntry(4000)); // 2. el menor precio >= 4000
System.out.println(porPrecio.headMap(3000)); // 3. todo lo menor que 3000
System.out.println(porPrecio.headMap(3000, true)); // 4. ... incluyendo el 3000
System.out.println(porPrecio.tailMap(3000, true)); // 5. desde 3000 en adelante
System.out.println(porPrecio.subMap(1000, true, 5000, false)); // 6. entre 1000 y 5000
System.out.println(porPrecio.firstEntry() + " " + porPrecio.lastKey());Qué pasó.
{800=Mouse, 1200=Teclado, 3000=Silla, 4500=Monitor, 25000=Laptop}
3000=Silla
4500=Monitor
{800=Mouse, 1200=Teclado}
{800=Mouse, 1200=Teclado, 3000=Silla}
{3000=Silla, 4500=Monitor, 25000=Laptop}
{1200=Teclado, 3000=Silla, 4500=Monitor}
800=Mouse 25000floor es "el más grande que no se pasa" y ceiling "el más chico que alcanza". headMap/tailMap/subMap devuelven vistas del mapa (la sección 1 otra vez): si el original cambia, la vista lo refleja.
Para un conjunto es igual, con floor, ceiling, subSet, y además pollFirst para sacar el menor:
NavigableSet<Integer> edades = new TreeSet<>(List.of(31, 45, 28, 29, 52));
System.out.println(edades.floor(30) + " " + edades.ceiling(30) + " " + edades.subSet(29, true, 45, true));
System.out.println(edades.pollFirst() + " " + edades);
System.out.println(edades.descendingSet());29 31 [29, 31, 45]
28 [29, 31, 45, 52]
[52, 45, 31, 29]Cuándo usarlo. Rangos de fechas, de precios, de versiones; "la tarifa que aplica a este monto"; "el siguiente horario disponible". Cualquier vez que la pregunta sea "el más cercano" en vez de "exactamente este".
Lo que debes llevarte: si vas a preguntar por rangos o por "el más cercano",
TreeMap/TreeSetdeclarados comoNavigableMap/NavigableSet.
6. Cuánto cuesta cada operación
Qué es. Cada implementación es rápida en unas operaciones y lenta en otras. La forma estándar de decirlo es con la notación O grande (Big-O): O(1) significa "tarda lo mismo sin importar cuántos elementos haya"; O(log n) "crece muy despacio con el tamaño"; O(n) "tarda proporcional al tamaño, porque recorre todo".
Verde: inmediato (O(1)). Amarillo: crece despacio (O(log n)). Rojo: recorre todo (O(n)). Lo que importa no es memorizar la tabla, sino saber qué operación vas a hacer millones de veces.
Lo que de verdad necesitas recordar:
ArrayList:get(i)yaddal final son O(1). Insertar o quitar al principio o en el medio es O(n) porque mueve todo lo que sigue.containses O(n): recorre.LinkedList:addFirst/removeFirstson O(1).get(i)es O(n).containses O(n).HashSet/HashMap:add,contains,get,put,removeson O(1) en promedio. Por eso son la opción por defecto. Sin orden.TreeSet/TreeMap: todo es O(log n), un poco más lento que hash, pero ordenado y confloor/ceiling/rangos.ArrayDeque: meter y sacar por cualquier extremo es O(1).
La consecuencia práctica más común: si tienes una List y haces contains dentro de un bucle sobre otra lista grande, eso es O(n²). Pasar la primera a un HashSet lo vuelve O(n). Es la optimización más barata y más frecuente que vas a hacer.
Lo que debes llevarte: hash es O(1), árbol es O(log n), y
containssobre una lista es O(n). Uncontainsen un bucle es la señal de que necesitas unSet.
7. Cuando hay varios hilos
Qué es. Un hilo (thread) es una secuencia de instrucciones que se ejecuta de forma independiente; un programa puede tener varias corriendo a la vez. Casi todo servidor Java atiende cada petición en un hilo distinto, así que dos peticiones pueden tocar la misma colección al mismo tiempo. Las colecciones de la parte 1 no están preparadas para eso.
Para los ejemplos usamos hilos virtuales (Java 21): Executors.newVirtualThreadPerTaskExecutor() crea un ejecutor al que le entregas tareas, y el try con recursos espera a que terminen todas.
El error típico: un ArrayList compartido
Cuatro hilos agregan 10 000 elementos cada uno a la misma lista. Esperamos 40 000.
List<Integer> compartida = new ArrayList<>();
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int h = 0; h < 4; h++) {
ejecutor.submit(() -> {
for (int i = 0; i < 10_000; i++) {
compartida.add(i);
}
});
}
}
System.out.println("size=" + compartida.size() + " (esperado 40000)");Qué pasó. Lo corrí tres veces:
intento 1: size=10005 (esperado 40000)
intento 2: size=13564 (esperado 40000)
intento 3: size=14257 (esperado 40000)Se perdieron entre 25 000 y 30 000 elementos, y cada vez una cantidad distinta. En tu máquina los números serán otros, y a veces en vez de perder elementos verás una ArrayIndexOutOfBoundsException. Lo que pasa por dentro: add hace dos cosas, "escribe en la posición size" y "aumenta size". Si dos hilos leen el mismo size antes de que alguno lo aumente, escriben en la misma casilla y uno pisa al otro.
Los dos hilos leen size = 5, los dos escriben en la casilla 5, y los dos aumentan size. Resultado: una casilla con un valor pisado y otra vacía.
A un programa que da resultados distintos según el orden en que se intercalan los hilos se le llama condición de carrera. Es el error más difícil de reproducir que existe, porque la mayoría de las veces no pasa.
Primera solución: sincronizar
Collections.synchronizedList envuelve la lista para que solo un hilo a la vez pueda ejecutar cada método:
List<Integer> segura = Collections.synchronizedList(new ArrayList<>());
// ... los mismos cuatro hilos ...
System.out.println("size=" + segura.size());size=40000Funciona. El precio es que los hilos se turnan: mientras uno agrega, los otros esperan. Para muchas lecturas simultáneas eso se nota.
Las antiguas: Vector, Stack y Hashtable
Aquí entran las clases que dejé pendientes en la parte 1. Vector, Stack y Hashtable son las colecciones de Java 1.0 (1996), anteriores al framework de colecciones. Siguen existiendo porque Java no rompe programas viejos. Son exactamente lo que hace synchronizedList: cada método está sincronizado.
Y ese es justo su problema, el mismo que tiene synchronizedList: sincronizar cada método no sincroniza la operación completa. Mira este caso, llamado "mirar y actuar" (check-then-act): ocho hilos intentan agregar los números del 0 al 99 sin repetir.
List<Integer> vector = new Vector<>();
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int h = 0; h < 8; h++) {
ejecutor.submit(() -> {
for (int i = 0; i < 100; i++) {
if (!vector.contains(i)) { // 1. miro
vector.add(i); // 2. actúo
}
}
});
}
}
System.out.println("size=" + vector.size() + " (esperado 100)");intento 1: size=104 (esperado 100)
intento 2: size=121 (esperado 100)
intento 3: size=134 (esperado 100)Hay repetidos aunque Vector sea "seguro para hilos". contains es atómico y add es atómico, pero entre el uno y el otro, otro hilo pudo agregar el mismo número. Atómico significa "pasa completo o no pasa; nadie puede meterse en medio". Cada método lo es; la pareja de métodos no.
Por eso ya no se usan, y por eso el reemplazo no es "una lista sincronizada" sino colecciones diseñadas para que las operaciones compuestas sean atómicas:
Vector→ArrayListsi es un solo hilo;CopyOnWriteArrayListsi hay varios y se lee mucho más de lo que se escribe.Stack→ArrayDeque(un hilo) oConcurrentLinkedDeque(varios).Hashtable→HashMap(un hilo) oConcurrentHashMap(varios).
ConcurrentHashMap: operaciones compuestas atómicas
El caso más frecuente en un servidor: contar cosas desde muchos hilos. Con HashMap se pierden cuentas; con ConcurrentHashMap, merge es atómico (lee, combina y escribe sin que nadie se meta en medio):
Map<String, Integer> conteoMal = new HashMap<>();
Map<String, Integer> conteoBien = new ConcurrentHashMap<>();
List<String> palabras = List.of("java", "lista", "mapa", "java", "set");
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int h = 0; h < 8; h++) {
ejecutor.submit(() -> {
for (int i = 0; i < 1000; i++) {
for (String p : palabras) {
conteoMal.merge(p, 1, Integer::sum);
conteoBien.merge(p, 1, Integer::sum);
}
}
});
}
}
System.out.println("HashMap = " + conteoMal);
System.out.println("ConcurrentHashMap = " + conteoBien);HashMap = {lista=7945, set=7942, java=15114, mapa=7977}
ConcurrentHashMap = {lista=8000, java=16000, set=8000, mapa=8000}8 hilos × 1000 vueltas × 2 "java" = 16 000. El HashMap contó 15 114 (y en tu máquina, otro número; a veces además se corrompe por dentro y lanza excepciones o se cuelga). El ConcurrentHashMap contó exacto, y además no bloquea a todos los hilos entre sí: divide el mapa en segmentos y solo bloquea el segmento que se toca.
ConcurrentHashMap es la colección concurrente que más vas a usar. merge, compute, computeIfAbsent y putIfAbsent son atómicos en ella.
CopyOnWriteArrayList: cuando se lee mucho y se escribe poco
Cada escritura copia el array entero y los lectores siguen leyendo la copia vieja. Es caro escribir, pero leer no bloquea nunca, y además se puede modificar mientras se recorre (lo que en la parte 1 lanzaba ConcurrentModificationException):
List<String> lectores = new CopyOnWriteArrayList<>(List.of("Ana", "Luis", "Marta"));
for (String n : lectores) {
if (n.equals("Luis")) {
lectores.add("Rosa"); // modificar mientras recorro, sin excepción
}
}
System.out.println(lectores);[Ana, Luis, Marta, Rosa]El for recorrió la versión vieja (tres nombres) y la lista nueva tiene cuatro. Es ideal para listas de "suscriptores" o "escuchas" (listeners) que casi nunca cambian pero se recorren todo el tiempo.
BlockingQueue: un hilo produce, otro consume
Qué es. Una cola donde take() espera si está vacía y put() espera si está llena. Es la forma estándar de conectar un hilo que genera trabajo con otro que lo procesa, sin que ninguno tenga que preguntar "¿ya hay algo?" en un bucle.
BlockingQueue<String> pedidos = new ArrayBlockingQueue<>(2); // capacidad 2
Thread productor = Thread.ofVirtual().start(() -> {
try {
for (int i = 1; i <= 5; i++) {
pedidos.put("pedido-" + i); // espera si la cola está llena
System.out.println("produje pedido-" + i);
}
pedidos.put("FIN");
} catch (InterruptedException e) { }
});
Thread consumidor = Thread.ofVirtual().start(() -> {
try {
while (true) {
String p = pedidos.take(); // espera si la cola está vacía
if (p.equals("FIN")) break;
Thread.sleep(50); // simula el trabajo
System.out.println("procesé " + p);
}
} catch (InterruptedException e) { }
});
productor.join();
consumidor.join();produje pedido-1
produje pedido-2
produje pedido-3
procesé pedido-1
produje pedido-4
procesé pedido-2
produje pedido-5
procesé pedido-3
procesé pedido-4
procesé pedido-5Fíjate cómo el productor se frena: produce tres (dos en la cola más uno que el consumidor ya tomó) y luego solo puede producir uno cada vez que el consumidor libera un lugar. La cola con capacidad limitada regula la velocidad sola. Eso se llama contrapresión (backpressure) y es lo que evita que un productor rápido llene la memoria.
Cuándo usar cada una. ConcurrentHashMap para cualquier mapa compartido. CopyOnWriteArrayList para listas que se leen mucho y cambian poco. BlockingQueue para pasar trabajo entre hilos. synchronizedList casi nunca: es la solución de 1998 a un problema que las otras resuelven mejor.
Lo que debes llevarte: una colección "sincronizada" protege cada método, no la operación completa. Las colecciones de
java.util.concurrenthacen atómicas las operaciones compuestas que importan.
8. "Transacciones" en memoria: copiar, validar, publicar
Qué es. A veces no basta con que cada operación sea atómica: necesitas que un grupo de cambios se vea completo o no se vea, como una transacción de base de datos. En memoria, el patrón que lo logra combina las dos mitades de este artículo: colecciones inmutables (sección 1) y un reemplazo atómico de referencia.
La idea. El estado vive en una referencia atómica a una lista inmutable. Para cambiarlo: copias la lista, haces todos los cambios en la copia, y al final reemplazas la referencia de un golpe. Nadie ve nunca un estado a medias, porque la lista que ven es inmutable y la nueva aparece completa.
AtomicReference<List<String>> carrito = new AtomicReference<>(List.of());
try (var ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int h = 0; h < 8; h++) {
final int id = h;
ejecutor.submit(() -> {
for (int i = 0; i < 100; i++) {
final int n = i;
carrito.updateAndGet(actual -> {
List<String> nuevo = new ArrayList<>(actual); // 1. copiar
nuevo.add("h" + id + "-" + n); // 2. cambiar la copia
return List.copyOf(nuevo); // 3. publicar inmutable
});
}
});
}
}
System.out.println("items=" + carrito.get().size() + " (esperado 800)");items=800 (esperado 800)updateAndGet recibe una función "estado actual → estado nuevo" y la aplica de forma atómica: si otro hilo cambió la referencia en medio, la vuelve a intentar con el estado más reciente. Por eso la función debe ser pura (no tener efectos fuera de sí misma): puede ejecutarse más de una vez.
La versión manual es compareAndSet(esperado, nuevo): "reemplaza solo si la referencia sigue siendo la que yo vi". Devuelve false si alguien se adelantó:
List<String> antes = carrito.get();
System.out.println(carrito.compareAndSet(antes, List.of("vacío"))); // nadie cambió: true
System.out.println(carrito.compareAndSet(antes, List.of("otra vez"))); // ya cambió: false
System.out.println(carrito.get());true
false
[vacío]El límite, dicho con claridad. Esto no es una transacción de base de datos. No sobrevive a que el proceso se caiga, no coordina dos referencias a la vez, y no tiene rollback de efectos externos (si en medio enviaste un correo, el correo se fue). Es una herramienta para que un estado en memoria sea coherente entre hilos. Para todo lo demás, la transacción la da la base de datos, y la colección en memoria es a lo sumo una copia de lectura.
Lo que debes llevarte: copiar, cambiar la copia y publicarla con un reemplazo atómico es la "transacción" de la memoria. Sirve para coherencia entre hilos; no reemplaza a la base de datos.
Lo que debes llevarte de todo el artículo
- Vista no modificable ≠ copia inmutable: la primera sigue a la original, la segunda no.
- Lo que guardas de otros, guárdalo como copia inmutable. Lo que no cambia no se rompe a distancia.
equalsyhashCodese definen juntos, con los mismos campos; unrecordlo hace por ti.- Las claves de un
Mapy los elementos de unSetno deben cambiar después de guardarse. Comparablees el orden natural;Comparatores el orden que eliges. UnTreeSetsolo distingue lo que su comparador distingue.NavigableMap/NavigableSetresponden "el más cercano" y "los que están en este rango".- Hash es O(1), árbol es O(log n),
containsen lista es O(n). - Sincronizar cada método no hace atómica la operación compuesta: usa
ConcurrentHashMap,CopyOnWriteArrayList,BlockingQueue. - Copiar, cambiar y publicar con
AtomicReferenceda coherencia en memoria; no es una transacción de base de datos.
En la parte 3 dejamos de escribir bucles: filtrar, transformar, agrupar y resumir colecciones con streams, desde filter hasta Collectors que pocos conocen.
¿Te ha pasado el HashSet con "repetidos" o la clave que desaparece del Map? ¿Cuánto tardaste en encontrarlo? Me interesa leerlo en los comentarios.
Referencias
- Oracle, Java 21 API: java.util.concurrent.
- Oracle, Java 21 API: Object.equals y Object.hashCode. El contrato entre los dos, en la documentación oficial.
- Oracle, Java 21 API: Comparator.
- Brian Goetz et al., Java Concurrency in Practice, Addison-Wesley, 2006. El libro de referencia sobre colecciones concurrentes y condiciones de carrera.
- JEP 444, Virtual Threads, Java 21.