Diferencias entre ConcurrentHashMap, HashMap y Collections.synchronizedMap

1. Introducción

Cuando múltiples threads leen y escriben simultáneamente en un Map, tienes tres opciones bien conocidas: HashMap, Collections.synchronizedMap y ConcurrentHashMap. Estas tres opciones difieren claramente en seguridad para multi-threading, en el mecanismo de lock y en el comportamiento durante la iteración.

Este artículo explica cada tipo a través de ejemplos funcionales. Después de leerlo, sabrás cuál elegir, cómo escribir operaciones complejas de forma atómica y evitarás los errores más comunes.

2. Preparación

Los ejemplos usan Java 21. Para los tests necesitas JUnit 5 y AssertJ:

<dependencies>
    <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>
</dependencies>

Si tu proyecto es Spring Boot 3.x, spring-boot-starter-test ya incluye ambas librerías.

3. HashMap: rápido pero no thread-safe

HashMap no tiene ningún mecanismo de sincronización. En un solo thread, es la opción más rápida. Cuando múltiples threads escriben simultáneamente, los datos pueden perderse.

3.1. Pérdida de actualizaciones en conteo concurrente

El siguiente ejemplo cuenta órdenes de un cliente usando 8 threads:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class HashMapRaceDemo {

    public static void main(String[] args) {
        Map<String, Integer> orderCount = new HashMap<>();

        try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
            for (int i = 0; i < 10_000; i++) {
                executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum));
            }
        } // close() espera a que todos los tasks terminen

        System.out.println("Número de órdenes: " + orderCount.get("customer-1"));
    }
}

Enviamos 10.000 tasks, cada uno suma 1 usando merge. Desde Java 19, ExecutorService es AutoCloseable, así que try-with-resources espera a que todos los tasks terminen. El resultado cambia en cada ejecución:

Número de órdenes: 9817

La razón es que merge de HashMap consta de varios pasos: leer el valor anterior, calcular el nuevo valor y escribirlo. Dos threads pueden leer 100 simultáneamente, escribir 101, y una suma se pierde. Peor aún, si dos threads activan resize simultáneamente, la estructura interna puede dañarse.

3.2. ConcurrentModificationException durante la iteración

El iterator de HashMap es fail-fast. Si el map se modifica durante la iteración sin pasar por el iterator mismo, la siguiente llamada a next() puede lanzar ConcurrentModificationException. Este error ocurre incluso en un solo thread, por ejemplo cuando llamas a map.remove() dentro de un bucle for-each.

Nota: El mecanismo fail-fast es solo “mejor esfuerzo” (best-effort). La ausencia de excepción no significa que tu código sea correcto.

4. Collections.synchronizedMap: un lock para todo

Collections.synchronizedMap envuelve cualquier Map. Todos los métodos del wrapper se ejecutan dentro de synchronized (mutex), y por defecto mutex es el mismo objeto wrapper.

Map<String, Integer> orderCount = Collections.synchronizedMap(new HashMap<>());

Si reemplazas la línea de inicialización en el ejemplo de la sección 3.1 con la anterior, el resultado siempre es correcto:

Número de órdenes: 10000

Desde Java 8, este wrapper reemplaza métodos por defecto como merge, compute y putIfAbsent, así que cada llamada individual es atómica.

4.1. Las operaciones complejas no son atómicas

Cada método está sincronizado por separado. Cuando combinas dos llamadas juntas, esa secuencia ya no es atómica:

Map<String, Customer> customers = Collections.synchronizedMap(new HashMap<>());

// INCORRECTO: dos threads pueden ver simultáneamente "no existe" y ambos harán put
if (!customers.containsKey(id)) {
    customers.put(id, new Customer(id));
}

// CORRECTO: mantén el lock durante todo el check-then-act
synchronized (customers) {
    if (!customers.containsKey(id)) {
        customers.put(id, new Customer(id));
    }
}

Una forma más simple es usar customers.putIfAbsent(id, new Customer(id)), porque es una única llamada.

4.2. Debes sincronizar manualmente durante la iteración

El iterator del wrapper no está sincronizado. Javadoc requiere que sincronices manualmente sobre el map:

synchronized (customers) {
    for (Customer c : customers.values()) {
        System.out.println(c);
    }
}

Si olvidas el bloque synchronized, aún puedes encontrar ConcurrentModificationException. Una vez bloqueado, cualquier otro thread que quiera leer o escribir debe esperar hasta que el bucle termine.

