W3docs

Colecciones concurrentes en Java

Colecciones thread-safe en java.util.concurrent — ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue — y cuándo usar cada una.

HashMap, ArrayList, ArrayDeque — estas son las colecciones cotidianas y ninguna de ellas es thread-safe. Usarlas desde múltiples hilos sin sincronización externa produce actualizaciones perdidas, invariantes rotos y el temido ConcurrentModificationException a mitad de la iteración. La respuesta clásica era Collections.synchronizedMap(...), que envuelve un mapa normal en un único bloqueo grande. Eso funciona, pero serializa cada operación.

El paquete java.util.concurrent reemplazó el enfoque de envolver-con-bloqueo con colecciones diseñadas para acceso concurrente desde cero: variantes con lock-striping, copia-en-escritura y sin bloqueos, ajustadas para distintas proporciones de lectura/escritura. Este capítulo es el recorrido — en qué destaca cada clase y los modos de fallo que conviene conocer.

ConcurrentHashMap — el caballo de batalla

La colección concurrente más utilizada en Java. Un mapa con la forma de HashMap que puedes usar desde muchos hilos sin sincronización externa:

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();

counts.put("hits", 1);
counts.merge("hits", 1, Integer::sum);                // atomic add-or-increment
counts.computeIfAbsent("misses", k -> 0);
counts.computeIfPresent("hits", (k, v) -> v + 1);

Tres cosas la hacen rápida bajo contención:

  1. Lock striping. Claves distintas están protegidas por bloqueos internos distintos, por lo que las escrituras sobre claves no relacionadas no se bloquean entre sí.
  2. Lecturas sin bloqueo. Las lecturas no adquieren un bloqueo en absoluto (en estado estable). Un lector puede competir con un escritor; el resultado es el valor antiguo o el nuevo, nunca uno corrupto.
  3. Actualizaciones compuestas atómicas. merge, compute, computeIfAbsent y putIfAbsent realizan su comprobación-y-acción de forma atómica. Sin ellos, el patrón no sincronizado if (!map.containsKey(k)) map.put(k, v) tiene una ventana de carrera entre las dos llamadas; los métodos atómicos la eliminan.

Usa ConcurrentHashMap siempre que más de un hilo toque un HashMap. Es el valor predeterminado correcto — más rápido que Hashtable, más rápido que synchronizedMap, y admite actualizaciones compuestas atómicas que los otros no tienen.

Una regla: las claves null y los valores null no están permitidos. containsKey(k) es fiable; map.get(k) == null es ambiguo (clave ausente vs. valor null). Prohibir los nulos elimina la ambigüedad.

ConcurrentSkipListMap — mapa concurrente ordenado

Cuando necesitas la forma de TreeMap (ordenado por clave) y acceso concurrente:

ConcurrentSkipListMap<Long, Event> byTimestamp = new ConcurrentSkipListMap<>();

byTimestamp.put(1700000000000L, e1);
byTimestamp.put(1700000005000L, e2);

byTimestamp.firstEntry();                              // earliest
byTimestamp.lastEntry();                               // latest
byTimestamp.subMap(start, end);                        // range query

Respaldado por una lista de saltos (una alternativa probabilística a un árbol balanceado que es más fácil de hacer sin bloqueos). Soporta la API completa de NavigableMap. Más lento que ConcurrentHashMap para búsquedas simples por clave; es la elección correcta cuando necesitas iteración ordenada o consultas por rango.

CopyOnWriteArrayList — lista pequeña con muchas lecturas

CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(myListener);
for (Listener l : listeners) l.onEvent(e);             // never throws ConcurrentModificationException

Cada escritura copia el array subyacente. Las lecturas no tienen espera — sin bloqueo, sin sincronización, sin CME. El intercambio es obvio: cada add/remove/set es O(n) porque copia todo el array.

