En el módulo 5 construiste el corazón de BiblioTech con ArrayList, HashMap, HashSet y ArrayDeque. Eran las herramientas correctas —para un solo hilo—. En 08-04 protegiste el catálogo con candados y funcionó, pero cada lectura paga un lock() aunque leer no estorbe a nadie, y en 08-05 ya usaste AtomicInteger sin haberlo explicado.

Esta lección cierra las dos grietas. Empieza donde más duele: una demostración de que un HashMap compartido entre dos hilos no es que dé resultados raros, es que puede corromperse estructuralmente y dejar un núcleo al 100 % en un bucle infinito del que no sale nunca. A partir de ahí recorre las tres generaciones de solución —colecciones sincronizadas, colecciones concurrentes, y variables atómicas— con la pregunta que las une: ¿cómo se consigue que una operación compuesta sea atómica sin bloquear a todo el mundo?

Por el camino se salda la deuda del módulo 5: la BlockingQueue que se mencionó allí y se remitió aquí, y la implementación completa del patrón productor-consumidor que se describió conceptualmente y se dejó sin escribir.

Al terminar, el catálogo de BiblioTech usará ConcurrentHashMap, sus estadísticas serán contadores atómicos sin un solo candado, y su cola de reservas será un productor-consumidor real con apagado limpio por píldora venenosa.

Contenido

  1. Un HashMap corrompido por dos hilos
  2. La ConcurrentModificationException revisitada
  3. Primera generación: colecciones sincronizadas
  4. La doble trampa de las colecciones sincronizadas
  5. ConcurrentHashMap: cómo funciona por dentro
  6. Las operaciones atómicas compuestas
  7. Iteradores débilmente consistentes y el size() aproximado
  8. CopyOnWriteArrayList y CopyOnWriteArraySet
  9. ConcurrentLinkedQueue y las colas concurrentes
  10. BlockingQueue: la familia completa
  11. El patrón productor-consumidor
  12. La píldora venenosa
  13. ConcurrentSkipListMap en una nota
  14. Variables atómicas y compare-and-swap
  15. Las operaciones de las clases atómicas
  16. AtomicReference y el problema ABA
  17. LongAdder bajo contención
  18. BiblioTech: catálogo concurrente y estadísticas atómicas
  19. Tabla final de decisión
  20. Errores Comunes y Consejos
  21. Ejercicios

  1. Un HashMap corrompido por dos hilos

HashMap no es seguro para varios hilos. La frase se repite en todas partes; lo que casi nunca se explica es qué significa exactamente, y el significado es peor de lo que suena.

Recuerda de 05-05 la estructura interna: un array de cubetas, y en cada cubeta una lista enlazada (o un árbol, si crece mucho) con las entradas que colisionan. Cuando el mapa supera el factor de carga, se hace un rehash: se crea un array mayor y se redistribuyen todas las entradas.

Si dos hilos hacen put durante un rehash, las listas enlazadas pueden acabar formando un ciclo. Y una lista con un ciclo hace que un get() posterior no termine nunca.

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class HashMapCorrompido {

    public static void main(String[] args) throws InterruptedException {

        for (int intento = 1; intento <= 5; intento++) {

            Map<Integer, String> mapa = new HashMap<>();
            final int POR_HILO = 100_000;

            Thread h1 = new Thread(() -> {
                for (int i = 0; i < POR_HILO; i++) mapa.put(i, "A" + i);
            }, "escritor-1");

            Thread h2 = new Thread(() -> {
                for (int i = POR_HILO; i < POR_HILO * 2; i++) mapa.put(i, "B" + i);
            }, "escritor-2");

            h1.start();
            h2.start();

            // Plazo: si el HashMap se corrompe, un hilo puede quedarse
            // en un bucle infinito dentro de put() o de get().
            h1.join(5000);
            h2.join(5000);

            int esperado = POR_HILO * 2;
            if (h1.isAlive() || h2.isAlive()) {
                System.out.printf("Intento %d: BUCLE INFINITO. "
                        + "h1=%s h2=%s  <-- estructura corrompida%n",
                        intento, h1.getState(), h2.getState());
                System.out.println("  (mira el uso de CPU: un nucleo al 100%)");
                System.exit(1);
            }
            System.out.printf("Intento %d: esperado %d, real %d, perdidas %d%n",
                    intento, esperado, mapa.size(), esperado - mapa.size());
        }
    }
}

Salida típica:

Intento 1: esperado 200000, real 187341, perdidas 12659
Intento 2: esperado 200000, real 193028, perdidas 6972
Intento 3: BUCLE INFINITO. h1=RUNNABLE h2=RUNNABLE  <-- estructura corrompida
  (mira el uso de CPU: un nucleo al 100%)

Los dos modos de fallo, de menos a más grave:

  1. Entradas perdidas. Dos put simultáneos sobre la misma cubeta pisan uno el trabajo del otro. El mapa acaba con menos entradas de las insertadas. Es la condición de carrera de 08-04 aplicada a una estructura de datos.
  2. Bucle infinito. Durante el rehash, dos hilos pueden dejar la lista enlazada de una cubeta apuntándose a sí misma. Un get() que caiga en esa cubeta recorre el ciclo para siempre, consumiendo un núcleo al 100 % y sin lanzar ninguna excepción.

El segundo caso es una anécdota famosa de la industria: durante años fue una causa recurrente de servidores que se quedaban al 100 % de CPU sin motivo aparente, y el diagnóstico —volcado de hilos, apartado 12 de 08-03, con varios hilos RUNNABLE dentro de HashMap.get— es una historia que cuenta cualquiera que lleve tiempo en producción.

Nota técnica. En Java 8+ la implementación del rehash cambió y el ciclo es mucho más difícil de provocar que en Java 7, pero la clase sigue sin ser segura para varios hilos y las pérdidas de entradas se reproducen sin dificultad. No es un problema resuelto: es un problema menos visible.

La conclusión: compartir un HashMap entre hilos sin protección no da "resultados aproximados". Da estructuras rotas.

  1. La ConcurrentModificationException revisitada

En 05-02 viste el comportamiento fail-fast: los iteradores de las colecciones clásicas llevan un contador modCount y comprueban en cada next() que nadie haya modificado la colección por detrás.

List<String> lista = new ArrayList<>(List.of("a", "b", "c"));
for (String s : lista) {
    lista.remove(s);      // ConcurrentModificationException
}