4.3. Cuello de botella de rendimiento

Hay solo un lock para todo el map. Dos threads leyendo dos claves diferentes aún deben esperar en fila. Cuando aumenta el número de threads, la contención de lock también aumenta, y el throughput disminuye significativamente.

5. ConcurrentHashMap: diseñado para multi-threading

ConcurrentHashMap (paquete java.util.concurrent) está diseñado específicamente para acceso concurrente. Desde Java 8, su implementación es la siguiente:

  • Lectura sin lock. get usa lectura volatile, así que casi nunca se bloquea.
  • Escritura con lock por bucket. Cuando un bucket está vacío, inserta usando CAS (compare-and-swap). Cuando un bucket ya tiene elementos, solo sincroniza en el nodo cabeza de ese bucket. Los threads escribiendo en buckets diferentes se ejecutan en paralelo.
  • Resize cooperativo. Múltiples threads participan juntos en trasladar datos a la nueva tabla, en lugar de bloquear todo el map.

5.1. Operaciones complejas atómicas

Los métodos putIfAbsent, computeIfAbsent, computeIfPresent, compute y merge son todos atómicos por clave:

import java.util.concurrent.ConcurrentHashMap;

public class InventoryService {

    private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();

    public void addStock(String product, int quantity) {
        stock.merge(product, quantity, Integer::sum);
    }

    public boolean reserve(String product) {
        Integer result = stock.computeIfPresent(product,
                (key, current) -> current > 0 ? current - 1 : current);
        return result != null && result >= 0;
    }

    public int available(String product) {
        return stock.getOrDefault(product, 0);
    }
}

addStock suma la cantidad usando merge. Si la clave no existe, merge usa directamente quantity como valor inicial. reserve reduce el inventario en 1 con computeIfPresent. Toda la operación de leer, verificar y escribir ocurre en un paso atómico para esa clave, así que ningún dos threads pueden ocupar simultáneamente la última unidad.

Para contar con frecuencia muy alta, combinar con LongAdder reduce aún más la contención:

ConcurrentHashMap<String, LongAdder> hits = new ConcurrentHashMap<>();
hits.computeIfAbsent("/orders", k -> new LongAdder()).increment();

5.2. Iterator débilmente consistente

El iterator de ConcurrentHashMap nunca lanza ConcurrentModificationException. Puedes modificar el map incluso mientras lo iteras:

ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>(
        Map.of("apple", 10, "banana", 0, "cherry", 3));

for (String product : stock.keySet()) {
    if (stock.get(product) == 0) {
        stock.remove(product);
    }
}

System.out.println(stock);
{apple=10, cherry=3}

El iterator refleja el estado del map en algún momento desde su creación. El iterator puede ver, o no ver, cambios que ocurren después. Cada elemento se devuelve como máximo una vez. Este es un compromiso deliberado: no te bloqueas y no hay exception, pero tampoco tienes un snapshot exacto.

5.3. No acepta null

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("apple", null); // NullPointerException
map.put(null, 1);       // NullPointerException

Con HashMap, get(key) retorna null puede significar dos cosas: la clave no existe, o la clave mapea a null. En un solo thread, puedes llamar a containsKey para diferenciar. En un entorno concurrente, el map puede haber cambiado entre esas dos llamadas. Por eso ConcurrentHashMap prohíbe null para que get retorne null tenga un único significado: la clave no existe.

5.4. size() es solo una aproximación

Cuando otros threads están escribiendo, size() e isEmpty() solo reflejan un estado aproximado. Si el map puede exceder Integer.MAX_VALUE elementos, usa mappingCount(), ya que este método retorna long. No escribas lógica de negocio basada en que size() debe ser exacto en un momento particular.

6. Pruebas del comportamiento concurrente

La siguiente prueba confirma que ConcurrentHashMap cuenta correctamente bajo carga concurrente y rechaza valores null:

import org.junit.jupiter.api.Test;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class ConcurrentMapTest {

    @Test
    void givenManyThreads_whenMerge_thenNoUpdateIsLost() {
        Map<String, Integer> orderCount = new ConcurrentHashMap<>();

        try (ExecutorService executor = Executors.newFixedThreadPool(8)) {
            IntStream.range(0, 10_000).forEach(i ->
                    executor.submit(() -> orderCount.merge("customer-1", 1, Integer::sum)));
        }

        assertThat(orderCount).containsEntry("customer-1", 10_000);
    }

    @Test
    void givenNullValue_whenPut_thenThrowNpe() {
        Map<String, Integer> stock = new ConcurrentHashMap<>();

        assertThatThrownBy(() -> stock.put("apple", null))
                .isInstanceOf(NullPointerException.class);
    }
}

La primera prueba siempre pasa. Si cambias a new HashMap<>(), la prueba fallará en la mayoría de ejecuciones, con un valor real menor que 10.000.

Nota: Un test multi-threading que pase no prueba que el código sea correcto. Las race conditions pueden solo aparecer bajo carga real. Confía en las garantías de la API, como la atomicidad de merge, en lugar de solo confiar en que los tests pasen.

7. Errores comunes con ConcurrentHashMap

Combinar get y put. Usar ConcurrentHashMap no hace que tu código sea automáticamente seguro:

// INCORRECTO: race condition entre get y put
Integer current = stock.get("apple");
stock.put("apple", current - 1);

// CORRECTO
stock.computeIfPresent("apple", (k, v) -> v - 1);

La función en compute* se ejecuta lentamente o tiene side effects. La función de mapeo se ejecuta mientras el bucket está bloqueado. Si llamas a HTTP o consultas la base de datos en computeIfAbsent, todos los otros threads escribiendo en ese bucket se bloquearán. Mantén la función concisa y sin side effects.

Modificar el propio map dentro de compute*. Llamar a map.put o map.computeIfAbsent anidado en la función de mapeo del mismo map puede lanzar IllegalStateException: Recursive update o causar deadlock. Un ejemplo típico es un cache recursivo tipo memoization de Fibonacci usando computeIfAbsent.

Necesitas atomicidad sobre múltiples claves. ConcurrentHashMap solo garantiza atomicidad por clave. Por ejemplo, transferir inventario de almacén A a almacén B es una operación en dos claves. Requiere su propio lock, o necesita rediseñar los datos para consolidarlos en una sola clave.

Necesitas ordenamiento. ConcurrentHashMap no mantiene el orden de inserción. Si necesitas que las claves estén ordenadas, usa ConcurrentSkipListMap.

8. Comparación integral

Criterio              | HashMap          | synchronizedMap          | ConcurrentHashMap
----------------------|------------------|--------------------------|--------------------------
Thread-safe           | No               | Sí                       | Sí
Mecanismo de lock     | Ninguno          | Un lock para todo el map | CAS + lock por bucket
Lectura concurrente   | No es seguro     | Secuencial (bloqueado)   | Sin lock
Operaciones complejas | No atómicas      | Requiere synchronized    | compute/merge atómicos
Iterator              | Fail-fast        | Fail-fast, sincronización | Débilmente consistente
Key/value null        | Permitido        | Depende del map envuelto | No permitido
Rendimiento multi-    | —                | Pobre con alta contención| Bueno
threading             |                  |                          |

Entonces, ¿cuál elegir?

  • HashMap: el map solo es accedido por un thread, o se crea, publica de forma segura y solo se lee. Para solo lectura, Map.copyOf es aún mejor.
  • Collections.synchronizedMap: necesitas envolver un Map existente como LinkedHashMap o TreeMap para mantener el orden o comportamiento específico, la carga concurrente es baja, o necesitas bloquear todo el map para una larga secuencia de operaciones.
  • ConcurrentHashMap: la opción predeterminada para cachés, contadores, registros y cualquier map compartido entre múltiples threads.

9. Conclusión

HashMap es rápido pero pierde datos cuando múltiples threads escriben simultáneamente. Collections.synchronizedMap es seguro gracias a un único lock. A cambio, crea un cuello de botella, y aún debes sincronizar manualmente durante la iteración o cuando combinas múltiples operaciones. ConcurrentHashMap permite lectura sin lock y escritura paralela por bucket. Proporciona operaciones atómicas como merge o computeIfAbsent, y su iterator no lanza excepciones cuando el map cambia.

Recuerda dos principios. Primero, una collection thread-safe no hace que tu secuencia de operaciones sea automáticamente atómica, así que usa compute* y merge en lugar de get y put. Segundo, mantén la función de mapeo concisa y no modifiques el propio map dentro de ella.

javaconcurrencycollectionsconcurrenthashmapmultithreading