Ese es un intercambio terrible para cargas de trabajo con muchas escrituras. Es un intercambio perfecto para la carga de trabajo para la que fue diseñada:

  • Una lista pequeña (decenas, quizás cientos, de elementos).
  • Las lecturas superan ampliamente a las escrituras.
  • La iteración es frecuente y nunca debe lanzar CME.

El caso de uso ejemplar: una lista de oyentes de eventos, entradas de configuración o suscriptores registrados. Las lecturas ocurren en cada evento; las escrituras ocurren al arrancar o cuando un componente se registra.

No uses CopyOnWriteArrayList para "cualquier cosa que pondría en un ArrayList." Para colecciones compartidas mutables que no son pequeñas y de lectura predominante, usa Collections.synchronizedList alrededor de un ArrayList, o reconsidera la estructura de datos.

BlockingQueue — la cola productor/consumidor

La abstracción más útil de java.util.concurrent:

BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1024);

queue.put(task);                                       // blocks if full
queue.offer(task, 100, TimeUnit.MILLISECONDS);         // blocks up to deadline
queue.add(task);                                       // throws if full

Task t = queue.take();                                 // blocks if empty
Task t2 = queue.poll(100, TimeUnit.MILLISECONDS);      // blocks up to deadline
Task t3 = queue.poll();                                // returns null if empty

put y take son las operaciones bloqueantes: esperan hasta que la cola no esté llena / no esté vacía. Esa es la columna vertebral entera del framework de ejecutores — cada ThreadPoolExecutor contiene internamente un BlockingQueue de tareas pendientes; los trabajadores hacen take de él; el execute público hace put en él.

Implementaciones comunes:

Clase¿Acotada?Cuándo usarla
ArrayBlockingQueue(cap)Sí — capacidad fijaBuffer de tamaño fijo; contrapresión al productor
LinkedBlockingQueue()No (o con límite)Cola de propósito general de alto rendimiento
SynchronousQueue0 — entrega directaCada put espera un take; transferencia hilo a hilo
PriorityBlockingQueueNoTareas ordenadas por prioridad (no por inserción)
DelayQueueNoCada elemento tiene un retraso; solo se toma cuando expira

ArrayBlockingQueue es el valor predeterminado en producción — limita el trabajo en curso, lo cual es esencial para la contrapresión. LinkedBlockingQueue sin límite es la trampa detrás de Executors.newFixedThreadPool (cola ilimitada → memoria ilimitada).

ConcurrentLinkedQueue y ConcurrentLinkedDeque — las opciones ilimitadas sin bloqueo

ConcurrentLinkedQueue<Event> events = new ConcurrentLinkedQueue<>();
events.add(e);
Event e = events.poll();                               // null if empty; doesn't block

Sin bloqueo, lock-free, ilimitadas. poll devuelve null en lugar de bloquear; no hay take. Lo mejor cuando:

  • Quieres alto rendimiento.
  • Puedes tolerar que "cola vacía" sea un retorno rápido en lugar de una espera.
  • No necesitas contrapresión.

Estas no son BlockingQueues — elige estas cuando genuinamente no quieres la semántica bloqueante.

Iteración: consistencia débil

Un iterador de HashMap lanza ConcurrentModificationException si el mapa cambia durante la iteración. Las colecciones concurrentes hacen algo diferente: sus iteradores son débilmente consistentes. Eso significa:

  • No lanzarán ConcurrentModificationException aunque otros hilos modifiquen la colección.
  • Tienen garantizado ver cada elemento que estaba presente cuando se creó el iterador.
  • Pueden o no reflejar las modificaciones realizadas después de crear el iterador.

Esto está bien para la mayoría de los usos — un iterador de instantánea es exactamente lo que el código concurrente quiere. El intercambio: size() también es "débilmente consistente" — para ConcurrentHashMap es un recuento aproximado, no un valor de instantánea garantizado. Si tratas size() como autoritativo, estás haciendo un mal uso de la API.

Cuándo elegir cada opción

Un árbol de decisión aproximado:

  • Almacén clave-valorConcurrentHashMap (por defecto), ConcurrentSkipListMap (necesitas orden/rango).
  • Lista de oyentes con muchas lecturasCopyOnWriteArrayList.
  • Cola de tareas productor-consumidorArrayBlockingQueue (acotada), LinkedBlockingQueue (sin necesidad de límite), SynchronousQueue (entrega directa).
  • Cola de prioridad entre hilosPriorityBlockingQueue.
  • Cola diferida para programar más tardeDelayQueue.
  • Alto rendimiento sin bloqueo lock-freeConcurrentLinkedQueue / ConcurrentLinkedDeque.
  • SetConcurrentHashMap.newKeySet(), CopyOnWriteArraySet, ConcurrentSkipListSet.

Siempre que más de un hilo toque una colección normal, elige una colección concurrente o envuélvela con Collections.synchronizedX — nunca confíes en que simplemente funcionará.

Un ejemplo práctico: cada colección haciendo su trabajo

El programa a continuación demuestra cuatro colecciones concurrentes bajo una carga de trabajo compartida — un ConcurrentHashMap contando eventos, un CopyOnWriteArrayList de oyentes, un ArrayBlockingQueue para productor/consumidor y un ConcurrentLinkedQueue para adición sin bloqueo.

java— editable, runs on the server

Qué se puede extraer de la ejecución:

  • El ConcurrentHashMap.merge de la sección 1 produjo el recuento exacto esperado de 40.000. La función de fusión (Integer::sum) se ejecutó atómicamente por clave, de modo que dos hilos incrementando la misma clave nunca perdieron una actualización — la actualización compuesta atómica es la clave. Con un HashMap simple y put, obtendrías una fracción del valor esperado y probablemente también un estado interno corrupto.
  • El iterador de CopyOnWriteArrayList de la sección 2 vio [a, b, c] (la instantánea en el momento en que se creó el iterador). Las escrituras que añadieron d y e durante la iteración no lanzaron ConcurrentModificationException y no fueron vistas por el iterador en curso. La lista final contenía los cinco elementos — las escrituras sí ocurrieron, simplemente eran invisibles para el iterador que ya había empezado.
  • El ArrayBlockingQueue con capacidad 4 de la sección 3 forzó al productor a bloquearse en put cada vez que la cola estaba llena. La salida mostró la cola llenándose hasta 4, luego el productor pausando mientras el consumidor drena, y luego el productor reanudando. Eso es contrapresión gestionada por la estructura de datos: el productor no puede ir más rápido que el consumidor, incluso sin código de coordinación.
  • El ConcurrentLinkedQueue de la sección 4 aceptó escrituras de cuatro hilos sin bloqueos ni contención de bloqueos. El recuento drenado final coincidió exactamente con el recuento añadido — cada elemento escrito fue leído correctamente. El coste: no hay take() para esperar en una cola vacía; poll() devuelve null y debes manejarlo tú mismo.
  • Durante todo el proceso, las colecciones concurrentes nunca lanzaron ConcurrentModificationException. Esa excepción es una característica de las colecciones no concurrentes — es la forma que tiene la JVM de decir "has roto esto." Las colecciones concurrentes están diseñadas para ser modificadas desde múltiples hilos, por lo que no necesitan esa señal.

Qué viene a continuación

El siguiente capítulo, Hilos Virtuales en Java, cubre la característica de Java 21 que cambia cómo piensas en el número de hilos — hilos ligeros programados por la JVM que vuelven a hacer que las I/O bloqueantes sean baratas.

Práctica

Práctica
Necesitas un `Map` thread-safe modificado por muchos hilos que soporte incrementar atómicamente un contador bajo una clave. ¿Cuál es la elección correcta?
Necesitas un `Map` thread-safe modificado por muchos hilos que soporte incrementar atómicamente un contador bajo una clave. ¿Cuál es la elección correcta?
Was this page helpful?