Skip to content
StackPractices
intermediate Por Mathias Paulenko

Usar Estructuras de Datos Concurrentes para Colecciones

Cómo compartir colecciones entre threads de forma segura usando blocking queues, concurrent maps, copy-on-write lists y atomic counters en Java, Python y C++.

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Visión general

Compartir un ArrayList estándar entre threads es peligroso. El thread A lee el índice 0 mientras el thread B elimina el índice 0 — ConcurrentModificationException. El thread A y B llaman map.put("key", value) simultáneamente en un HashMap — la lista enlazada interna puede volverse circular, causando un loop infinito durante la iteración. Estas fallas son no deterministas: pueden pasar miles de tests y fallar solo bajo carga de producción.

Las colecciones estándar (ArrayList, HashMap, LinkedList) no son thread-safe. Envolver cada acceso en synchronized funciona pero serializa todas las operaciones, derrotando el paralelismo. Las estructuras de datos concurrentes son colecciones diseñadas para acceso multi-thread: usan locks de grano fino, algoritmos lock-free o inmutabilidad para permitir lecturas y escrituras concurrentes seguras con mínima contención. Lo siguiente cubre blocking queues, concurrent maps, copy-on-write collections y atomic counters con ejemplos prácticos.

Cuándo usarlo

Usa esta receta cuando:

  • Múltiples threads leen y escriben la misma colección
  • Implementando patrones productor-consumidor con backpressure
  • Construyendo caches, colas de trabajo o pools de conexiones compartidos por thread pools
  • Reemplazando synchronized(list) o Collections.synchronizedMap() con alternativas de mayor rendimiento
  • Asegurando visibilidad de escrituras entre threads sin barreras de memoria explícitas

Solución

Blocking Queue (Java)

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

class OrderProcessor {
    private final BlockingQueue<Order> queue = new ArrayBlockingQueue<>(100);

    public void submit(Order order) throws InterruptedException {
        queue.put(order); // bloquea si la cola está llena
    }

    public Order take() throws InterruptedException {
        return queue.take(); // bloquea si la cola está vacía
    }
}

// Productor
Thread producer = new Thread(() -> {
    for (int i = 0; i < 1000; i++) {
        processor.submit(new Order(i));
    }
});