Allí era un solo hilo. Ahora aparece la versión concurrente, y es más traicionera:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class FailFastConcurrente {

    public static void main(String[] args) throws InterruptedException {

        List<String> catalogo = new ArrayList<>();
        for (int i = 0; i < 10_000; i++) catalogo.add("978-" + i);

        Thread lector = new Thread(() -> {
            try {
                while (!Thread.currentThread().isInterrupted()) {
                    int n = 0;
                    for (String isbn : catalogo) {    // iteracion
                        n += isbn.length();
                    }
                }
            } catch (java.util.ConcurrentModificationException e) {
                System.out.println("[lector] ConcurrentModificationException: "
                        + "otro hilo modifico el catalogo mientras iteraba");
            }
        }, "bibliotech-lector");

        Thread escritor = new Thread(() -> {
            try {
                while (!Thread.currentThread().isInterrupted()) {
                    catalogo.add("978-nuevo");
                    TimeUnit.MILLISECONDS.sleep(1);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "bibliotech-escritor");

        lector.start();
        escritor.start();

        TimeUnit.SECONDS.sleep(2);
        lector.interrupt();
        escritor.interrupt();
    }
}

Salida (casi inmediata):

[lector] ConcurrentModificationException: otro hilo modifico el catalogo mientras iteraba

Lo importante de este ejemplo es cómo hay que interpretar esa excepción. No es "un error de concurrencia que hay que capturar". Es un detector de errores que la colección ofrece como cortesía: te avisa de que estás usando la colección de forma insegura. Capturar la excepción y reintentar es tapar el síntoma; la solución es cambiar la colección o proteger el acceso.

Y hay un matiz que se olvida: el fail-fast no está garantizado. La documentación es explícita: modCount no es volatile, así que un iterador puede no ver la modificación y devolver datos incoherentes en lugar de lanzar la excepción. Puedes tener una carrera silenciosa.

  1. Primera generación: colecciones sincronizadas

Java 1.2 introdujo los envoltorios sincronizados de Collections:

import java.util.*;

Map<String, Material> mapa = Collections.synchronizedMap(new HashMap<>());
List<Material> lista       = Collections.synchronizedList(new ArrayList<>());
Set<String> conjunto       = Collections.synchronizedSet(new HashSet<>());

Cada método del envoltorio es un synchronized sobre el propio envoltorio:

// Aproximadamente, lo que hace Collections.synchronizedMap:
public V get(Object clave) {
    synchronized (mutex) { return m.get(clave); }
}
public V put(K clave, V valor) {
    synchronized (mutex) { return m.put(clave, valor); }
}

Resuelve la corrupción del apartado 1 —los put ya no se pisan— pero tiene un problema evidente y dos trampas.

El problema evidente: un único candado global. Todas las operaciones, incluidas las lecturas, se serializan. Con dieciséis hilos leyendo un mapa, quince están esperando. Es un cuello de botella exacto.

Hashtable y Vector son la versión antigua de lo mismo, de Java 1.0, con todos sus métodos sincronizados. Tienen además el defecto de 08-04: sincronizan sobre this, así que su candado está expuesto. No los uses en código nuevo.

  1. La doble trampa de las colecciones sincronizadas

Trampa 1: iterar sigue requiriendo sincronización manual.

Cada método individual está sincronizado, pero una iteración son muchas llamadas. Entre hasNext() y next() no hay ningún candado, así que otro hilo puede modificar la colección y provocar la ConcurrentModificationException del apartado 2.

List<Material> lista = Collections.synchronizedList(new ArrayList<>());

// MAL: cada llamada del iterador esta sincronizada, pero la
// ITERACION COMPLETA no. ConcurrentModificationException garantizada.
for (Material m : lista) {
    procesar(m);
}

// BIEN: sincronizar la iteracion entera sobre el propio envoltorio.
// Es lo que exige explicitamente el javadoc de Collections.synchronizedList,
// y casi nadie lee esa parte.
synchronized (lista) {
    for (Material m : lista) {
        procesar(m);      // OJO: 'procesar' se ejecuta CON EL CANDADO TOMADO
    }                     // (regla 3 de 08-04: nada de codigo ajeno aqui)
}

Fíjate en el coste de la versión correcta: durante toda la iteración, nadie más puede tocar la lista. Con diez mil elementos y un procesamiento de un milisegundo cada uno, son diez segundos de bloqueo total.

Trampa 2: las operaciones compuestas siguen sin ser atómicas.

Esta es la peor, porque el código parece seguro.

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

// MAL: comprobar-luego-actuar. Cada llamada esta sincronizada,
// pero el HUECO entre ellas no lo esta.
if (!prestamosPorIsbn.containsKey(isbn)) {     // (1) hilo A: no existe
    prestamosPorIsbn.put(isbn, 1);             // (3) hilo A escribe 1
}                                              // (2) hilo B tambien vio "no existe"
                                               // (4) hilo B escribe 1 -> se pierde uno

// MAL: leer-modificar-escribir, el mismo problema que 'contador++' (08-04)
Integer n = prestamosPorIsbn.get(isbn);        // (1) lee 5
prestamosPorIsbn.put(isbn, n + 1);             // (3) escribe 6
                                               // otro hilo tambien leyo 5 y
                                               // tambien escribe 6: un prestamo perdido

Son las dos formas canónicas de condición de carrera de 08-04, y la colección sincronizada no hace nada por evitarlas. La única forma de arreglarlo con esta generación es un candado externo:

// Correcto pero torpe: candado externo sobre la coleccion.
synchronized (prestamosPorIsbn) {
    Integer n = prestamosPorIsbn.get(isbn);
    prestamosPorIsbn.put(isbn, n == null ? 1 : n + 1);
}

Funciona, pero has tenido que salir de la abstracción: la colección "segura" no lo era para lo que necesitabas, y ahora la seguridad depende de que todo el código de la aplicación recuerde tomar ese candado. Con eso llegamos a la segunda generación.

  1. ConcurrentHashMap: cómo funciona por dentro

ConcurrentHashMap (Java 5, reescrito en Java 8) resuelve las tres cosas a la vez: no se corrompe, no serializa las lecturas, y ofrece operaciones compuestas atómicas.

Sus dos ideas centrales:

Idea 1: las lecturas no bloquean nunca. Los nodos internos tienen sus campos value y next declarados volatile. Gracias a las garantías de visibilidad de 08-04, un get() puede leer sin adquirir ningún candado y aun así ver un valor coherente y reciente. Cero contención entre lectores, y entre lectores y escritores.

Idea 2: las escrituras bloquean solo la cubeta afectada. En lugar de un candado global, se sincroniza sobre el primer nodo de la cubeta. Dos escrituras en cubetas distintas —que es el caso normal, porque el hash las reparte— no se estorban en absoluto.

flowchart TB
    subgraph SM["Collections.synchronizedMap"]
        direction TB
        C1["UN candado global"] --> T1["cubeta 0"]
        C1 --> T2["cubeta 1"]
        C1 --> T3["cubeta 2"]
        C1 --> T4["cubeta 3"]
        N1["Todas las operaciones,<br/>lecturas incluidas,<br/>se serializan"]
    end
    subgraph CHM["ConcurrentHashMap"]
        direction TB
        L["Lecturas: SIN candado<br/>campos volatile"]
        B0["cubeta 0<br/>candado propio"]
        B1["cubeta 1<br/>candado propio"]
        B2["cubeta 2<br/>candado propio"]
        B3["cubeta 3<br/>candado propio"]
        N2["Escrituras en cubetas<br/>distintas: en paralelo"]
    end

Demostración de la diferencia de rendimiento:

import java.util.*;
import java.util.concurrent.*;

public class ComparativaMapas {

    static long medir(Map<Integer, String> mapa, int hilos, int opsPorHilo,
                      double proporcionLectura) throws InterruptedException {

        // Precargar para que las lecturas acierten.
        for (int i = 0; i < 10_000; i++) mapa.put(i, "valor-" + i);

        CountDownLatch salida = new CountDownLatch(1);
        CountDownLatch meta   = new CountDownLatch(hilos);

        for (int h = 0; h < hilos; h++) {
            new Thread(() -> {
                try {
                    salida.await();
                    ThreadLocalRandom azar = ThreadLocalRandom.current();
                    for (int i = 0; i < opsPorHilo; i++) {
                        int clave = azar.nextInt(10_000);
                        if (azar.nextDouble() < proporcionLectura) {
                            mapa.get(clave);
                        } else {
                            mapa.put(clave, "nuevo-" + i);
                        }
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    meta.countDown();
                }
            }, "acceso-" + h).start();
        }

        long inicio = System.nanoTime();
        salida.countDown();          // arrancan todos a la vez (08-05)
        meta.await();
        return (System.nanoTime() - inicio) / 1_000_000;
    }

    public static void main(String[] args) throws InterruptedException {

        final int HILOS = 16;
        final int OPS = 200_000;

        System.out.printf("%d hilos x %d operaciones%n%n", HILOS, OPS);
        System.out.printf("%-28s | %10s | %10s%n", "Implementacion", "90% lectura", "50% lectura");
        System.out.println("-----------------------------|------------|------------");

        long s90 = medir(Collections.synchronizedMap(new HashMap<>()), HILOS, OPS, 0.9);
        long s50 = medir(Collections.synchronizedMap(new HashMap<>()), HILOS, OPS, 0.5);
        System.out.printf("%-28s | %8d ms | %8d ms%n", "synchronizedMap", s90, s50);

        long c90 = medir(new ConcurrentHashMap<>(), HILOS, OPS, 0.9);
        long c50 = medir(new ConcurrentHashMap<>(), HILOS, OPS, 0.5);
        System.out.printf("%-28s | %8d ms | %8d ms%n", "ConcurrentHashMap", c90, c50);

        System.out.printf("%nMejora: %.1fx (90%% lectura), %.1fx (50%% lectura)%n",
                (double) s90 / c90, (double) s50 / c50);
    }
}

Salida orientativa:

16 hilos x 200000 operaciones

Implementacion               | 90% lectura | 50% lectura
-----------------------------|------------|------------
synchronizedMap              |     3187 ms |     3402 ms
ConcurrentHashMap            |      184 ms |      271 ms

Mejora: 17.3x (90% lectura), 12.6x (50% lectura)

Un orden de magnitud de diferencia, y crece con el número de hilos. Con un solo hilo, en cambio, los dos son casi iguales: la ganancia es de escalabilidad, no de velocidad bruta.

  1. Las operaciones atómicas compuestas

Esta es la aportación más valiosa de ConcurrentHashMap, y la que resuelve la trampa 2 del apartado 4: operaciones compuestas que son atómicas de verdad.

Método Qué hace atómicamente
putIfAbsent(k, v) Inserta si la clave no está; devuelve el valor previo o null
computeIfAbsent(k, f) Si no está, calcula el valor con f e inserta
computeIfPresent(k, f) Si está, recalcula el valor con f
compute(k, f) Recalcula siempre; null como resultado elimina la entrada
merge(k, v, f) Si no está pone v; si está, combina el actual con v usando f
remove(k, v) Elimina solo si el valor actual es v
replace(k, viejo, nuevo) Reemplaza solo si el valor actual es viejo
getOrDefault(k, d) Devuelve el valor o d si no está (no modifica)

Los mismos casos del apartado 4, ahora correctos:

import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;

public class OperacionesAtomicasMapa {

    private final Map<String, Integer> prestamosPorIsbn = new ConcurrentHashMap<>();
    private final Map<String, Ficha> cacheFichas = new ConcurrentHashMap<>();

    /** Comprobar-luego-actuar, resuelto: una sola operacion atomica. */
    public boolean registrarPrimerPrestamo(String isbn) {
        // Devuelve null si NO existia (y lo inserta), o el valor previo.
        return prestamosPorIsbn.putIfAbsent(isbn, 1) == null;
    }

    /** Leer-modificar-escribir, resuelto con merge. */
    public void contarPrestamo(String isbn) {
        // Si no existe -> pone 1. Si existe -> aplica Integer::sum
        // entre el valor actual y el 1 que pasamos. Todo atomico.
        prestamosPorIsbn.merge(isbn, 1, Integer::sum);
    }

    /** Lo mismo con compute, mas explicito. */
    public void contarPrestamoAlternativo(String isbn) {
        prestamosPorIsbn.compute(isbn, (clave, actual) ->
                actual == null ? 1 : actual + 1);
    }

    /**
     * Cache perezosa, resuelta con computeIfAbsent.
     * Sustituye a las tres fases del ejercicio 3 de 08-04:
     * la funcion se ejecuta A LO SUMO UNA VEZ por clave, aunque
     * diez hilos la pidan a la vez. Los otros nueve esperan y
     * reciben el mismo objeto: no hay calculo duplicado.
     */
    public Ficha obtenerFicha(String isbn) {
        return cacheFichas.computeIfAbsent(isbn, this::construirFicha);
    }

    /** Eliminar solo si el valor es el esperado: comparar-y-eliminar. */
    public boolean devolverSiEsElUltimo(String isbn) {
        return prestamosPorIsbn.remove(isbn, 1);
    }

    /** Contador de descargas sin NullPointerException. */
    public int descargasDe(String isbn) {
        return prestamosPorIsbn.getOrDefault(isbn, 0);
    }

    private Ficha construirFicha(String isbn) { /* consulta cara */ return null; }
}

La advertencia crítica sobre computeIfAbsent y compute: la función que pasas se ejecuta con el candado de la cubeta tomado. De ahí tres prohibiciones absolutas:

// PROHIBIDO 1: modificar el MISMO mapa dentro de la funcion.
// Puede provocar un interbloqueo o corromper la estructura.
mapa.computeIfAbsent(k, clave -> {
    mapa.put("otra", "cosa");     // NUNCA
    return calcular(clave);
});

// PROHIBIDO 2: operaciones largas o bloqueantes.
// Bloquea la cubeta entera, y toda la aplicacion se resiente
// (regla 2 de 08-04: nada de E/S dentro del bloqueo).
mapa.computeIfAbsent(k, clave -> leerDeDisco(clave));   // MAL si tarda

// PROHIBIDO 3: llamar a codigo ajeno (oyentes, callbacks).
// Regla 3 de 08-04, aplicada aqui.

// CORRECTO cuando el calculo es caro: calcular fuera y publicar con putIfAbsent.
Ficha calculada = leerDeDisco(isbn);         // sin ningun candado
Ficha establecida = mapa.putIfAbsent(isbn, calculada);
Ficha resultado = (establecida != null) ? establecida : calculada;
// Puede haber calculo duplicado si dos hilos coinciden, pero
// el resultado es correcto y no se bloquea la cubeta.

  1. Iteradores débilmente consistentes y el size() aproximado

Las colecciones concurrentes cambian dos contratos respecto a las clásicas, y hay que conocerlos.

Iteradores débilmente consistentes. Los iteradores de ConcurrentHashMap no lanzan ConcurrentModificationException. Recorren el estado del mapa en el momento en que se creó el iterador, y pueden o no reflejar modificaciones posteriores. No garantizan ver los cambios, pero garantizan no romperse.

Fail-fast (HashMap) Débilmente consistente (ConcurrentHashMap)
Modificación durante la iteración ConcurrentModificationException Sin excepción
Ve los cambios posteriores Puede que sí, puede que no
Recorre cada elemento Sí, o falla Sí, cada elemento presente al empezar
Bloquea a los escritores Solo si sincronizas a mano Nunca
Es seguro para varios hilos No
Map<String, Material> catalogo = new ConcurrentHashMap<>();

// SEGURO: no lanza excepcion, no bloquea a nadie, y ningun escritor
// se queda esperando a que termines de recorrer 100.000 entradas.
for (Map.Entry<String, Material> e : catalogo.entrySet()) {
    procesar(e.getValue());
}
// Si otro hilo anade una entrada mientras iteras, puede que la veas
// y puede que no. Lo que NO ocurrira es que el bucle falle.

size() es aproximado. En ConcurrentHashMap, size(), isEmpty() y containsValue() devuelven un valor que era correcto en algún instante reciente, pero que puede haber cambiado antes de que lo uses.

// MAL: comprobar-luego-actuar sobre un tamano aproximado.
if (catalogo.size() < LIMITE) {
    catalogo.put(isbn, material);    // el tamano pudo cambiar entre medias
}

// El tamano de una estructura concurrente es un dato ESTADISTICO,
// util para logs y metricas, no para decisiones de control.
LOG.log(Level.INFO, "Catalogo con ~{0} materiales", catalogo.size());

Es una consecuencia inevitable del diseño: mantener un contador exacto exigiría un punto de sincronización global, y eso es justo lo que se ha eliminado para ganar escalabilidad.

  1. CopyOnWriteArrayList y CopyOnWriteArraySet

Estrategia radicalmente distinta: cada modificación copia el array entero.

  • Lecturas: sin ningún candado, sobre un array inmutable. Rapidísimas.
  • Escrituras: bajo candado, copian todo el array. Coste O(n) por escritura.
package com.nexussoftware.bibliotech.servicio;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * Registro de oyentes de eventos del catalogo.
 *
 * Caso de uso PERFECTO para CopyOnWriteArrayList:
 *  - Se registran ~10 oyentes al arrancar y casi nunca cambian.
 *  - Se recorren en CADA operacion del catalogo: miles de veces por minuto.
 *  - Proporcion lectura/escritura: 100.000 a 1.
 */
public class RegistroOyentes {

    private final List<OyenteCatalogo> oyentes = new CopyOnWriteArrayList<>();

    public void registrar(OyenteCatalogo o)   { oyentes.add(o); }
    public void desregistrar(OyenteCatalogo o) { oyentes.remove(o); }

    /**
     * Notifica a todos los oyentes.
     *
     * DOS ventajas decisivas frente a una lista sincronizada:
     *  1. NO hay candado durante el recorrido: los oyentes pueden
     *     registrar o desregistrar oyentes sin interbloquearse.
     *  2. Es la regla 3 de 08-04 —no llamar a codigo ajeno con un
     *     candado— cumplida automaticamente por la coleccion.
     */
    public void notificarAlta(Material m) {
        for (OyenteCatalogo o : oyentes) {
            try {
                o.materialAnadido(m);
            } catch (RuntimeException e) {
                // Un oyente defectuoso no debe impedir que los demas
                // se enteren (politica de degradacion de 06-07).
                LOG.log(Level.WARNING, "Oyente fallido: " + o, e);
            }
        }
    }
}

El detalle elegante: el iterador de CopyOnWriteArrayList trabaja sobre una instantánea del array en el momento de crearse. Es completamente inmune a las modificaciones, no lanza excepciones y no bloquea a nadie. A cambio, no soporta remove() (lanza UnsupportedOperationException) porque modificar una instantánea no tendría sentido.

Cuándo compensa y cuándo no:

Situación ¿Usar copia-al-escribir?
Oyentes de eventos , el caso canónico
Lista de configuración leída constantemente
Conjunto pequeño de reglas de negocio
Lista con miles de elementos y escrituras frecuentes No: cada escritura copia miles de referencias
Cola de trabajo No: usa una BlockingQueue
Acumulador que crece en un bucle No: O(n²) total
// DESASTRE: 10.000 escrituras sobre una lista que crece.
// Cada add() copia todo el array: 1 + 2 + ... + 10.000 ≈ 50 millones
// de copias de referencias. Segundos donde deberian ser milisegundos.
List<Material> lista = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10_000; i++) {
    lista.add(materiales.get(i));      // O(n) cada una -> O(n²) total
}

  1. ConcurrentLinkedQueue y las colas concurrentes

ConcurrentLinkedQueue es una cola FIFO no bloqueante e ilimitada, implementada con el algoritmo de Michael-Scott basado en compare-and-swap (apartado 14): sin candados en absoluto.

import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;

Queue<Reserva> pendientes = new ConcurrentLinkedQueue<>();

pendientes.offer(reserva);        // anadir: nunca bloquea, nunca falla
Reserva r = pendientes.poll();    // sacar: devuelve NULL si esta vacia

La diferencia clave con una BlockingQueue: poll() devuelve null si la cola está vacía, en lugar de esperar. Eso obliga al consumidor a sondear:

// ANTIPATRON con ConcurrentLinkedQueue: sondeo (08-03, apartado 6).
while (!Thread.currentThread().isInterrupted()) {
    Reserva r = pendientes.poll();
    if (r == null) {
        Thread.sleep(100);      // despierta 10 veces por segundo para nada
        continue;
    }
    atender(r);
}

Ese bucle quema CPU cuando no hay trabajo y añade hasta 100 ms de latencia cuando lo hay. Casi siempre lo que quieres es una BlockingQueue.

Usa ConcurrentLinkedQueue cuando no necesites esperar: acumular eventos que otro hilo vaciará periódicamente, o una bolsa de trabajo consultada de forma oportunista.

  1. BlockingQueue: la familia completa

Aquí se salda la deuda del módulo 5. BlockingQueue es una cola cuyas operaciones esperan cuando no se pueden completar: take() espera si está vacía, put() espera si está llena.

Los cuatro grupos de operaciones, que hay que conocer porque la elección importa:

Lanza excepción Devuelve valor especial Bloquea Con plazo
Insertar add(e) offer(e)false put(e) offer(e, t, u)
Extraer remove() poll()null take() poll(t, u)
Examinar element() peek()null

Las implementaciones:

Implementación Capacidad Estructura Cuándo usarla
ArrayBlockingQueue Acotada (fija) Array circular La opción por defecto: la cota da contrapresión
LinkedBlockingQueue Opcionalmente acotada Lista enlazada Más rendimiento con muchos productores y consumidores
SynchronousQueue 0 Sin almacenamiento Traspaso directo: cada put espera a un take
PriorityBlockingQueue Ilimitada Montículo Cuando el orden lo marca la prioridad, no la llegada
DelayQueue Ilimitada Montículo por tiempo Elementos que no se pueden sacar hasta cierto instante
LinkedTransferQueue Ilimitada Lista enlazada transfer(): esperar a que el consumidor lo reciba

Cada una en contexto:

import java.util.concurrent.*;

// 1. ARRAYBLOCKINGQUEUE: acotada. Si se llena, el productor ESPERA.
//    Eso es CONTRAPRESION: el productor se frena al ritmo del consumidor.
//    Es lo que evita el OutOfMemoryError de las colas ilimitadas (08-05).
BlockingQueue<Reserva> reservas = new ArrayBlockingQueue<>(100);

// 2. LINKEDBLOCKINGQUEUE acotada: dos candados internos (uno para la
//    cabeza y otro para la cola), asi que un productor y un consumidor
//    pueden trabajar a la vez. Mejor con mucha concurrencia.
BlockingQueue<Aviso> avisos = new LinkedBlockingQueue<>(500);

// 3. SYNCHRONOUSQUEUE: capacidad CERO. Cada put() espera a un take().
//    Es el traspaso mano a mano, sin almacen. Es la cola que usa
//    newCachedThreadPool (08-05) y por eso crea hilos sin limite.
BlockingQueue<Tarea> traspaso = new SynchronousQueue<>();

// 4. PRIORITYBLOCKINGQUEUE: sale primero el mas "pequeno" segun el
//    Comparator (05-09). Los avisos mas vencidos, primero.
BlockingQueue<Prestamo> porUrgencia = new PriorityBlockingQueue<>(
        100, Comparator.comparingLong(Prestamo::diasDeRetraso).reversed());

// 5. DELAYQUEUE: los elementos implementan Delayed y no se pueden
//    sacar hasta que expire su retardo. Reintentos con espera.
DelayQueue<ReintentoAviso> reintentos = new DelayQueue<>();

La ArrayBlockingQueue acotada merece un párrafo, porque resuelve un problema real de 08-05. Con una cola ilimitada, un productor rápido acumula tareas hasta agotar la memoria. Con una acotada, cuando la cola se llena el productor se bloquea en put() y deja de producir hasta que haya hueco. El sistema se autorregula sin descartar nada y sin crecer sin límite. Es el mismo efecto que CallerRunsPolicy conseguía en un pool.

  1. El patrón productor-consumidor

Prometido en el módulo 5, descrito conceptualmente y remitido aquí. Ahora, completo.

La idea: unos hilos (productores) generan trabajo y lo ponen en una cola; otros (consumidores) lo sacan y lo procesan. La cola desacopla ambos: no necesitan conocerse, ni ir al mismo ritmo, ni coordinarse.

sequenceDiagram
    participant P1 as productor-1
    participant P2 as productor-2
    participant Q as BlockingQueue(10)
    participant C1 as consumidor-1
    participant C2 as consumidor-2

    P1->>Q: put(reserva A)
    P2->>Q: put(reserva B)
    C1->>Q: take() -> A
    Note over C1: procesa A
    C2->>Q: take() -> B
    Note over C2: procesa B
    C1->>Q: take()
    Note over C1,Q: cola vacia: C1 BLOQUEADO<br/>sin consumir CPU
    P1->>Q: put(reserva C)
    Q-->>C1: despierta con C
    Note over P1,Q: si la cola se llena,<br/>los productores se bloquean<br/>en put(): CONTRAPRESION

Implementación completa para BiblioTech:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Reserva;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Procesador de reservas con el patron productor-consumidor.
 *
 * Los empleados producen reservas desde el menu (varios hilos);
 * un grupo de consumidores las atiende (comprobar disponibilidad,
 * notificar, registrar).
 *
 * La cola ACOTADA da contrapresion: si las reservas llegan mas rapido
 * de lo que se atienden, el menu se frena en lugar de acumular
 * reservas en memoria hasta agotarla.
 */
public class ProcesadorReservas implements AutoCloseable {

    private static final Logger LOG = Logger.getLogger(ProcesadorReservas.class.getName());

    private static final int CAPACIDAD = 50;
    private static final int CONSUMIDORES = 4;

    private final BlockingQueue<Reserva> cola = new ArrayBlockingQueue<>(CAPACIDAD);
    private final ExecutorService consumidores;

    private final AtomicInteger atendidas = new AtomicInteger();
    private final AtomicInteger rechazadas = new AtomicInteger();
    private volatile boolean aceptandoNuevas = true;

    public ProcesadorReservas() {
        AtomicInteger n = new AtomicInteger(1);
        this.consumidores = Executors.newFixedThreadPool(CONSUMIDORES,
                r -> new Thread(r, "bibliotech-reservas-" + n.getAndIncrement()));

        for (int i = 0; i < CONSUMIDORES; i++) {
            consumidores.execute(this::bucleConsumidor);
        }
    }

    // ---------- PRODUCTOR ----------

    /**
     * Encola una reserva. BLOQUEA si la cola esta llena: es la
     * contrapresion, y es una caracteristica, no un defecto.
     */
    public void encolar(Reserva r) throws InterruptedException {
        if (!aceptandoNuevas) {
            throw new IllegalStateException("El procesador se esta apagando");
        }
        cola.put(r);      // espera si esta llena
    }

    /**
     * Variante que no espera indefinidamente: si en 2 segundos no hay
     * hueco, rechaza. Es lo apropiado para una interfaz de usuario:
     * mejor decir "el sistema esta saturado" que dejar el menu colgado.
     */
    public boolean encolarConPlazo(Reserva r) throws InterruptedException {
        boolean aceptada = cola.offer(r, 2, TimeUnit.SECONDS);
        if (!aceptada) {
            rechazadas.incrementAndGet();
            LOG.log(Level.WARNING, "Reserva rechazada por saturacion: {0}", r.id());
        }
        return aceptada;
    }

    // ---------- CONSUMIDOR ----------

    private void bucleConsumidor() {
        String yo = Thread.currentThread().getName();
        LOG.log(Level.INFO, "[{0}] consumidor listo", yo);
        try {
            while (true) {
                // take() BLOQUEA sin consumir CPU si la cola esta vacia.
                // Es la alternativa correcta al sondeo con sleep (08-03).
                Reserva r = cola.take();

                // PILDORA VENENOSA: senal de apagado (apartado 12).
                if (r == Reserva.FIN) {
                    LOG.log(Level.INFO, "[{0}] pildora recibida, termino", yo);
                    return;
                }

                try {
                    atender(r);
                    atendidas.incrementAndGet();
                } catch (Exception e) {
                    // Un fallo individual NO debe matar al consumidor:
                    // si muere, el pool pierde capacidad silenciosamente.
                    LOG.log(Level.WARNING, "[" + yo + "] reserva fallida: " + r.id(), e);
                }
            }
        } catch (InterruptedException e) {
            LOG.log(Level.INFO, "[{0}] consumidor interrumpido", yo);
            Thread.currentThread().interrupt();     // restaurar (08-02)
        }
    }

    private void atender(Reserva r) throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(120);           // E/S simulada
        if (r.id().hashCode() % 23 == 0) {
            throw new IllegalStateException("material ya prestado");
        }
    }

    // ---------- APAGADO ----------

    /**
     * Apagado ORDENADO: deja de aceptar, inserta una pildora por
     * consumidor y espera. Todo lo que ya estaba en la cola se procesa.
     */
    @Override
    public void close() {
        aceptandoNuevas = false;
        try {
            for (int i = 0; i < CONSUMIDORES; i++) {
                cola.put(Reserva.FIN);       // una por consumidor
            }
            consumidores.shutdown();
            if (!consumidores.awaitTermination(30, TimeUnit.SECONDS)) {
                consumidores.shutdownNow();
            }
        } catch (InterruptedException e) {
            consumidores.shutdownNow();
            Thread.currentThread().interrupt();
        }
        LOG.log(Level.INFO, "Procesador cerrado: {0} atendidas, {1} rechazadas",
                new Object[] { atendidas.get(), rechazadas.get() });
    }

    public int atendidas()  { return atendidas.get(); }
    public int rechazadas() { return rechazadas.get(); }
    public int pendientes() { return cola.size(); }
}

Uso:

public class DemostracionReservas {

    public static void main(String[] args) throws InterruptedException {

        try (ProcesadorReservas procesador = new ProcesadorReservas()) {

            // Tres productores: tres empleados usando el menu a la vez.
            Thread[] productores = new Thread[3];
            for (int p = 0; p < 3; p++) {
                final String empleado = switch (p) {
                    case 0 -> "Marta Ruiz";
                    case 1 -> "Diego Alonso";
                    default -> "Nuria Vidal";
                };
                productores[p] = new Thread(() -> {
                    try {
                        for (int i = 1; i <= 40; i++) {
                            procesador.encolar(new Reserva(
                                    empleado + "-R" + i, "978-000000000" + (i % 3 + 1)));
                            TimeUnit.MILLISECONDS.sleep(20);
                        }
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }, "menu-" + empleado.split(" ")[0]);
                productores[p].start();
            }

            // Progreso mientras trabajan.
            for (int t = 0; t < 10; t++) {
                System.out.printf("  atendidas=%d  pendientes=%d%n",
                        procesador.atendidas(), procesador.pendientes());
                TimeUnit.MILLISECONDS.sleep(400);
            }

            for (Thread p : productores) p.join();
            System.out.println("Todos los productores han terminado");

        }   // close(): pildoras venenosas y espera al vaciado
    }
}

Salida:

  atendidas=0  pendientes=3
  atendidas=12  pendientes=14
  atendidas=25  pendientes=26
  atendidas=38  pendientes=39
  atendidas=51  pendientes=50   <-- cola LLENA: los productores se frenan
  atendidas=64  pendientes=50
  atendidas=77  pendientes=43
  atendidas=90  pendientes=30
  atendidas=103  pendientes=17
  atendidas=113  pendientes=7
Todos los productores han terminado
INFO: Procesador cerrado: 120 atendidas, 0 rechazadas

La línea clave es donde pendientes se estanca en 50. La cola llegó a su capacidad y los productores empezaron a bloquearse en put(): dejaron de producir al ritmo que querían y pasaron a producir al ritmo que el sistema podía absorber. Eso es contrapresión, y es la propiedad que impide que un sistema se hunda bajo carga. Con una cola ilimitada, pendientes habría seguido creciendo hasta agotar la memoria.

  1. La píldora venenosa

¿Cómo se le dice a un consumidor bloqueado en take() que ya no habrá más trabajo? Hay dos formas, y una es mejor.

Opción A: interrumpir. Funciona —take() lanza InterruptedException— pero es brusca: si el consumidor estaba a mitad de procesar un elemento, ese trabajo se pierde.

Opción B: la píldora venenosa (poison pill). Se inserta en la cola un elemento centinela que significa "se acabó". El consumidor lo reconoce y termina ordenadamente, después de haber procesado todo lo que había delante.

package com.nexussoftware.bibliotech.dominio;

public record Reserva(String id, String isbn) {

    /**
     * PILDORA VENENOSA: instancia centinela que significa
     * "no habra mas trabajo, termina".
     *
     * Se compara con == (identidad), no con equals: es un objeto
     * unico e irrepetible, y ningun dato real puede coincidir con el.
     */
    public static final Reserva FIN = new Reserva("__FIN__", "__FIN__");
}

Las tres reglas de la píldora venenosa:

1. Una píldora por consumidor. Cada consumidor consume una y termina; si insertas una sola con cuatro consumidores, tres se quedan esperando para siempre.

for (int i = 0; i < CONSUMIDORES; i++) {
    cola.put(Reserva.FIN);
}

2. La píldora va al final. Como la cola es FIFO, todo lo insertado antes se procesa antes. El apagado es ordenado por construcción: no se pierde ni un elemento.

3. Comparar con ==, no con equals. La píldora es una instancia única; comparar por identidad es más rápido y no puede confundirse con un dato real que casualmente sea igual.

Cuidado con la variante de "reinyectar la píldora", que se ve a veces:

// Alternativa: una sola pildora que cada consumidor reinyecta.
if (r == Reserva.FIN) {
    cola.put(Reserva.FIN);   // pasarla al siguiente
    return;
}

Es ingeniosa y peligrosa con una cola acotada: si la cola está llena, ese put() se bloquea y el consumidor no termina nunca. Con offer() en su lugar podrías perder la píldora. Una píldora por consumidor es más simple y siempre correcto.

Comparación:

Píldora venenosa Interrupción
Trabajo pendiente en la cola Se procesa Se pierde
Trabajo en curso Termina Se interrumpe
Requiere un valor centinela No
Funciona si un consumidor está atascado No
Cuándo usarla Apagado ordenado Apagado urgente

En la práctica se usan las dos: píldora primero, y shutdownNow() como plan B si no terminan en el plazo. Es el patrón de dos fases de 08-05, aplicado aquí.

  1. ConcurrentSkipListMap en una nota

ConcurrentSkipListMap y ConcurrentSkipListSet son las versiones concurrentes y ordenadas de TreeMap y TreeSet. Implementan NavigableMap, así que ofrecen firstKey, headMap, tailMap, ceilingKey y compañía, y son seguras para varios hilos sin candados.

import java.util.concurrent.ConcurrentSkipListMap;
import java.util.NavigableMap;

// Prestamos ordenados por fecha de vencimiento (en milisegundos).
NavigableMap<Long, Prestamo> porVencimiento = new ConcurrentSkipListMap<>();

porVencimiento.put(vencimientoMs, prestamo);

// Todos los vencidos antes de ahora, en orden, sin bloquear a nadie.
NavigableMap<Long, Prestamo> vencidos =
        porVencimiento.headMap(System.currentTimeMillis(), true);

Están implementadas con listas de saltos (skip lists), una estructura probabilística que da O(log n) sin necesidad de reequilibrar como un árbol —lo que sería muy costoso de hacer concurrentemente—. Úsalas cuando necesites orden y concurrencia; si solo necesitas concurrencia, ConcurrentHashMap es más rápido.

  1. Variables atómicas y compare-and-swap

Segunda mitad de la lección. Las clases de java.util.concurrent.atomic resuelven el problema del contador de 08-01 sin candados.

import java.util.concurrent.atomic.AtomicInteger;

public class ContadorAtomico {

    private final AtomicInteger valor = new AtomicInteger(0);

    public void incrementar() {
        valor.incrementAndGet();     // ATOMICO, sin candado
    }

    public int valor() { return valor.get(); }

    public static void main(String[] args) throws InterruptedException {
        final int VUELTAS = 1_000_000;
        ContadorAtomico c = new ContadorAtomico();

        Thread h1 = new Thread(() -> { for (int i = 0; i < VUELTAS; i++) c.incrementar(); });
        Thread h2 = new Thread(() -> { for (int i = 0; i < VUELTAS; i++) c.incrementar(); });
        h1.start(); h2.start(); h1.join(); h2.join();

        System.out.println("Esperado: " + VUELTAS * 2 + ", real: " + c.valor());
    }
}
Esperado: 2000000, real: 2000000

Exacto, siempre. ¿Cómo, sin candado?

La instrucción compare-and-swap

Los procesadores modernos ofrecen una instrucción atómica llamada CAS (compare-and-swap, o CMPXCHG en x86). Su semántica, ejecutada por el hardware de forma indivisible:

«Mira esta posición de memoria. Si contiene el valor esperado, sustitúyelo por nuevo y dime que sí. Si no, no toques nada y dime que no.»

Es la operación que sostiene toda la concurrencia sin candados.

AtomicInteger a = new AtomicInteger(10);

// "Si vale 10, ponlo a 11". Devuelve true si lo hizo.
boolean exito = a.compareAndSet(10, 11);

El bucle de reintento que hay debajo

incrementAndGet() no es magia: es un bucle CAS. Su implementación, conceptualmente:

/**
 * Lo que hace incrementAndGet() por dentro, simplificado.
 * Es el patron BUCLE CAS, la base de toda la programacion sin candados.
 */
public int incrementAndGet() {
    while (true) {
        int actual = get();                      // 1. leer
        int siguiente = actual + 1;              // 2. calcular
        if (compareAndSet(actual, siguiente)) {  // 3. intentar escribir
            return siguiente;                    //    exito: salir
        }
        // Fallo: otro hilo cambio el valor entre 1 y 3.
        // No se ha perdido nada: se vuelve a leer y se reintenta.
    }
}

La diferencia esencial con un candado:

  • Un candado dice: "nadie más toque esto mientras trabajo". Los demás esperan bloqueados.
  • El CAS dice: "lo intento; si alguien se me adelantó, lo vuelvo a intentar". Nadie espera bloqueado; siempre hay alguien progresando.

Esta última propiedad se llama libertad de bloqueo (lock-free): en cualquier momento, al menos un hilo avanza. No puede haber interbloqueo, porque no hay nada que retener.

sequenceDiagram
    participant A as hilo-A
    participant M as AtomicInteger
    participant B as hilo-B

    Note over M: valor = 10
    A->>M: get() -> 10
    B->>M: get() -> 10
    A->>M: compareAndSet(10, 11)
    Note over M: coincide: valor = 11, devuelve true
    B->>M: compareAndSet(10, 11)
    Note over M: NO coincide (vale 11): devuelve false
    Note over B: reintenta
    B->>M: get() -> 11
    B->>M: compareAndSet(11, 12)
    Note over M: coincide: valor = 12
    Note over A,B: Dos incrementos, valor = 12.<br/>NINGUNO perdido.

Compara este diagrama con el de 08-04: allí los dos hilos escribían 11 y se perdía un incremento. Aquí el segundo hilo detecta que alguien se le adelantó y reintenta. Esa detección es lo que hace el CAS.

Coste: bajo contención extrema, un bucle CAS puede reintentar muchas veces y desperdiciar CPU. Con contención moderada, es más rápido que un candado porque no hay cambios de contexto ni suspensión de hilos.

  1. Las operaciones de las clases atómicas

Las clases principales: AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference<V>, más los arrays AtomicIntegerArray, AtomicLongArray y AtomicReferenceArray.

Método Qué hace Devuelve
get() / set(v) Leer / escribir (como volatile) valor / void
incrementAndGet() ++v El valor nuevo
getAndIncrement() v++ El valor anterior
decrementAndGet() / getAndDecrement() --v / v-- nuevo / anterior
addAndGet(d) / getAndAdd(d) Sumar d nuevo / anterior
getAndSet(v) Escribir y devolver lo que había anterior
compareAndSet(esp, nuevo) CAS boolean
updateAndGet(f) Aplicar la función f atómicamente nuevo
getAndUpdate(f) Ídem anterior
accumulateAndGet(x, f) Combinar el valor actual con x mediante f nuevo
package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.atomic.*;

/**
 * Estadisticas de BiblioTech con contadores atomicos.
 * Sin un solo candado, y con lecturas que no bloquean nada.
 */
public class EstadisticasBiblioTech {

    private final AtomicLong prestamosTotales   = new AtomicLong();
    private final AtomicLong devolucionesTotales = new AtomicLong();
    private final AtomicLong multasRecaudadasCentimos = new AtomicLong();
    private final AtomicInteger prestamosActivos = new AtomicInteger();
    private final AtomicBoolean modoMantenimiento = new AtomicBoolean(false);
    private final AtomicLong maximoSimultaneos = new AtomicLong();

    public void registrarPrestamo() {
        prestamosTotales.incrementAndGet();
        int activos = prestamosActivos.incrementAndGet();

        // MAXIMO HISTORICO con accumulateAndGet: combina el valor
        // actual con 'activos' usando Math::max, atomicamente.
        // Escribirlo con get()+set() seria una carrera clasica.
        maximoSimultaneos.accumulateAndGet(activos, Math::max);
    }

    public void registrarDevolucion(long multaCentimos) {
        devolucionesTotales.incrementAndGet();
        prestamosActivos.decrementAndGet();
        if (multaCentimos > 0) {
            multasRecaudadasCentimos.addAndGet(multaCentimos);
        }
    }

    /**
     * Aplica un descuento del 10% al total recaudado.
     * updateAndGet aplica la funcion ATOMICAMENTE, con un bucle CAS
     * por debajo: si otro hilo modifica el valor entre la lectura y la
     * escritura, la funcion se REEJECUTA con el valor nuevo.
     *
     * IMPORTANTE: la funcion debe ser PURA y rapida, porque puede
     * ejecutarse varias veces. Nada de efectos secundarios.
     */
    public long aplicarDescuento() {
        return multasRecaudadasCentimos.updateAndGet(v -> (long) (v * 0.9));
    }

    /**
     * Entrar en mantenimiento SOLO SI no estabamos ya en el.
     * compareAndSet garantiza que, aunque diez hilos lo pidan a la vez,
     * exactamente UNO reciba true y ejecute la preparacion.
     * Es el idioma "solo una vez" sin candados.
     */
    public boolean entrarEnMantenimiento() {
        if (modoMantenimiento.compareAndSet(false, true)) {
            prepararMantenimiento();       // solo un hilo llega aqui
            return true;
        }
        return false;                      // otro se nos adelanto
    }

    public String resumen() {
        return String.format(
                "prestamos=%d devoluciones=%d activos=%d maximo=%d multas=%.2f EUR",
                prestamosTotales.get(), devolucionesTotales.get(),
                prestamosActivos.get(), maximoSimultaneos.get(),
                multasRecaudadasCentimos.get() / 100.0);
    }

    private void prepararMantenimiento() { /* ... */ }
}

Nota sobre resumen(): los seis valores se leen en instantes distintos, así que el conjunto no es una instantánea coherente. Puede mostrar prestamos=100 y activos=3 de dos momentos distintos. Para métricas es perfectamente aceptable; si necesitaras coherencia entre todos los contadores, harían falta un candado o el patrón de estado inmutable con AtomicReference del apartado siguiente.

  1. AtomicReference y el problema ABA

AtomicReference<V> aplica el CAS a una referencia a objeto. Combinado con la inmutabilidad de 08-04, da un patrón muy potente: actualizar un estado completo de forma atómica y sin candados.

package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.atomic.AtomicReference;

public class EstadoBiblioTech {

    /** Estado completo, inmutable (record de 04-07). */
    public record Estado(long prestamos, long devoluciones,
                         long multasCentimos, boolean mantenimiento) {

        Estado conPrestamo() {
            return new Estado(prestamos + 1, devoluciones, multasCentimos, mantenimiento);
        }
        Estado conDevolucion(long multa) {
            return new Estado(prestamos, devoluciones + 1,
                    multasCentimos + multa, mantenimiento);
        }
    }

    private final AtomicReference<Estado> estado =
            new AtomicReference<>(new Estado(0, 0, 0, false));

    /**
     * Lectura sin ningun bloqueo y SIEMPRE COHERENTE: los cuatro
     * campos vienen del mismo instante, porque son un solo objeto
     * inmutable. Es la ventaja sobre cuatro contadores separados.
     */
    public Estado instantanea() {
        return estado.get();
    }

    /** Actualizacion atomica de los cuatro campos a la vez. */
    public void registrarPrestamo() {
        estado.updateAndGet(Estado::conPrestamo);
    }

    public void registrarDevolucion(long multaCentimos) {
        estado.updateAndGet(e -> e.conDevolucion(multaCentimos));
    }
}

Esta combinación —estado inmutable + AtomicReference + updateAndGet— es una de las técnicas más elegantes de la concurrencia en Java: lecturas gratuitas y siempre coherentes, escrituras atómicas sin candados, y ninguna posibilidad de interbloqueo.

El problema ABA

Hay un caso patológico del CAS que conviene conocer.

El CAS comprueba que el valor sea el esperado, no que no haya cambiado. Si un valor pasa de A a B y vuelve a A, un CAS que esperaba A tendrá éxito, aunque entre medias haya pasado algo importante.

Hilo 1: lee A ................................. CAS(A -> C): EXITO
Hilo 2:      lee A, CAS(A->B), CAS(B->A)
                                                 ^
        El hilo 1 no se entera de que hubo dos cambios.
        Si su decision dependia de que NADA hubiera pasado, es un bug.

Con contadores de enteros esto es inocuo: si el contador vuelve a valer 10, vale 10 y punto. El problema aparece con referencias en estructuras enlazadas: un nodo puede ser retirado, reciclado y reinsertado, y un CAS sobre su referencia tendría éxito sobre un nodo que ya no es el mismo lógicamente.

La solución: añadir un sello o marca que siempre cambie.

import java.util.concurrent.atomic.AtomicStampedReference;

// Cada modificacion incrementa el SELLO, aunque el valor vuelva a ser
// el mismo. El CAS comprueba valor Y sello, asi que A-B-A se detecta.
AtomicStampedReference<Nodo> cabeza =
        new AtomicStampedReference<>(nodoInicial, 0);

int[] selloActual = new int[1];
Nodo actual = cabeza.get(selloActual);

cabeza.compareAndSet(actual, nuevoNodo,
                     selloActual[0], selloActual[0] + 1);   // sello + 1

// Variante mas simple con un solo bit booleano:
// AtomicMarkableReference<Nodo>

En la práctica, ABA solo aparece si implementas estructuras de datos sin candados a mano. Si usas ConcurrentHashMap y AtomicInteger, la biblioteca ya se ha ocupado. Conócelo para saber que existe y para entender por qué AtomicStampedReference está ahí.

  1. LongAdder bajo contención

AtomicLong es excelente con contención moderada. Bajo contención extrema —dieciséis hilos incrementando el mismo contador sin parar— su bucle CAS empieza a fallar mucho: los hilos reintentan una y otra vez y, además, todos escriben sobre la misma línea de caché, lo que provoca invalidaciones constantes entre núcleos (cache line bouncing).

LongAdder (Java 8) resuelve esto con una idea sencilla: mantiene varias celdas internas y cada hilo incrementa la suya. Solo al llamar a sum() se suman todas.

import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.*;

public class ComparativaContadores {

    static long medirAtomicLong(int hilos, int ops) throws InterruptedException {
        AtomicLong c = new AtomicLong();
        return medir(hilos, ops, c::incrementAndGet, c::get);
    }

    static long medirLongAdder(int hilos, int ops) throws InterruptedException {
        LongAdder c = new LongAdder();
        return medir(hilos, ops, c::increment, c::sum);
    }

    static long medir(int hilos, int ops, Runnable incremento,
                      java.util.function.LongSupplier lectura) throws InterruptedException {
        CountDownLatch salida = new CountDownLatch(1);
        CountDownLatch meta = new CountDownLatch(hilos);
        for (int h = 0; h < hilos; h++) {
            new Thread(() -> {
                try {
                    salida.await();
                    for (int i = 0; i < ops; i++) incremento.run();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally { meta.countDown(); }
            }, "contador-" + h).start();
        }
        long inicio = System.nanoTime();
        salida.countDown();
        meta.await();
        long ns = System.nanoTime() - inicio;
        // Verificacion: los dos deben dar el resultado exacto.
        if (lectura.getAsLong() != (long) hilos * ops) {
            throw new AssertionError("resultado incorrecto");
        }
        return ns / 1_000_000;
    }

    public static void main(String[] args) throws InterruptedException {
        final int OPS = 2_000_000;
        System.out.printf("%-8s | %14s | %14s | %8s%n",
                "Hilos", "AtomicLong ms", "LongAdder ms", "Mejora");
        System.out.println("---------|----------------|----------------|---------");
        for (int hilos : new int[] { 1, 2, 4, 8, 16 }) {
            long a = medirAtomicLong(hilos, OPS / hilos);
            long l = medirLongAdder(hilos, OPS / hilos);
            System.out.printf("%-8d | %14d | %14d | %7.1fx%n",
                    hilos, a, l, (double) a / Math.max(l, 1));
        }
    }
}

Salida orientativa:

Hilos    |  AtomicLong ms |   LongAdder ms |   Mejora
---------|----------------|----------------|---------
1        |             12 |             18 |     0.7x
2        |             41 |             21 |     2.0x
4        |             96 |             19 |     5.1x
8        |            213 |             22 |     9.7x
16       |            487 |             26 |    18.7x

Lo que enseña esta tabla:

  • Con un hilo, AtomicLong gana. LongAdder tiene más maquinaria interna y no la amortiza sin contención.
  • La ventaja crece con los hilos. A 16 hilos, casi 19×.
  • AtomicLong escala mal: pasar de 1 a 16 hilos multiplica el tiempo por 40, aunque el trabajo total sea el mismo. Es contención pura.
AtomicLong LongAdder
Escritura bajo contención Se degrada Excelente
Lectura (get/sum) O(1) exacta O(nº de celdas), aproximada si hay escrituras concurrentes
Memoria 1 valor Varias celdas (crece con la contención)
Soporta compareAndSet No
Usar para Contadores con poca contención; cuando necesitas CAS Métricas y estadísticas de alta frecuencia

La regla: si solo cuentas y lees el total de vez en cuando —métricas, contadores de peticiones, estadísticas—, LongAdder. Si necesitas el valor exacto en cada operación o usar compareAndSet, AtomicLong. DoubleAdder, LongAccumulator y DoubleAccumulator completan la familia; los Accumulator permiten una función de combinación arbitraria.

  1. BiblioTech: catálogo concurrente y estadísticas atómicas

La versión final del catálogo, sin un solo lock() explícito:

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Material;

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

/**
 * Catalogo concurrente de BiblioTech.
 *
 * Frente a la version con ReadWriteLock de 08-04:
 *  - No hay ningun candado explicito que soltar en un finally.
 *  - Las lecturas no bloquean NADA, ni siquiera a otras lecturas.
 *  - Las escrituras en cubetas distintas ocurren en paralelo.
 *  - Las operaciones compuestas se resuelven con los metodos atomicos
 *    del propio mapa, no encapsulando secciones criticas a mano.
 *
 * LIMITE HONESTO: un invariante que abarque VARIAS estructuras
 * (como el de RegistroPrestamos entre porId y porEmpleado) sigue
 * necesitando un candado. Las colecciones concurrentes garantizan
 * la atomicidad de UNA operacion sobre UNA coleccion, no de dos.
 */
public class CatalogoConcurrente {

    /** Indice principal por ISBN. Lecturas sin bloqueo. */
    private final ConcurrentMap<String, Material> porIsbn = new ConcurrentHashMap<>();

    /** Materiales agrupados por tipo. El valor es una lista concurrente. */
    private final ConcurrentMap<TipoMaterial, List<Material>> porTipo =
            new ConcurrentHashMap<>();

    /** Oyentes de eventos: muchas lecturas, casi ninguna escritura. */
    private final List<OyenteCatalogo> oyentes = new CopyOnWriteArrayList<>();

    // --- Estadisticas: LongAdder por su alta frecuencia de escritura ---
    private final LongAdder consultas = new LongAdder();
    private final LongAdder aciertos  = new LongAdder();
    private final LongAdder altas     = new LongAdder();
    private final AtomicLong ultimaModificacionMs = new AtomicLong();

    // ---------- ESCRITURA ----------

    /**
     * Anade un material si su ISBN no estaba.
     * putIfAbsent es ATOMICO: aunque diez hilos anadan el mismo ISBN
     * a la vez, exactamente uno recibe true. Es el comprobar-luego-actuar
     * resuelto sin candados.
     */
    public boolean anadir(Material m) {
        if (porIsbn.putIfAbsent(m.isbn(), m) != null) {
            return false;                       // ya existia
        }
        // computeIfAbsent crea la lista solo si no existe, atomicamente.
        // CopyOnWriteArrayList porque estas listas se recorren mucho
        // mas de lo que se modifican.
        porTipo.computeIfAbsent(m.tipo(), t -> new CopyOnWriteArrayList<>()).add(m);

        altas.increment();
        ultimaModificacionMs.set(System.currentTimeMillis());
        notificarAlta(m);
        return true;
    }

    public boolean eliminar(String isbn) {
        Material m = porIsbn.remove(isbn);
        if (m == null) return false;

        List<Material> lista = porTipo.get(m.tipo());
        if (lista != null) lista.remove(m);

        ultimaModificacionMs.set(System.currentTimeMillis());
        return true;
    }

    // ---------- LECTURA ----------

    /** Sin candados. Con 100 hilos consultando, ninguno espera a otro. */
    public Material buscarPorIsbn(String isbn) {
        consultas.increment();
        Material m = porIsbn.get(isbn);
        if (m != null) aciertos.increment();
        return m;
    }

    /**
     * Devuelve la lista de un tipo. Como es CopyOnWriteArrayList,
     * el llamante puede iterarla con total seguridad aunque otro hilo
     * la modifique: su iterador trabaja sobre una instantanea inmutable.
     * No hace falta copiar defensivamente, a diferencia de 08-04.
     */
    public List<Material> porTipo(TipoMaterial tipo) {
        return porTipo.getOrDefault(tipo, List.of());
    }

    /**
     * Recorrido completo del catalogo.
     * El iterador es DEBILMENTE CONSISTENTE: no lanza
     * ConcurrentModificationException y no bloquea a los escritores.
     */
    public void recorrer(java.util.function.Consumer<Material> accion) {
        for (Material m : porIsbn.values()) {
            accion.accept(m);
        }
    }

    /** OJO: aproximado (apartado 7). Vale para metricas, no para control. */
    public int tamanoAproximado() {
        return porIsbn.size();
    }

    // ---------- OYENTES ----------

    public void registrarOyente(OyenteCatalogo o) { oyentes.add(o); }

    private void notificarAlta(Material m) {
        // Sin candado durante la notificacion: la regla 3 de 08-04
        // ("nunca llames a codigo ajeno con un candado") se cumple
        // automaticamente gracias a CopyOnWriteArrayList.
        for (OyenteCatalogo o : oyentes) {
            try {
                o.materialAnadido(m);
            } catch (RuntimeException e) {
                LOG.log(Level.WARNING, "Oyente fallido", e);
            }
        }
    }

    // ---------- METRICAS ----------

    public String metricas() {
        long c = consultas.sum();
        long a = aciertos.sum();
        return String.format("consultas=%d aciertos=%d tasa=%.1f%% altas=%d materiales~%d",
                c, a, c == 0 ? 0.0 : 100.0 * a / c, altas.sum(), porIsbn.size());
    }
}

Prueba de esfuerzo:

public class PruebaCatalogoConcurrente {

    public static void main(String[] args) throws InterruptedException {

        final int HILOS = 16;
        final int OPS = 100_000;

        CatalogoConcurrente catalogo = new CatalogoConcurrente();
        CountDownLatch salida = new CountDownLatch(1);
        CountDownLatch meta   = new CountDownLatch(HILOS);

        for (int h = 0; h < HILOS; h++) {
            final int id = h;
            new Thread(() -> {
                try {
                    salida.await();
                    ThreadLocalRandom azar = ThreadLocalRandom.current();
                    for (int i = 0; i < OPS; i++) {
                        if (azar.nextInt(100) < 90) {
                            catalogo.buscarPorIsbn("978-" + azar.nextInt(1000));
                        } else {
                            catalogo.anadir(new Libro(
                                    "978-" + azar.nextInt(1000), "Titulo", "Autor"));
                        }
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally { meta.countDown(); }
            }, "catalogo-" + id).start();
        }

        long inicio = System.nanoTime();
        salida.countDown();
        meta.await();
        long ms = (System.nanoTime() - inicio) / 1_000_000;

        System.out.println("Operaciones : " + (HILOS * OPS));
        System.out.println("Tiempo      : " + ms + " ms");
        System.out.println("Throughput  : " + (HILOS * OPS / Math.max(ms, 1)) + " ops/ms");
        System.out.println(catalogo.metricas());
    }
}

Salida orientativa:

Operaciones : 1600000
Tiempo      : 312 ms
Throughput  : 5128 ops/ms
Metricas    : consultas=1439871 aciertos=1438204 tasa=99.9% altas=1000 materiales~1000

Un millón seiscientas mil operaciones con dieciséis hilos en 312 ms, sin un solo lock(). Y el detalle que valida el diseño: altas=1000 con 1000 ISBN posibles. Aunque dieciséis hilos intentaron añadir los mismos ISBN repetidamente, exactamente mil altas tuvieron éxito: el putIfAbsent cumplió su contrato bajo máxima contención.

  1. Tabla final de decisión

Necesitas… Usa Por qué
Un contador de alta frecuencia LongAdder Escala bajo contención
Un contador con valor exacto en cada operación AtomicInteger/AtomicLong CAS, exacto, sin candados
Una bandera con "solo una vez" AtomicBoolean.compareAndSet Exactamente un ganador
Un estado de varios campos, coherente al leer AtomicReference + record Instantánea inmutable y atómica
Una bandera simple de parada volatile boolean Lo más barato que garantiza visibilidad
Un mapa compartido ConcurrentHashMap Lecturas sin bloqueo, compuestas atómicas
Un mapa compartido y ordenado ConcurrentSkipListMap Navegable y concurrente
Una lista de oyentes o configuración CopyOnWriteArrayList Iteración inmune y sin bloqueo
Una lista grande con escrituras frecuentes synchronizedList o un candado La copia-al-escribir sería O(n²)
Traspasar trabajo entre hilos BlockingQueue take/put sin sondeo, con contrapresión
Una cola sin espera ConcurrentLinkedQueue No bloqueante, pero obliga a sondear
Un invariante entre varias estructuras synchronized o Lock Las colecciones concurrentes no lo cubren
Muchas lecturas y pocas escrituras sobre estado propio ReadWriteLock Lectores en paralelo
Datos que no cambian Objeto inmutable (record) Cero sincronización, imposible corromper
Datos usados por un solo hilo Variable local / ThreadLocal La mejor opción: no compartir

El orden en que hay que plantearse las opciones, de mejor a peor:

  1. No compartir (local, confinado).
  2. Compartir inmutable (record).
  3. Atómico (AtomicX, LongAdder).
  4. Colección concurrente (ConcurrentHashMap, BlockingQueue).
  5. Candado (synchronized, Lock, ReadWriteLock).

Errores Comunes y Consejos

Error 1: compartir un HashMap entre hilos. No da resultados "aproximados": pierde entradas y puede corromperse hasta provocar un bucle infinito con un núcleo al 100 %.

Error 2: creer que Collections.synchronizedMap hace seguro tu código. Cada método lo es; iterar y las operaciones compuestas, no. Es el error más frecuente de la primera generación.

Error 3: iterar una colección sincronizada sin sincronizar la iteración. ConcurrentModificationException. Y si sincronizas, bloqueas a todos durante todo el recorrido.

Error 4: hacer get + put sobre un ConcurrentHashMap. El mapa es seguro; tu secuencia de dos llamadas no. Usa merge, compute, computeIfAbsent o putIfAbsent.

Error 5: usar size() de una colección concurrente para tomar decisiones. Es aproximado por diseño. Vale para métricas, no para control.

Error 6: hacer trabajo pesado o bloqueante dentro de computeIfAbsent. Se ejecuta con la cubeta bloqueada. Calcula fuera y publica con putIfAbsent.

Error 7: modificar el mismo mapa dentro de la función de compute. Puede interbloquear o corromper la estructura. Prohibido.

Error 8: usar CopyOnWriteArrayList para acumular en un bucle. Cada add copia el array: O(n²) total. Es para muchas lecturas y casi ninguna escritura.

Error 9: sondear una ConcurrentLinkedQueue con poll() + sleep(). Quema CPU y añade latencia. Usa una BlockingQueue y take().

Error 10: usar una BlockingQueue ilimitada como cola de trabajo. Sin cota no hay contrapresión, y el productor rápido acaba agotando la memoria.

Error 11: insertar una sola píldora venenosa con varios consumidores. Los demás esperan para siempre. Una por consumidor.

Error 12: pasar una función con efectos secundarios a updateAndGet. Puede ejecutarse varias veces por el bucle CAS. Debe ser pura y rápida.

Error 13: creer que las colecciones concurrentes eliminan la necesidad de candados. Garantizan la atomicidad de una operación sobre una colección. Un invariante entre dos estructuras sigue necesitando un candado.

Consejo 1: ConcurrentHashMap por defecto para cualquier mapa compartido. Es más rápido, más seguro y con mejor API que las alternativas. No hay motivo para no usarlo.

Consejo 2: aprende merge y computeIfAbsent de memoria. Resuelven el 90 % de los casos de comprobar-luego-actuar en una línea legible y atómica.

Consejo 3: acota tus colas. La cota es la diferencia entre un sistema que se degrada con elegancia y uno que se cae.

Consejo 4: LongAdder para métricas, AtomicLong para lógica. Si solo cuentas, LongAdder. Si necesitas el valor exacto en cada paso o compareAndSet, AtomicLong.

Consejo 5: AtomicReference + record es tu mejor herramienta para estado compartido de varios campos. Lecturas gratuitas y coherentes, escrituras atómicas, imposible interbloquear.

Consejo 6: la píldora venenosa da un apagado ordenado; la interrupción, uno urgente. Usa la primera y guarda la segunda como plan B con plazo.

Ejercicios

Ejercicio 1: Las tres generaciones, medidas

Escribe TresGeneraciones que compare HashMap sin protección, Collections.synchronizedMap y ConcurrentHashMap bajo la misma carga: 12 hilos, 100.000 operaciones cada uno, 80 % lecturas y 20 % escrituras sobre un espacio de 5.000 claves. Para cada implementación mide el tiempo, comprueba si el número final de entradas es el esperado, y captura cualquier excepción. Usa un CountDownLatch de puerta de salida y un plazo por si el HashMap entra en bucle infinito. Explica los tres resultados.

Ejercicio 2: Productor-consumidor completo con contrapresión

Implementa PipelineImportacion, un procesamiento en dos etapas para BiblioTech:

  • Etapa 1 (2 hilos productores): leen "líneas" de un catálogo simulado y las ponen en una ArrayBlockingQueue<String> de capacidad 20.
  • Etapa 2 (4 hilos consumidores): sacan líneas, las convierten en Material (con un sleep de 30 ms) y las añaden a un ConcurrentHashMap.

Requisitos: contadores con LongAdder para líneas leídas, materiales creados y líneas descartadas; un hilo monitor que imprima cada 300 ms el tamaño de la cola y los contadores, demostrando que la cola se llena y frena a los productores; apagado con píldora venenosa (una por consumidor); y verificación final de que no se ha perdido ni una línea.

Ejercicio 3: Contador de estadísticas, cuatro implementaciones

Escribe ComparativaEstadisticas que implemente el mismo contador de préstamos de cuatro formas: (a) long con synchronized, (b) AtomicLong, (c) LongAdder, y (d) AtomicReference<Estado> con un record inmutable de tres campos actualizado con updateAndGet. Somete cada una a 16 hilos × 500.000 incrementos, verifica que todas dan el resultado exacto, y mide el tiempo. Añade una segunda medición con un solo hilo para mostrar la inversión de resultados. Comenta qué implementación elegirías para métricas de alta frecuencia y cuál para un estado de negocio que debe leerse de forma coherente.

Soluciones

Solución al Ejercicio 1

import java.util.*;
import java.util.concurrent.*;

public class TresGeneraciones {

    static final int HILOS = 12;
    static final int OPS = 100_000;
    static final int CLAVES = 5_000;

    record Resultado(String nombre, long ms, int entradas, String incidencia) { }

    static Resultado medir(String nombre, Map<Integer, String> mapa)
            throws InterruptedException {

        CountDownLatch salida = new CountDownLatch(1);
        CountDownLatch meta   = new CountDownLatch(HILOS);
        // volatile no basta para acumular texto desde varios hilos:
        // usamos una cola concurrente para recoger incidencias.
        Queue<String> incidencias = new ConcurrentLinkedQueue<>();

        for (int h = 0; h < HILOS; h++) {
            Thread t = new Thread(() -> {
                try {
                    salida.await();
                    ThreadLocalRandom azar = ThreadLocalRandom.current();
                    for (int i = 0; i < OPS; i++) {
                        int clave = azar.nextInt(CLAVES);
                        if (azar.nextInt(100) < 80) {
                            mapa.get(clave);
                        } else {
                            mapa.put(clave, "v" + i);
                        }
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } catch (RuntimeException e) {
                    incidencias.add(e.getClass().getSimpleName());
                } finally {
                    meta.countDown();
                }
            }, nombre + "-" + h);
            t.setDaemon(true);      // demonio: si entra en bucle infinito,
            t.start();              // no impedira que la JVM termine
        }

        long inicio = System.nanoTime();
        salida.countDown();

        // Plazo: el HashMap sin proteccion puede no terminar NUNCA.
        boolean completado = meta.await(20, TimeUnit.SECONDS);
        long ms = (System.nanoTime() - inicio) / 1_000_000;

        String inc = completado
                ? (incidencias.isEmpty() ? "-" : incidencias.peek())
                : "NO TERMINO (bucle infinito o corrupcion)";

        int entradas;
        try {
            entradas = mapa.size();
        } catch (RuntimeException e) {
            entradas = -1;
        }
        return new Resultado(nombre, ms, entradas, inc);
    }

    public static void main(String[] args) throws InterruptedException {

        System.out.printf("%d hilos x %d ops (80%% lectura) sobre %d claves%n%n",
                HILOS, OPS, CLAVES);

        List<Resultado> resultados = new ArrayList<>();
        resultados.add(medir("HashMap", new HashMap<>()));
        resultados.add(medir("synchronizedMap", Collections.synchronizedMap(new HashMap<>())));
        resultados.add(medir("ConcurrentHashMap", new ConcurrentHashMap<>()));

        System.out.printf("%-20s | %8s | %10s | %-40s%n",
                "Implementacion", "ms", "entradas", "incidencia");
        System.out.println("---------------------|----------|------------|"
                + "------------------------------------------");
        for (Resultado r : resultados) {
            System.out.printf("%-20s | %8d | %10d | %-40s%n",
                    r.nombre(), r.ms(), r.entradas(), r.incidencia());
        }

        System.out.println();
        System.out.println("Esperado: " + CLAVES + " entradas (todas las claves tocadas)");
    }
}

Salida orientativa:

12 hilos x 100000 ops (80% lectura) sobre 5000 claves

Implementacion       |       ms |   entradas | incidencia
---------------------|----------|------------|------------------------------------------
HashMap              |    20003 |       4211 | NO TERMINO (bucle infinito o corrupcion)
synchronizedMap      |     1876 |       5000 | -
ConcurrentHashMap    |      147 |       5000 | -

Esperado: 5000 entradas (todas las claves tocadas)

Los tres resultados:

  1. HashMap no terminó en 20 segundos y su size() dice 4211 en lugar de 5000: se han perdido entradas y al menos un hilo se quedó en un bucle. Marcarlos como demonio fue esencial para que el programa pudiera acabar.
  2. synchronizedMap es correcto pero lento: 1876 ms, con doce hilos serializados por un único candado, incluidas las 80 % de lecturas que no se estorbarían entre sí.
  3. ConcurrentHashMap es correcto y 12× más rápido que el sincronizado: las lecturas no bloquean nada y las escrituras solo compiten cuando caen en la misma cubeta.

Solución al Ejercicio 2

package com.nexussoftware.bibliotech.persistencia;

import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;

public class PipelineImportacion {

    private static final String PILDORA = "__FIN__";
    private static final int CAPACIDAD = 20;
    private static final int PRODUCTORES = 2;
    private static final int CONSUMIDORES = 4;
    private static final int LINEAS_POR_PRODUCTOR = 150;

    // Cola ACOTADA: si los consumidores no dan abasto, los productores
    // se bloquean en put(). Contrapresion.
    private final BlockingQueue<String> cola = new ArrayBlockingQueue<>(CAPACIDAD);

    private final ConcurrentMap<String, String> materiales = new ConcurrentHashMap<>();

    // LongAdder: escrituras muy frecuentes, lecturas ocasionales.
    private final LongAdder leidas     = new LongAdder();
    private final LongAdder creados    = new LongAdder();
    private final LongAdder descartadas = new LongAdder();

    private volatile boolean enMarcha = true;

    // ---------- ETAPA 1: PRODUCTORES ----------

    private void producir(int idProductor) {
        try {
            for (int i = 0; i < LINEAS_POR_PRODUCTOR; i++) {
                String linea = "978-" + idProductor + String.format("%05d", i)
                        + ";Titulo " + i + ";Autor";
                cola.put(linea);      // BLOQUEA si la cola esta llena
                leidas.increment();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    // ---------- ETAPA 2: CONSUMIDORES ----------

    private void consumir() {
        String yo = Thread.currentThread().getName();
        try {
            while (true) {
                String linea = cola.take();      // BLOQUEA si esta vacia

                // Pildora venenosa: comparacion por IDENTIDAD.
                if (linea == PILDORA) {
                    System.out.println("  [" + yo + "] pildora recibida, termino");
                    return;
                }
                try {
                    TimeUnit.MILLISECONDS.sleep(30);      // conversion "cara"
                    String[] campos = linea.split(";");
                    if (campos.length < 3) {
                        throw new IllegalArgumentException("linea incompleta");
                    }
                    materiales.put(campos[0], campos[1]);
                    creados.increment();
                } catch (IllegalArgumentException e) {
                    descartadas.increment();               // degradar (06-07)
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    // ---------- ORQUESTACION ----------

    public void ejecutar() throws InterruptedException {

        ExecutorService productores = Executors.newFixedThreadPool(PRODUCTORES,
                new NombradorHilos("pipeline-productor"));
        ExecutorService consumidores = Executors.newFixedThreadPool(CONSUMIDORES,
                new NombradorHilos("pipeline-consumidor"));

        for (int c = 0; c < CONSUMIDORES; c++) consumidores.execute(this::consumir);

        CountDownLatch produccionTerminada = new CountDownLatch(PRODUCTORES);
        for (int p = 0; p < PRODUCTORES; p++) {
            final int id = p;
            productores.execute(() -> {
                try { producir(id); }
                finally { produccionTerminada.countDown(); }   // SIEMPRE
            });
        }

        // Monitor: demuestra que la cola se llena y frena a los productores.
        Thread monitor = new Thread(() -> {
            try {
                while (enMarcha) {
                    System.out.printf("      [monitor] cola=%2d/%d  leidas=%d  creados=%d%n",
                            cola.size(), CAPACIDAD, leidas.sum(), creados.sum());
                    TimeUnit.MILLISECONDS.sleep(300);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "pipeline-monitor");
        monitor.setDaemon(true);
        monitor.start();

        // 1. Esperar a que los productores acaben.
        produccionTerminada.await();
        System.out.println("  Produccion terminada; inyectando pildoras");

        // 2. UNA pildora POR CONSUMIDOR, al final de la cola:
        //    todo lo anterior se procesa antes.
        for (int c = 0; c < CONSUMIDORES; c++) cola.put(PILDORA);

        // 3. Apagado ordenado en dos fases.
        productores.shutdown();
        consumidores.shutdown();
        boolean ok = consumidores.awaitTermination(30, TimeUnit.SECONDS);
        if (!ok) consumidores.shutdownNow();

        enMarcha = false;
        monitor.interrupt();

        // 4. Verificacion.
        long total = LINEAS_POR_PRODUCTOR * PRODUCTORES;
        System.out.println();
        System.out.println("=== RESULTADO ===");
        System.out.println("Lineas producidas : " + leidas.sum() + " (esperado " + total + ")");
        System.out.println("Materiales creados: " + creados.sum());
        System.out.println("Descartadas       : " + descartadas.sum());
        System.out.println("En el mapa        : " + materiales.size());
        System.out.println("Cola al final     : " + cola.size() + " (debe ser 0)");
        System.out.println("Sin perdidas      : "
                + (creados.sum() + descartadas.sum() == total));
    }

    static class NombradorHilos implements ThreadFactory {
        private final String prefijo;
        private int n = 1;
        NombradorHilos(String prefijo) { this.prefijo = prefijo; }
        @Override public synchronized Thread newThread(Runnable r) {
            return new Thread(r, prefijo + "-" + n++);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new PipelineImportacion().ejecutar();
    }
}

Salida (fragmento):

      [monitor] cola=20/20  leidas= 42  creados= 22
      [monitor] cola=20/20  leidas= 82  creados= 62
      [monitor] cola=20/20  leidas=122  creados=102
      [monitor] cola=20/20  leidas=162  creados=142
      ...
  Produccion terminada; inyectando pildoras
  [pipeline-consumidor-2] pildora recibida, termino
  [pipeline-consumidor-1] pildora recibida, termino
  [pipeline-consumidor-4] pildora recibida, termino
  [pipeline-consumidor-3] pildora recibida, termino

=== RESULTADO ===
Lineas producidas : 300 (esperado 300)
Materiales creados: 300
Descartadas       : 0
En el mapa        : 300
Cola al final     : 0 (debe ser 0)
Sin perdidas      : true

Tres cosas que demuestra la salida:

  1. cola=20/20 de forma sostenida: la cola está permanentemente llena, así que los productores pasan la mayor parte del tiempo bloqueados en put(). Producen al ritmo de los consumidores, no al suyo. Con una cola ilimitada, cola habría crecido hasta 300 y toda la memoria intermedia se habría reservado de golpe.
  2. Cola al final: 0 y Sin perdidas: true: la píldora venenosa, al ir al final de una cola FIFO, garantiza que todo el trabajo anterior se procesa antes del apagado. Ni una línea perdida.
  3. Los cuatro consumidores terminan, uno por píldora. Con una sola píldora, tres se habrían quedado bloqueados en take() para siempre y awaitTermination habría expirado.

Solución al Ejercicio 3

import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class ComparativaEstadisticas {

    interface Contador {
        void registrarPrestamo();
        long total();
        String nombre();
    }

    /** (a) long protegido con synchronized. */
    static class ConSynchronized implements Contador {
        private long prestamos = 0;
        public synchronized void registrarPrestamo() { prestamos++; }
        public synchronized long total() { return prestamos; }
        public String nombre() { return "synchronized"; }
    }

    /** (b) AtomicLong: bucle CAS por debajo. */
    static class ConAtomicLong implements Contador {
        private final AtomicLong prestamos = new AtomicLong();
        public void registrarPrestamo() { prestamos.incrementAndGet(); }
        public long total() { return prestamos.get(); }
        public String nombre() { return "AtomicLong"; }
    }

    /** (c) LongAdder: celdas separadas, suma al leer. */
    static class ConLongAdder implements Contador {
        private final LongAdder prestamos = new LongAdder();
        public void registrarPrestamo() { prestamos.increment(); }
        public long total() { return prestamos.sum(); }
        public String nombre() { return "LongAdder"; }
    }

    /** (d) AtomicReference sobre un record inmutable de TRES campos. */
    static class ConAtomicReference implements Contador {

        record Estado(long prestamos, long devoluciones, long multas) {
            Estado conPrestamo() {
                return new Estado(prestamos + 1, devoluciones, multas);
            }
        }

        private final AtomicReference<Estado> estado =
                new AtomicReference<>(new Estado(0, 0, 0));

        public void registrarPrestamo() {
            // updateAndGet reintenta si otro hilo se adelanto.
            // La funcion debe ser PURA: puede ejecutarse varias veces.
            estado.updateAndGet(Estado::conPrestamo);
        }

        public long total() { return estado.get().prestamos(); }

        /** VENTAJA UNICA: instantanea COHERENTE de los tres campos. */
        public Estado instantanea() { return estado.get(); }

        public String nombre() { return "AtomicReference+record"; }
    }

    static long medir(Contador c, int hilos, int porHilo) throws InterruptedException {
        CountDownLatch salida = new CountDownLatch(1);
        CountDownLatch meta   = new CountDownLatch(hilos);

        for (int h = 0; h < hilos; h++) {
            new Thread(() -> {
                try {
                    salida.await();
                    for (int i = 0; i < porHilo; i++) c.registrarPrestamo();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally { meta.countDown(); }
            }, "est-" + h).start();
        }

        long inicio = System.nanoTime();
        salida.countDown();
        meta.await();
        long ms = (System.nanoTime() - inicio) / 1_000_000;

        long esperado = (long) hilos * porHilo;
        if (c.total() != esperado) {
            throw new AssertionError(c.nombre() + " INCORRECTO: "
                    + c.total() + " != " + esperado);
        }
        return ms;
    }

    static Contador nueva(int tipo) {
        return switch (tipo) {
            case 0 -> new ConSynchronized();
            case 1 -> new ConAtomicLong();
            case 2 -> new ConLongAdder();
            default -> new ConAtomicReference();
        };
    }

    public static void main(String[] args) throws InterruptedException {

        final int TOTAL = 8_000_000;

        // Calentamiento (JIT).
        for (int t = 0; t < 4; t++) medir(nueva(t), 4, 50_000);

        for (int hilos : new int[] { 1, 16 }) {
            System.out.printf("%n=== %d hilo(s), %d incrementos en total ===%n",
                    hilos, TOTAL);
            System.out.printf("%-26s | %8s | %14s%n", "Implementacion", "ms", "inc/ms");
            System.out.println("---------------------------|----------|---------------");

            for (int t = 0; t < 4; t++) {
                Contador c = nueva(t);
                long ms = medir(c, hilos, TOTAL / hilos);
                System.out.printf("%-26s | %8d | %14d%n",
                        c.nombre(), ms, TOTAL / Math.max(ms, 1));
            }
        }

        // Demostracion de la ventaja unica de AtomicReference.
        ConAtomicReference ar = new ConAtomicReference();
        medir(ar, 8, 100_000);
        ConAtomicReference.Estado foto = ar.instantanea();
        System.out.printf("%nInstantanea COHERENTE: prestamos=%d devoluciones=%d multas=%d%n",
                foto.prestamos(), foto.devoluciones(), foto.multas());
        System.out.println("Los tres campos vienen del MISMO instante, cosa que");
        System.out.println("tres contadores independientes no pueden garantizar.");
    }
}

Salida orientativa:

=== 1 hilo(s), 8000000 incrementos en total ===
Implementacion             |       ms |         inc/ms
---------------------------|----------|---------------
synchronized               |       61 |         131147
AtomicLong                 |       48 |         166666
LongAdder                  |       72 |         111111
AtomicReference+record     |      284 |          28169

=== 16 hilo(s), 8000000 incrementos en total ===
Implementacion             |       ms |         inc/ms
---------------------------|----------|---------------
synchronized               |     1842 |           4343
AtomicLong                 |      918 |           8714
LongAdder                  |       94 |          85106
AtomicReference+record     |     1531 |           5225

Instantanea COHERENTE: prestamos=800000 devoluciones=0 multas=0
Los tres campos vienen del MISMO instante, cosa que
tres contadores independientes no pueden garantizar.

Análisis completo:

  • Con un hilo, AtomicLong gana y LongAdder pierde. Sin contención, las celdas múltiples de LongAdder son maquinaria que no se amortiza. Confirma que el mejor contador depende de la contención, no hay uno absoluto.
  • Con 16 hilos, LongAdder es 20× más rápido que AtomicLong y casi 20× más que synchronized. Es exactamente el caso para el que fue diseñado.
  • AtomicReference+record es el más lento en ambos casos, y tiene sentido: cada incremento crea un objeto nuevo y su bucle CAS reintenta bajo contención. Pero mira la última línea de la salida: es el único que puede dar una instantánea coherente de los tres campos. Los otros tres, con tres contadores separados, darían valores de instantes distintos.
  • synchronized escala peor que todos: pasa de 61 ms a 1842 ms, un factor de 30, con el mismo trabajo total. Es contención de candado pura, con cambios de contexto y suspensión de hilos.

Qué elegir: para métricas de alta frecuencia —consultas al catálogo, préstamos por segundo—, LongAdder sin dudarlo. Para un estado de negocio que debe leerse de forma coherente —el resumen que se muestra al usuario o se persiste—, AtomicReference con un record inmutable, aceptando su mayor coste de escritura a cambio de lecturas gratuitas y siempre consistentes.

Conclusión

Has cerrado las dos grietas que quedaban abiertas y has saldado la deuda del módulo 5.

Sabes exactamente qué significa que HashMap no sea seguro para varios hilos. No es que dé resultados aproximados: pierde entradas y puede corromper su estructura hasta el punto de que un get() entre en un bucle infinito y deje un núcleo al 100 % sin lanzar ninguna excepción. Y conoces la versión concurrente del fail-fast de 05-02, con el matiz que casi nunca se dice: la ConcurrentModificationException no está garantizada, porque modCount no es volatile, así que la alternativa a la excepción no es que todo vaya bien, sino que la carrera pase desapercibida.

Conoces las tres generaciones de solución. Las colecciones sincronizadas de Collections, que arreglan la corrupción con un candado global —serializando incluso las lecturas— y traen consigo la doble trampa: iterar sigue exigiendo sincronización manual del recorrido completo, y las operaciones compuestas —comprobar-luego-actuar y leer-modificar-escribir— siguen sin ser atómicas, exactamente igual que en 08-04. Y las colecciones concurrentes, con ConcurrentHashMap a la cabeza: lecturas sin ningún candado gracias a los campos volatile de sus nodos, escrituras bloqueadas solo en la cubeta afectada, y un orden de magnitud de diferencia en un escenario de dieciséis hilos.

Tienes la aportación más valiosa de ConcurrentHashMap: las operaciones atómicas compuestasputIfAbsent, computeIfAbsent, compute, merge, getOrDefault, remove(k,v), replace(k,v1,v2)— que son la forma correcta de resolver comprobar-luego-actuar, con la advertencia crítica de que la función se ejecuta con la cubeta bloqueada: nada de trabajo largo, nada de tocar el mismo mapa, nada de código ajeno. Y conoces los dos contratos que cambian: los iteradores débilmente consistentes, que no lanzan excepción y no bloquean a nadie a cambio de no garantizar que vean los cambios posteriores, y el size() aproximado, que es un dato estadístico y nunca una base para decidir.

Sabes cuándo usar CopyOnWriteArrayList —oyentes de eventos, configuración, reglas: muchísimas lecturas y casi ninguna escritura— y por qué usarla para acumular en un bucle es O(n²). Sabes que ConcurrentLinkedQueue no bloquea pero obliga a sondear, y que eso casi siempre es la respuesta equivocada.

Y tienes las BlockingQueue completas, prometidas desde el módulo 5: sus cuatro grupos de operaciones —excepción, valor especial, bloqueante, con plazo— y sus seis implementaciones, con ArrayBlockingQueue acotada como opción por defecto porque la cota es lo que da contrapresión: cuando la cola se llena, el productor se bloquea en put() y pasa a producir al ritmo que el sistema puede absorber, en lugar de acumular hasta agotar la memoria. Y con ellas, el patrón productor-consumidor completo que el módulo 5 describió y dejó sin escribir, con take() que espera sin quemar CPU, con los fallos individuales que no matan al consumidor, y con la píldora venenosa y sus tres reglas: una por consumidor, al final de la cola —de modo que el apagado es ordenado por construcción y no se pierde ni un elemento—, y comparada por identidad.

Y conoces la programación sin candados. La instrucción compare-and-swap del procesador —«si contiene lo esperado, sustitúyelo y dime que sí; si no, no toques nada»— y el bucle de reintento que hay debajo de incrementAndGet. Con la diferencia esencial que la define: un candado dice "nadie más toque esto" y los demás esperan bloqueados; un CAS dice "lo intento y, si alguien se me adelantó, lo repito", y siempre hay alguien progresando, lo que hace imposible el interbloqueo. Dominas las operaciones de AtomicInteger, AtomicLong, AtomicBoolean y AtomicReference —incluidos updateAndGet y accumulateAndGet, cuya función debe ser pura porque puede ejecutarse varias veces—, el idioma compareAndSet(false, true) que garantiza exactamente un ganador, el problema ABA y su solución con AtomicStampedReference, y LongAdder, que a dieciséis hilos supera a AtomicLong en un factor de veinte y a un solo hilo pierde: la prueba de que el mejor contador depende de la contención.

Sobre todo, tienes el patrón que combina lo mejor de dos lecciones: estado inmutable con record + AtomicReference + updateAndGet, que da lecturas gratuitas, siempre coherentes entre todos los campos, y escrituras atómicas sin ningún candado. Es la técnica más elegante de toda la concurrencia en Java.

BiblioTech ya no tiene un solo lock() en su catálogo. CatalogoConcurrente usa ConcurrentHashMap para sus índices, CopyOnWriteArrayList para sus oyentes —lo que cumple automáticamente la regla de 08-04 de no llamar a código ajeno con un candado en la mano—, y LongAdder para sus métricas. Un millón seiscientas mil operaciones con dieciséis hilos en trescientos milisegundos, con altas=1000 exactas sobre mil ISBN posibles: el putIfAbsent cumpliendo su contrato bajo máxima contención. Y ProcesadorReservas implementa el productor-consumidor real, con contrapresión visible en la salida —la cola estancada en su capacidad, frenando a los productores— y apagado ordenado sin perder una sola reserva.

Con el límite declarado con honestidad: las colecciones concurrentes garantizan la atomicidad de una operación sobre una colección, no de dos. El invariante de RegistroPrestamos entre porId y porEmpleado sigue necesitando el candado de 08-04, y eso no es un defecto de la biblioteca: es la frontera real de lo que se puede resolver sin exclusión mutua.

Y tienes la tabla de decisión que ordena todo el módulo, con su jerarquía: no compartir, compartir inmutable, atómico, colección concurrente, candado — en ese orden, bajando solo cuando el nivel anterior no llega.

Pero fíjate en lo que sigue faltando. BiblioTech ya hace cosas en paralelo, pero cada vez que necesita el resultado de algo, alguien se queda esperando: future.get() bloquea. Si quisieras encadenar tres pasos —consultar el catálogo, calcular las multas y exportar el informe—, tendrías que hacer get() entre cada uno, y el hilo que orquesta pasaría casi todo su tiempo parado. Future te dice "aquí llegará un resultado", pero la única forma de usarlo es preguntar y esperar. No hay manera de decir "cuando esté listo, haz esto otro", ni de combinar dos resultados independientes, ni de definir qué hacer si algo falla en mitad de la cadena.

En la próxima lección, Tareas Asíncronas con CompletableFuture, se cierra el módulo con la respuesta a eso. Verás las cuatro limitaciones de Future que motivaron su sucesor; el modelo de composición asíncrona, en el que declaras la cadena entera de antemano y ningún hilo espera a nadie; la creación con supplyAsync y runAsync y por qué conviene pasar tu propio Executor en lugar de usar el pool común; la diferencia entre thenApply y thenCompose —con el CompletableFuture<CompletableFuture<T>> que aparece cuando te equivocas—; la combinación con thenCombine, allOf y anyOf; el manejo de errores con exceptionally, handle y whenComplete, con la CompletionException que envuelve la causa; los plazos de orTimeout y completeOnTimeout; y las trampas que hacen que una cadena asíncrona mal escrita sea peor que el código bloqueante que sustituye. Al terminar, BiblioTech tendrá una cadena que consulta, calcula y exporta sin bloquear el menú en ningún momento, y el módulo quedará cerrado.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados