1. Por qué las colecciones normales no sirven para el multihilo
Recordemos cómo trabajábamos con colecciones en nuestra aplicación principal (por ejemplo, una sala de chat):
List<String> messages = new ArrayList<>();
messages.add("¡Hola!");
messages.add("¿Cómo estás?");
En un programa monohilo todo va bien. Pero si varios hilos intentan añadir, eliminar o leer elementos de la misma colección al mismo tiempo — bienvenido al mundo de las condiciones de carrera (race conditions), estados inconsistentes y errores misteriosos.
Por ejemplo, un hilo añade un elemento, otro lo elimina, un tercero itera — y de repente obtenemos ConcurrentModificationException, a veces incluso ArrayIndexOutOfBoundsException o simplemente una colección «corrupta».
Clásico:
List<String> list = new ArrayList<>();
Runnable writer = () -> {
for (int i = 0; i < 1000; i++) {
list.add("msg-" + i);
}
};
Runnable reader = () -> {
for (String msg : list) {
// ...
}
};
// Lanzamos writer y reader en hilos distintos — aparecerán errores.
Conclusión: Las colecciones normales (ArrayList, HashMap, HashSet, etc.) NO son thread-safe. No se deben usar desde varios hilos sin sincronización adicional (synchronized, bloqueos, etc.).
2. Qué tipos de colecciones thread-safe hay en Java
Java no te deja tirado. Para tareas multihilo, en el paquete java.util.concurrent hay toda una colección de colecciones (perdón por la tautología) que se pueden usar de forma segura desde varios hilos.
Colecciones thread-safe principales:
| Colección | Dónde usar | Características |
|---|---|---|
|
Map, caché, acceso frecuente | Alto rendimiento, sin bloqueo global |
|
List, rara vez cambia, se lee a menudo | Lecturas rápidas, modificaciones lentas |
|
Set, rara vez cambia, se lee a menudo | Análogo del listado con Copy-On-Write |
|
Cola, FIFO | Rápida, no bloqueante, colas de tareas |
|
Map con ordenación (NavigableMap) | Análogo thread-safe de TreeMap |
|
Set con ordenación | Análogo thread-safe de TreeSet |
|
Colas con bloqueo (pools de hilos) | Interfaz, muchas implementaciones |
¡Importante! El viejo y querido Collections.synchronizedList(list) y similares no son exactamente lo mismo que las colecciones modernas de java.util.concurrent. Más detalles más abajo.
3. ConcurrentHashMap: tu amigo en el mundo del multihilo
ConcurrentHashMap<K, V> es, en esencia, un HashMap preparado para el multihilo. Permite que varios hilos lean y escriban datos simultáneamente de forma segura, sin bloquear el mapa completo.
En un HashMap normal, si queremos hacer el acceso seguro a hilos, hay que poner un candado a toda la estructura — y enseguida se convierte en «cuello de botella»: mientras un hilo trabaja, los demás esperan.
ConcurrentHashMap resuelve este problema de forma más inteligente. En versiones antiguas el mapa se dividía en segmentos con bloqueos independientes; en las implementaciones nuevas se emplean operaciones atómicas ligeras (CAS) a nivel de buckets individuales. Gracias a ello, los hilos pueden trabajar en paralelo siempre que no toquen los mismos datos.
Ejemplo de uso de ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
public class ChatStats {
private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();
public void increment(String user) {
// Incrementamos el valor de forma atómica
userMessageCount.merge(user, 1, Integer::sum);
}
public int getCount(String user) {
return userMessageCount.getOrDefault(user, 0);
}
}
Puntos importantes:
- Se pueden invocar los métodos desde distintos hilos: todo funcionará correctamente.
- El método merge es atómico: si varios hilos incrementan el contador a la vez, el resultado será correcto.
- Para leer no hace falta sincronización adicional.
¿En qué es mejor ConcurrentHashMap que synchronizedMap?
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
Cuando usas synchronizedMap, cualquier operación —lectura, escritura o eliminación— bloquea todo el mapa. Mientras un hilo trabaja con los datos, los demás tienen que esperar su turno.
ConcurrentHashMap está diseñado con más elegancia: permite que varios hilos lean e incluso modifiquen datos simultáneamente siempre que no accedan a las mismas zonas del mapa (buckets). En consecuencia, en sistemas multihilo reales muestra un rendimiento muy superior — a veces la diferencia llega a ser de órdenes de magnitud.
4. CopyOnWriteArrayList y CopyOnWriteArraySet
CopyOnWriteArrayList y CopyOnWriteArraySet son colecciones especiales que, en cada modificación (por ejemplo, al llamar a add() o remove()), crean una nueva copia de todo el array. A cambio, leer de ellas no requiere ninguna sincronización y es totalmente seguro para hilos.
Imagina que tienes una lista de invitados a una fiesta. Cada vez que alguien entra o sale, reescribes la lista y repartes copias frescas a todos. Un poco derrochador, pero así nadie se confunde sobre quién está presente.
Cuándo resulta realmente útil
- Las lecturas son frecuentes y las modificaciones, raras.
- Un caso clásico es la lista de listeners de eventos: los handlers se añaden de vez en cuando, pero los eventos llegan constantemente.
Ejemplo: listeners del chat
import java.util.concurrent.CopyOnWriteArrayList;
public class ChatRoom {
private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();
public void addListener(ChatListener listener) {
listeners.add(listener);
}
public void removeListener(ChatListener listener) {
listeners.remove(listener);
}
public void sendMessage(String message) {
// Seguro para multihilo, incluso si alguien se suscribe/anula la suscripción ahora mismo
for (ChatListener listener : listeners) {
listener.onMessage(message);
}
}
}
Aspectos importantes:
- Iterar sobre CopyOnWriteArrayList nunca lanzará ConcurrentModificationException.
- Las modificaciones (add/remove) son costosas en tiempo y memoria (¡se copia todo el array!).
- No conviene para colecciones grandes con cambios frecuentes.
5. Otras colecciones thread-safe
ConcurrentLinkedQueue
ConcurrentLinkedQueue es una cola no bloqueante que funciona en modo FIFO. Permite que varios hilos añadan y retiren elementos de forma segura sin usar bloqueos explícitos. Se utiliza a menudo para pasar tareas entre hilos: rápida y sin cuellos de botella.
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // devolverá null si la cola está vacía
ConcurrentSkipListMap y ConcurrentSkipListSet
- Análogos thread-safe de TreeMap y TreeSet.
- Los elementos están siempre ordenados.
- Se usan cuando es importante mantener el orden de las claves.
import java.util.concurrent.ConcurrentSkipListMap;
ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b
BlockingQueue y sus implementaciones
- Interfaz de cola que soporta operaciones de bloqueo (esperar hasta que haya/queda espacio).
- Implementaciones: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue, etc.
- Se usan en pools de hilos, para el patrón «productor-consumidor».
import java.util.concurrent.ArrayBlockingQueue;
ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // Bloquea si la cola está llena
String t = blockingQueue.take(); // Bloquea si la cola está vacía
6. Ejemplos: operaciones seguras con colecciones
Ejemplo 1: Map thread-safe para contar mensajes
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();
// Hilo 1
messageCount.put("Anna", 1);
// Hilo 2
messageCount.put("Anna", messageCount.getOrDefault("Anna", 0) + 1); // ¡No es atómico!
// Correcto (atómico):
messageCount.merge("Anna", 1, Integer::sum);
Ejemplo 2: Iteración sobre CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArrayList;
CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Anton");
users.add("Maria");
for (String user : users) {
System.out.println(user);
users.remove(user); // ¡No lanzará ConcurrentModificationException!
}
System.out.println(users); // []
Ejemplo 3: Cola de tareas entre hilos
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
// Hilo productor
queue.add("task-1");
// Hilo consumidor
String task = queue.poll(); // null si está vacía
7. Matices útiles
Cuándo (y por qué) usar colecciones thread-safe
El uso de colecciones thread-safe está justificado si:
- La misma colección se comparte entre varios hilos.
- No quieres sincronizar manualmente cada operación.
- Es importante evitar condiciones de carrera y problemas de consistencia.
Escenarios típicos:
- Caché en un sistema multihilo (por ejemplo, ConcurrentHashMap para almacenar sesiones de usuario).
- Colas de tareas entre hilos (ConcurrentLinkedQueue, BlockingQueue).
- Listas de listeners de eventos (CopyOnWriteArrayList).
- Procesamiento de datos multihilo (por ejemplo, estilo MapReduce).
Limitaciones y trampas
- Las operaciones sobre varios elementos no son atómicas. La construcción if (!map.containsKey(k)) map.put(k, v) no es atómica. Usa putIfAbsent, computeIfAbsent, merge.
- CopyOnWriteArrayList es ineficiente con cambios frecuentes. Con tamaños grandes y add/remove frecuentes, la sobrecarga crece exponencialmente.
- La iteración sobre ConcurrentHashMap es «débil». El recorrido ofrece una instantánea de consistencia débil: es posible no ver parte de los cambios en paralelo.
- Las colecciones thread-safe no resuelven todos los problemas de sincronización. Si la lógica afecta a varias colecciones/variables a la vez, necesitarás sincronización externa (synchronized, locks, clases atómicas).
8. Errores típicos al trabajar con colecciones thread-safe
Error n.º 1: Esperar magia de las colecciones thread-safe. «Como la colección es thread-safe, puedo hacer lo que sea sin pensar en la sincronización». Por desgracia, las secuencias de varias operaciones (comprobación + inserción) no son atómicas. Usa métodos especializados: putIfAbsent, compute, merge.
Error n.º 2: Usar CopyOnWriteArrayList para colecciones grandes y con cambios frecuentes. Es adecuada para listas de listeners, pero con 10 000+ elementos y cambios frecuentes tendrás un gran coste de memoria y tiempo.
Error n.º 3: ConcurrentModificationException al usar colecciones normales. Iteras sobre ArrayList o HashMap mientras otro hilo cambia la colección — recibirás ConcurrentModificationException. Usa colecciones especializadas o bloquea el acceso manualmente.
Error n.º 4: Olvidar la atomicidad de las operaciones complejas. Si necesitas modificar varias colecciones a la vez o ejecutar una serie de acciones relacionadas, las colecciones thread-safe no te ayudarán. Aplica sincronización externa o lógica transaccional.
Error n.º 5: Errores al iterar sobre ConcurrentHashMap. La iteración es de consistencia débil: no puedes usar el iterador como «instantánea» del estado del mapa. Para una instantánea consistente, copia los datos a una estructura aparte.
GO TO FULL VERSION