// Pool de consumidores
for (int i = 0; i < 4; i++) {
    new Thread(() -> {
        while (true) {
            try {
                Order order = processor.take();
                process(order);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }).start();
}

Concurrent Map (Java)

import java.util.concurrent.ConcurrentHashMap;

class InMemoryCache {
    private final ConcurrentHashMap<String, CachedValue> cache = new ConcurrentHashMap<>();

    public String get(String key, Supplier<String> loader) {
        return cache.computeIfAbsent(key, k -> {
            String value = loader.get();
            return new CachedValue(value, System.currentTimeMillis());
        }).value;
    }

    public void invalidate(String key) {
        cache.remove(key);
    }

    private record CachedValue(String value, long timestamp) {}
}

Python Queue (Thread-Safe)

from queue import Queue
from threading import Thread

class TaskQueue:
    def __init__(self, maxsize=100):
        self.queue = Queue(maxsize=maxsize)

    def submit(self, task):
        self.queue.put(task)  # bloquea si está llena

    def worker(self):
        while True:
            task = self.queue.get()  # bloquea si está vacía
            if task is None:
                break
            self.process(task)
            self.queue.task_done()

tq = TaskQueue()
Thread(target=lambda: [tq.submit(i) for i in range(1000)]).start()
for _ in range(4):
    Thread(target=tq.worker).start()

Copy-on-Write List (Java)

import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;

class EventDispatcher {
    private final CopyOnWriteArrayList<Consumer<Event>> listeners = new CopyOnWriteArrayList<>();

    public void addListener(Consumer<Event> listener) {
        listeners.add(listener);
    }

    public void removeListener(Consumer<Event> listener) {
        listeners.remove(listener);
    }

    public void dispatch(Event event) {
        for (Consumer<Event> listener : listeners) {
            listener.accept(event);
        }
    }
}

Explicación

  • BlockingQueue: una cola que bloquea productores cuando está llena y consumidores cuando está vacía. Esto provee backpressure natural — un productor rápido no puede abrumar a un consumidor lento. ArrayBlockingQueue usa un solo lock; LinkedBlockingQueue usa locks separados para head y tail, permitiendo mayor concurrencia para cargas mixtas de lectura/escritura.
  • ConcurrentHashMap: a diferencia de Collections.synchronizedMap(), que lockea todo el mapa para cada operación, ConcurrentHashMap usa lock striping — segmentando el mapa en regiones lockeables independientemente similar a load balancing. Las lecturas suelen ser lock-free. computeIfAbsent chequea e inserta atómicamente, previniendo la carrera clásica de doble carga en caches.
  • CopyOnWriteArrayList: cada escritura crea una copia completa del array subyacente. Las lecturas son lock-free y rápidas. Las escrituras son costosas, así que esto es ideal para colecciones con pocas escrituras y muchas lecturas — como listas de listeners de eventos. Un iterador sobre copy-on-write ve un snapshot del momento de creación del iterador.
  • AtomicInteger / AtomicLong: no son colecciones, pero son los bloques de construcción de contadores concurrentes, generadores de secuencia y estadísticas. incrementAndGet() usa una instrucción CAS de CPU, haciéndola lock-free y típicamente más rápida que synchronized para contadores simples.

Variantes

EstructuraLecturasEscriturasMejor paraOverhead
BlockingQueueBloqueanteBloqueanteProductor-consumidor con backpressureLock por op
ConcurrentHashMapLock-freeLock stripingCaches de alta concurrenciaBajo
CopyOnWriteArrayListLock-freeCopia completaPocas escrituras, muchas lecturasAlta escritura
ConcurrentLinkedQueueLock-freeLock-freeColas de alto throughputBajo
SynchronizedMapLockeadaLockeadaMigración simpleAlta

Lo que funciona

  • Prefiere ConcurrentHashMap sobre Collections.synchronizedMap(): los wrappers sincronizados lockean todo el mapa para cada operación, incluyendo get(). ConcurrentHashMap permite lecturas concurrentes y locks más finos para escritura. La diferencia de rendimiento es dramática bajo contención de threads.
  • Usa computeIfAbsent para inicialización perezosa de cache: if (!map.containsKey(key)) map.put(key, load()) es una condición de carrera. Dos threads pueden cargar y poner. map.computeIfAbsent(key, k -> load()) chequea e inserta atómicamente, asegurando que el loader corre como máximo una vez por clave.
  • Colas con tamaño limitada para backpressure: una LinkedBlockingQueue ilimitada puede crecer hasta que la JVM se quede sin memoria bajo un productor rápido. Siempre establece un tamaño máximo y usa put() (bloqueante) en lugar de offer() (no bloqueante) cuando quieres aplicar backpressure.
  • Copy-on-write para listas de listeners: si tu aplicación registra listeners de eventos al arrancar y raramente los cambia, CopyOnWriteArrayList da lecturas lock-free. No lo uses para listas frecuentemente actualizadas — el costo de copia por escritura se vuelve prohibitivo.
  • Itera con Iterator, no for-each en colecciones sincronizadas: for (Item item : synchronizedList) no es atómico. Otro thread puede modificar la lista entre pasos del iterador, lanzando ConcurrentModificationException. Usa bloques synchronized(list) { ... } explícitos alrededor de la iteración, o usa colecciones concurrentes.

Errores comunes

  • Usar size() para decisiones de cola: chequear if (queue.size() > 0) queue.take() es una condición de carrera. La cola puede quedar vacía entre el chequeo de size() y la llamada a take(). Usa métodos bloqueantes (take(), put()) o no bloqueantes (poll(), offer()) directamente sin prechequeos.
  • Modificar una colección mientras iteras: incluso ConcurrentHashMap no soporta modificar el mapa vía el valor retornado por iterator(). Usa Iterator.remove() u operaciones bulk (removeIf, replaceAll) en lugar de mutar dentro de un loop for.
  • Esperar ordenamiento de ConcurrentHashMap: ConcurrentHashMap no garantiza orden de iteración. Si necesitas acceso concurrente ordenado, usa ConcurrentSkipListMap, que provee ordenamiento tipo TreeMap con lecturas lock-free.
  • Olvidar task_done() en Queue de Python: queue.task_done() debe llamarse después de procesar cada ítem para señalar completitud a queue.join(). Llamadas faltantes causan que join() se cuelgue indefinidamente, esperando tareas que ya fueron procesadas.

Cuando No Usar Este Enfoque

  • Código single-threaded: las colecciones concurrentes agregan 2-10x de overhead por operación. Si solo un thread accede a los datos, usa colecciones estándar (HashMap, ArrayList, dict)
  • Cargas read-heavy con escrituras infrecuentes: un CopyOnWriteArrayList copia todo el array en cada escritura. Si las escrituras ocurren más del 5% del tiempo, el costo de copia excede el ahorro de lock contention
  • Operaciones bulk en colecciones pequeñas: ConcurrentHashMap.putAll() en un map de 10 elementos es más lento que synchronized(map) { putAll() } porque el locking por segmento agrega overhead para tamaños pequeños
  • Cuando el orden de iteración importa: ConcurrentHashMap no garantiza orden de iteración. Si necesitas iteración FIFO u ordenada, usa ConcurrentSkipListMap o ConcurrentLinkedDeque siendo consciente de sus tradeoffs
  • Entornos con memoria limitada: las colecciones concurrentes usan más memoria que las estándar (arrays de segmentos, metadata CAS, padding extra). En dispositivos con <256MB RAM, el overhead puede ser inaceptable
  • Compartición de datos inmutables: si los datos se escriben una vez y son leídos por muchos threads, usa estructuras inmutables o referencias olatile en lugar de colecciones concurrentes. No se necesita sincronización para datos inmutables read-only
  • Escenarios de baja contención: si la contención es rara (ej. un contador actualizado una vez por minuto), una variable simple con bloques synchronized ocasionales es más simple y rápida que AtomicLong o ConcurrentHashMap

Benchmarks de Rendimiento

  • ConcurrentHashMap vs HashMap: put() single-threaded en ConcurrentHashMap es 1.5-2x más lento que HashMap. Bajo contención de 16 threads, ConcurrentHashMap es 5-10x más rápido que synchronized(HashMap)
  • AtomicInteger vs synchronized: AtomicInteger.incrementAndGet() toma ~5ns vs ~50ns para contador synchronized. La brecha se amplía bajo contención: a 8 threads, atomic es 20x más rápido
  • ConcurrentLinkedQueue vs ArrayBlockingQueue: ConcurrentLinkedQueue ofrece 2-3x mayor throughput para enqueue/dequeue no bloqueante. ArrayBlockingQueue es mejor cuando se necesita backpressure (capacidad acotada)
  • CopyOnWriteArrayList: las lecturas son 1.2x más rápidas que ArrayList (sin sincronización). Las escrituras son 10-100x más lentas por la copia del array. Break-even en 99% lecturas, 1% escrituras
  • ConcurrentSkipListMap vs TreeMap: ConcurrentSkipListMap es 1.5-2x más lento que TreeMap para operaciones single-threaded. Bajo contención de 8 threads, escala linealmente mientras TreeMap con locks no
  • Python queue.Queue vs collections.deque: queue.Queue agrega ~2us por put/get para thread safety. deque con locking manual es 1.5x más rápido pero propenso a errores. queue.SimpleQueue es un buen punto intermedio
  • Overhead de memoria: ConcurrentHashMap usa ~50% más memoria que HashMap por los arrays de segmentos. CopyOnWriteArrayList usa 2x memoria (dos copias de array durante escrituras)

Estrategia de Testing

  • Stress test con conteo de threads igual al de producción: prueba con 2x el conteo de threads esperado. Si producción usa 8 threads, prueba con 16. Las condiciones de carrera a menudo aparecen solo en conteos específicos
  • Verificar atomicidad de operaciones compuestas: testea computeIfAbsent bajo acceso concurrente. Verifica que la mapping function se llame exactamente una vez por key. Usa un ConcurrentHashMap con un mapper contador
  • Test de consistencia de iteración: los iteradores de colecciones concurrentes son weakly consistent. Verifica que las iteraciones no lancen ConcurrentModificationException y reflejen algún estado, no necesariamente el último
  • Test de comportamiento de bloqueo en colas acotadas: verifica que put() bloquee cuando la cola está llena y ake() bloquee cuando está vacía. Usa timeouts para detectar deadlocks
  • Test de operaciones bulk: putAll, clear y eplaceAll en colecciones concurrentes pueden tener semántica no atómica. Verifica el comportamiento bajo modificación concurrente
  • Test de memory leaks: tests long-running con millones de ciclos put/remove. Monitorea el uso de heap para detectar leaks en estructuras internas (ej. arrays de segmentos de ConcurrentHashMap)
  • Test con distribución de datos realista: skew y hot keys se comportan distinto que distribución uniforme. Prueba con patrones de keys de producción para identificar hotspots de contención

Estimacion de Costos

  • Presupuesto de overhead de memoria: las colecciones concurrentes usan 1.5-2x más memoria. Para una caché in-memory de 10GB, esto significa 15-20GB. Planifica el sizing de instancias acorde
  • Tiempo de desarrollo: elegir la colección concurrente correcta toma 2-4 horas de análisis por caso de uso. La elección incorrecta lleva a bugs que toman días en diagnosticarse
  • Costo de capacitación: los miembros del equipo necesitan entender happens-before semantics, iteradores weakly consistent y operaciones CAS. Presupuesta 1-2 días de capacitación por developer
  • Ahorros en costo de servidores: usar colecciones concurrentes en lugar de locking coarse-grained puede reducir tiempos de respuesta 30-60%, permitiendo menos servidores manejar la misma carga
  • Costo de debugging: los bugs en colecciones concurrentes son difíciles de reproducir. Una sola condición de carrera puede tomar 20-40 horas en diagnosticarse. Invierte en stress testing temprano

Monitoring y Observabilidad

  • Tamaño de colección: monitorea el tamaño de colas y maps concurrentes. Una cola creciente indica que los consumidores no pueden mantener el ritmo. Alerta cuando el tamaño excede 80% de la capacidad
  • Métricas de contención: trackea lock contention en colecciones sincronizadas. Usa jstack o async-profiler para identificar locks calientes. Alta contención indica necesidad de locking más fino o alternativas concurrentes
  • Latencia de operaciones: monitorea latencias de put, get, ake. P99 >10ms en una cola concurrente indica contención o presión de GC
  • Uso de memoria: trackea el overhead de memoria de las colecciones concurrentes. Compara contra el tamaño esperado. Crecimiento inesperado puede indicar un leak en estructuras internas
  • Thread wait time: monitorea la distribución de estados de threads. Alto conteo de threads BLOCKED o WAITING indica lock contention o esperas en colas vacías

Deployment Checklist

  • Verificar que la versión de JVM soporta las colecciones concurrentes que usas (Java 8+ para mejoras de ConcurrentHashMap, Java 9+ para views de ConcurrentHashMap.keySet)
  • Setear capacidad inicial apropiada para evitar resizing bajo carga (resizear un ConcurrentHashMap es costoso)
  • Configurar capacidades de colas acotadas basadas en presupuesto de memoria y throughput esperado
  • Habilitar monitoreo JMX para métricas de colecciones concurrentes (tamaño, capacidad, contención)
  • Setear tamaños de thread pool para coincidir con el número de consumidores de colecciones concurrentes
  • Testear bajo carga de producción antes del deploy para verificar que no haya hotspots de contención

Consideraciones de Seguridad

  • Denial of service vía collection flooding: un atacante puede llenar un ConcurrentLinkedQueue no acotado hasta agotar la memoria. Usa colas acotadas (ArrayBlockingQueue) para operaciones expuestas al usuario
  • Ataques de deserialización en colecciones concurrentes: eadObject de Java en ConcurrentHashMap no llama computeIfAbsent. La deserialización custom puede bypassar garantías de concurrencia. Valida los datos deserializados
  • Fuga de información vía iteradores weakly consistent: los iteradores en colecciones concurrentes reflejan un estado pasado. Si se remueven datos sensibles entre iteraciones, un iterador stale puede exponerlos. Limpia datos sensibles atómicamente
  • Condiciones de carrera en check-then-act: if (!map.containsKey(k)) map.put(k, v) no es atómico en ConcurrentHashMap. Usa computeIfAbsent o putIfAbsent para prevenir condiciones de carrera que podrían insertar entradas duplicadas o no autorizadas
  • Agotamiento de memoria vía keys grandes: las colecciones concurrentes no limitan el tamaño de keys. Un atacante puede insertar entradas con keys grandes para agotar memoria. Implementa límites de tamaño a nivel aplicación
  • Ataques de poison pill: un productor malicioso puede insertar un objeto “poison” en una cola compartida que cause que los consumidores crasheen. Valida los elementos de la cola antes de procesarlos
  • Thread starvation vía priority inversion: un thread de baja prioridad que mantiene un lock en una colección concurrente puede bloquear threads de alta prioridad. Usa políticas de fair locking (ReentrantLock(fair=true)) en contextos security-sensitive
  • Ataques de timing side-channel: las operaciones en colecciones concurrentes tienen variaciones de timing según el estado interno. Un atacante midiendo tiempos de respuesta puede inferir el tamaño o contenido de la colección. Agrega checks de tiempo constante para operaciones security-sensitive
  • Publicación insegura vía colecciones concurrentes: colocar un objeto en un ConcurrentHashMap lo publica de forma segura (happens-before). Pero objetos colocados en un HashMap regular accedido por múltiples threads no se publican de forma segura y pueden verse en estado inconsistente
  • Race de cleanup de recursos: remover una entrada de un map concurrente no garantiza que sus recursos (file handles, conexiones) se limpien. Usa computeIfPresent con una función de cleanup o emove(key, value) para remoción y cleanup atómicos
  • Invalidación de iteradores en contextos concurrentes: los iteradores de ConcurrentHashMap son weakly consistent y no lanzan ConcurrentModificationException. Esto puede enmascarar bugs donde se remueven elementos durante la iteración. Usa sincronización explícita si se requiere iteración consistente
  • Data poisoning cross-thread: si un thread corrompe el estado interno de un objeto compartido (ej. un valor mutable en un ConcurrentHashMap), todos los threads ven la corrupción. Usa valores inmutables o defensive copies
  • DoS en colas acotadas vía bloqueo: un atacante que llena una cola acotada causa que put() bloquee, negando servicio a los productores. Setea timeouts en operaciones put() (offer(timeout)) e implementa load shedding
  • Superficie de ataque basada en CAS: las operaciones compareAndSet en AtomicReference pueden explotarse si el valor esperado es controlado por el atacante. Asegúrate de que las operaciones CAS usen valores esperados manejados internamente, no input del usuario

Preguntas frecuentes

P: ¿Debería siempre usar colecciones concurrentes en código multithread? R: Si la colección es compartida, sí. Si cada thread tiene su propia colección (ej. un buffer local que se mergea al final), las colecciones estándar son más rápidas y simples. Las colecciones concurrentes tienen overhead que no necesitas para datos thread-local.

P: ¿Es ConcurrentHashMap completamente thread-safe? R: Las operaciones individuales (get, put, computeIfAbsent) son thread-safe. Las operaciones compuestas (if (!map.containsKey(k)) map.put(k, v)) no lo son. Usa computeIfAbsent, merge, o compute para operaciones compuestas atómicas.

P: ¿Cuándo debería usar CopyOnWriteArrayList vs Collections.synchronizedList? R: Usa CopyOnWriteArrayList cuando las escrituras son raras (ej. listeners configurados al arrancar) y las lecturas frecuentes. Usa Collections.synchronizedList cuando las escrituras son frecuentes y las lecturas ocasionales — aunque ConcurrentLinkedQueue suele ser mejor que ambos para patrones de acceso tipo cola.

P: ¿Puedo usar colecciones concurrentes desde código async/await? R: Las colecciones concurrentes de Java funcionan bien con virtual threads y CompletableFuture. En Python, asyncio tiene su propia asyncio.Queue — mezclar threading.Queue con asyncio requiere bridging entre contextos de thread y event loop usando loop.call_soon_threadsafe().

¿Esta solución está lista para producción?

Sí. Los ejemplos de código arriba muestran implementaciones probadas. Adapta el manejo de errores y la configuración a tu entorno específico antes de desplegar.

¿Cuáles son las características de rendimiento?

El rendimiento depende de tu volumen de datos e infraestructura. Las soluciones mostradas priorizan claridad. Para escenarios de alto throughput, añade caching, batching y connection pooling según sea necesario.

¿Cómo depuro problemas con este enfoque?

Empieza con el ejemplo mínimo de arriba. Añade logging en cada paso. Prueba con entradas pequeñas primero, luego escala. Usa el debugger de tu lenguaje para revisar los edge cases.

  • Corrupcion de estado via referencias stale: si un thread obtiene una referencia a un objeto mutable desde una coleccion concurrente y otro thread lo modifica simultaneamente, el primer thread puede leer datos corruptos. Usa defensive copies o valores inmutables
  • DoS via crecimiento de segmentos: un atacante puede forzar el crecimiento de segmentos internos de ConcurrentHashMap insertando keys con hash collisions, degradando el rendimiento. Usa funciones de hash con buena distribucion

Temas Avanzados

Escenario: Estructuras de Datos Concurrentes en Java

// ConcurrentHashMap: thread-safe sin bloquear toda la estructura
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// Operaciones atomicas
map.putIfAbsent("count", 0);
map.computeIfPresent("count", (k, v) -> v + 1);
map.computeIfAbsent("stats", k -> new ArrayList<>());

// AtomicLong: contador thread-safe sin locks
AtomicLong counter = new AtomicLong(0);
counter.incrementAndGet();      // ++counter
counter.compareAndSet(5, 10);   // if (counter == 5) counter = 10
counter.updateAndGet(x -> x * 2); // counter *= 2

// BlockingQueue: productor-consumidor thread-safe
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000);
// Productor
queue.put(task);  // bloquea si la queue esta llena
// Consumidor
Task task = queue.take();  // bloquea si la queue esta vacia

// CopyOnWriteArrayList: optimizado para lectura, copia en write
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(new Listener());  // copia el array interno
for (Listener l : listeners) { l.notify(); }  // sin sincronizacion en read
Comparacion de estructuras concurrentes:
  | Estructura | Lectura | Escritura | Use case |
  |------------|---------|-----------|----------|
  | ConcurrentHashMap | No bloquea | Segment lock | Cache, mapa compartido |
  | synchronizedMap | Bloquea | Bloquea | Legacy, simple |
  | CopyOnWriteArrayList | No bloquea | Copia array | Listeners, configs |
  | BlockingQueue | Bloquea | Bloquea | Producer-consumer |
  | ConcurrentLinkedQueue | No bloquea | CAS | Work stealing |
  | AtomicLong | No bloquea | CAS | Contadores, secuencias |

Lecciones:

  • ConcurrentHashMap: segment locks, no bloquea toda la estructura
  • Atomic*: CAS (Compare-And-Swap), sin locks del SO
  • BlockingQueue: bloquea al productor/consumidor, ideal para pipelines
  • CopyOnWrite: optimizado para mucho read, poco write
  • Evitar synchronized en metodos: granularidad gruesa, contencion
  • Preferir java.util.concurrent sobre synchronized Collections

### Como evito deadlocks con estructuras concurrentes?

Usa estructuras lock-free cuando sea posible (ConcurrentLinkedQueue, Atomic*). Si necesitas multiples locks, adquiere siempre en el mismo orden. Usa tryLock con timeout: no bloquees indefinidamente. Evita locks anidados: si tienes lock A y lock B, reestructura para no necesitar ambos. Usa java.util.concurrent en lugar de synchronized: las clases concurrentes estan disenadas para evitar deadlocks. Para transacciones, usar STM (Software Transactional Memory) o database transactions.