Esta es la lección central del módulo. Todo lo anterior fue preparación: sabes qué es un hilo, sabes crearlo, cancelarlo y observar en qué estado está. Ahora toca resolver el problema que da sentido a todo el tema, y que sigue abierto desde 08-01: dos hilos que suman uno a un contador dos millones de veces y obtienen un millón trescientos mil.

La lección tiene dos mitades. La primera es teoría que no puedes saltarte: por qué contador++ no es una operación sino tres, qué es exactamente una operación atómica, y —lo más profundo del módulo— el modelo de memoria de Java, que explica por qué un hilo puede escribir un valor y otro no verlo nunca, aunque pasen minutos. Esa parte es incómoda porque contradice la intuición de que la memoria es un sitio donde se escribe y se lee; pero sin ella, volatile y synchronized son magia que se aplica por superstición.

La segunda mitad es la caja de herramientas: volatile para visibilidad, synchronized para exclusión mutua, ReentrantLock cuando synchronized no llega, ReadWriteLock cuando se lee mucho más de lo que se escribe, y las dos estrategias que ganan a todas: inmutabilidad y confinamiento. Entre medias, el interbloqueo reproducible y sus soluciones.

Al terminar, Catalogo y RegistroPrestamos serán seguros para varios hilos, y el caso C de 08-01 —Marta Ruiz y Diego Alonso registrando préstamos a la vez— dejará de ser una amenaza.

Cómo estudiar esta lección. Es larga y densa a propósito: es la que sostiene el resto del módulo y buena parte de tu carrera escribiendo servicios. Ejecuta los ejemplos que fallan. Especialmente el del apartado 5, la bandera de parada que sin volatile no se ve nunca: la primera vez que lo veas colgarse entenderás el modelo de memoria mejor que con diez páginas de texto.

Contenido

  1. La condición de carrera al nivel del bytecode
  2. Qué es una operación atómica
  3. El modelo de memoria de Java
  4. La relación happens-before
  5. volatile: lo que garantiza
  6. volatile: lo que NO garantiza
  7. synchronized: el monitor intrínseco
  8. Las formas de synchronized
  9. Qué sincronizar y qué no
  10. Interbloqueo: el ejemplo reproducible
  11. Las soluciones al interbloqueo
  12. Inanición y livelock
  13. ReentrantLock frente a synchronized
  14. Condition: sustituto de wait/notify
  15. ReadWriteLock: leer mucho, escribir poco
  16. Publicación segura e inmutabilidad
  17. Confinamiento: la mejor sincronización es no compartir
  18. BiblioTech: un catálogo seguro para varios hilos
  19. Errores Comunes y Consejos
  20. Ejercicios

  1. La condición de carrera al nivel del bytecode

contador++ parece una operación. No lo es. Compílalo y míralo:

public class Contador {
    private int valor = 0;
    public void incrementar() {
        valor++;
    }
}
javac Contador.java
javap -c Contador
public void incrementar();
  Code:
     0: aload_0            // apilar la referencia 'this'
     1: dup                // duplicarla (hace falta dos veces)
     2: getfield #7        // LEER  el campo 'valor'  <-- operacion 1
     5: iconst_1           // apilar la constante 1
     6: iadd               // SUMAR                    <-- operacion 2
     7: putfield #7        // ESCRIBIR el campo 'valor'<-- operacion 3
    10: return

Tres operaciones separadas: leer, sumar, escribir. Entre cualquiera de ellas el planificador puede quitar el hilo y poner otro. Ese hueco es la condición de carrera.

El entrelazado que pierde un incremento, paso a paso, partiendo de valor = 10:

sequenceDiagram
    participant A as sumador-1
    participant M as memoria (valor)
    participant B as sumador-2

    Note over M: valor = 10
    A->>M: getfield -> lee 10
    Note over A: registro A = 10
    B->>M: getfield -> lee 10
    Note over B: registro B = 10
    Note over A: iadd -> A = 11
    Note over B: iadd -> B = 11
    A->>M: putfield 11
    Note over M: valor = 11
    B->>M: putfield 11
    Note over M: valor = 11 (¡otra vez!)
    Note over A,B: Dos incrementos, valor solo subio 1.<br/>Un incremento PERDIDO.

Los dos hilos leyeron 10, los dos calcularon 11, los dos escribieron 11. Se han ejecutado dos incrementos y el contador ha subido uno. Repite esto un millón de veces con entrelazados aleatorios y tienes exactamente la salida de 08-01: cientos de miles de incrementos perdidos.

Este patrón tiene nombre: leer-modificar-escribir (read-modify-write). Aparece en muchas más formas de las que parece, y todas son inseguras sin protección:

valor++;                     // leer, sumar, escribir
valor--;                     // idem
valor += 5;                  // idem
saldo = saldo - importe;     // idem
if (!mapa.containsKey(k)) {  // COMPROBAR...
    mapa.put(k, v);          // ...LUEGO ACTUAR: otro hilo puede colarse en medio
}
if (instancia == null) {     // el singleton perezoso clasico
    instancia = new Servicio();
}
lista.add(lista.size(), x);  // leer tamano, luego usarlo

Las dos últimas familias —comprobar-luego-actuar y leer-modificar-escribir— son las dos formas canónicas de condición de carrera. Si ves cualquiera de ellas sobre estado compartido, hay un bug.

  1. Qué es una operación atómica

Una operación es atómica si, desde el punto de vista de los demás hilos, ocurre entera o no ocurre: no hay estado intermedio observable.

En Java son atómicas:

  • La lectura y la escritura de cualquier variable de tipo primitivo salvo long y double, y de cualquier referencia.
  • La lectura y la escritura de un long o double declarado volatile.
  • Las operaciones de las clases de java.util.concurrent.atomic (08-06).

No son atómicas:

  • valor++, valor--, valor += n (son tres operaciones).
  • Cualquier secuencia de dos o más operaciones que deba verse como una sola.
  • La lectura o escritura de un long/double no volatile.

El caso curioso de long y double. La especificación permite que la JVM implemente la escritura de un valor de 64 bits como dos escrituras de 32 bits. En una JVM de 32 bits eso ocurría de verdad, y producía un fenómeno inquietante: un hilo podía leer un long cuya mitad alta viniera de una escritura y la mitad baja de otra, obteniendo un valor que ningún hilo escribió nunca. Se llamaba word tearing.

public class LongPartido {
    // Sin volatile, en una JVM de 32 bits, un lector podia ver
    // 0x00000000FFFFFFFF: mitad de un valor y mitad de otro.
    private long contadorPrestamos = 0;

    // Con volatile, la escritura de 64 bits es atomica por especificacion.
    private volatile long contadorSeguro = 0;
}

En las JVM de 64 bits actuales esto ya no ocurre en la práctica, pero la especificación lo sigue permitiendo, así que el código correcto no depende de ello: si compartes un long o un double, declaralo volatile o protégelo.

El punto que hay que llevarse: la atomicidad de una operación individual no basta. Aunque cada lectura y cada escritura de valor sean atómicas, valor++ sigue siendo incorrecto porque son tres operaciones atómicas separadas. La atomicidad que necesitas es la de la operación compuesta, y esa hay que construirla.

  1. El modelo de memoria de Java

Aquí llega la parte que contradice la intuición. Hasta ahora has supuesto que la memoria es un cuaderno compartido: un hilo escribe una línea y otro la lee. Esa imagen es falsa en cualquier procesador de los últimos treinta años.

3.1 Las tres razones por las que la memoria no se comporta como esperas

Razón 1: cada núcleo tiene sus propias cachés.

Acceder a la memoria principal cuesta unos cientos de ciclos. Acceder a la caché L1 del núcleo cuesta unos pocos. Por eso cada núcleo mantiene copias locales de los datos que usa. Cuando el núcleo 1 escribe contador = 5, ese 5 puede quedarse en su caché L1 durante un tiempo indeterminado antes de llegar a un nivel visible para el núcleo 2.

flowchart TB
    subgraph CPU["Procesador"]
        direction LR
        subgraph N1["Núcleo 1 - hilo A"]
            R1["Registros"] --- L11["Caché L1<br/>contador = 5"]
        end
        subgraph N2["Núcleo 2 - hilo B"]
            R2["Registros"] --- L12["Caché L1<br/>contador = 0"]
        end
    end
    L11 --- L2["Caché L2/L3 compartida"]
    L12 --- L2
    L2 --- RAM["Memoria principal<br/>contador = 0"]

En ese dibujo, el hilo A ha escrito 5 y el hilo B sigue leyendo 0. Los dos están en lo cierto según su propia caché. Y no hay ninguna garantía temporal de cuándo —ni de si— B verá el 5.

Razón 2: el compilador reordena instrucciones.

Tanto javac como, sobre todo, el compilador JIT de la JVM reordenan libremente el código mientras el resultado sea el mismo para un solo hilo. Esa cláusula es la clave: la garantía se llama as-if-serial y solo aplica dentro de un hilo.

// Lo que escribes:
datos = cargarCatalogo();     // (1)
listo = true;                 // (2)

// Lo que el compilador puede generar, porque para ESTE hilo
// el resultado es indistinguible:
listo = true;                 // (2)
datos = cargarCatalogo();     // (1)

Para otro hilo que haga if (listo) usar(datos), esa reordenación es catastrófica: puede ver listo == true con datos == null.

Razón 3: la CPU también reordena.

Los procesadores modernos ejecutan fuera de orden y tienen buffers de escritura. Aunque el compilador emita las instrucciones en orden, el hardware puede hacerlas efectivas en otro. En x86 el modelo es relativamente fuerte; en ARM —tu móvil, muchos servidores— es mucho más débil y las reordenaciones se observan con facilidad.

3.2 La consecuencia: dos hilos pueden ver historias distintas

Sin sincronización, no hay ninguna garantía de que un hilo vea las escrituras de otro, ni en el mismo orden, ni nunca. No es que tarde: puede no verlas jamás.

Este programa lo demuestra, y es el ejemplo más impactante del módulo:

public class BanderaSinVolatile {

    // SIN volatile. Ahi esta el problema.
    private static boolean detener = false;

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

        Thread trabajador = new Thread(() -> {
            long vueltas = 0;
            // El JIT puede transformar esto en 'while (true)' porque
            // dentro de ESTE hilo 'detener' nunca cambia: es una
            // optimizacion legal llamada elevacion (hoisting).
            while (!detener) {
                vueltas++;
            }
            System.out.println("[trabajador] parado tras " + vueltas + " vueltas");
        }, "bibliotech-trabajador");

        trabajador.start();

        Thread.sleep(1000);
        System.out.println("[main] escribiendo detener = true");
        detener = true;

        trabajador.join(3000);
        if (trabajador.isAlive()) {
            System.out.println("[main] EL TRABAJADOR NO SE HA ENTERADO. Sigue vivo.");
            System.exit(1);
        }
    }
}

Salida típica con el JIT en marcha (compila con javac y ejecuta normalmente, sin depurador):

[main] escribiendo detener = true
[main] EL TRABAJADOR NO SE HA ENTERADO. Sigue vivo.

El hilo trabajador no para nunca. main escribió true y el trabajador siguió leyendo false indefinidamente. No es un retraso de microsegundos: es para siempre.

Por qué ocurre: el compilador JIT ve que dentro del bucle nadie modifica detener, así que saca la lectura fuera del bucle —optimización estándar y perfectamente legal según la garantía as-if-serial— y lo convierte en el equivalente de:

boolean copia = detener;      // se lee UNA vez
while (!copia) { vueltas++; } // bucle infinito

Si al ejecutarlo sí para, prueba con más tiempo de calentamiento (el JIT tarda unos miles de iteraciones en compilar), o añade -XX:+PrintCompilation para ver cuándo compila el método. En un depurador o con -Xint (solo intérprete) casi siempre para, porque el intérprete no aplica esa optimización. Ese es precisamente el peligro: el bug desaparece bajo el depurador.

Cambia boolean detener por volatile boolean detener y ejecuta de nuevo:

[main] escribiendo detener = true
[trabajador] parado tras 1847239104 vueltas

Para inmediatamente. Esa palabra clave es la diferencia entre un programa correcto y uno que se cuelga.

  1. La relación happens-before

El modelo de memoria de Java (JSR 133, incorporado en Java 5) no describe cachés ni reordenaciones: define una relación de orden parcial llamada happens-before entre acciones, y una única garantía:

Si la acción A happens-before la acción B, entonces los efectos de A son visibles para B, y B ve a A como ocurrida antes.

Y su contrapositiva, que es la que usarás en la práctica:

Si dos acciones no están relacionadas por happens-before y al menos una escribe, hay una carrera de datos y no hay ninguna garantía sobre lo que se observa.

Las reglas que establecen happens-before —memorízalas, son la caja de herramientas entera:

Regla Establece que…
Orden del programa Dentro de un mismo hilo, cada acción happens-before las siguientes en el código
Monitor Soltar un monitor happens-before cualquier adquisición posterior del mismo monitor
volatile Escribir una variable volatile happens-before cualquier lectura posterior de esa misma variable
Thread.start() Todo lo hecho antes de start() happens-before la primera acción del hilo nuevo
Thread.join() Todo lo hecho por el hilo happens-before el retorno de join()
Campos final La inicialización de un campo final en el constructor happens-before que otro hilo vea el objeto correctamente construido
Transitividad Si A hb B y B hb C, entonces A hb C
Utilidades de j.u.c. Poner en una cola concurrente hb sacar de ella; countDown hb await; etc. (08-05, 08-06)

Aplicado a los casos que ya conoces:

// REGLA start(): main escribe, el hilo nuevo lo ve garantizado.
catalogo.cargar();                  // A
Thread h = new Thread(tarea);
h.start();                          // A happens-before todo lo del hilo nuevo
// dentro de 'tarea': ve el catalogo cargado, seguro.

// REGLA join(): el hilo escribe, main lo ve garantizado.
h.start();
h.join();                           // todo lo del hilo hb el retorno de join
System.out.println(tarea.resultado());   // valor correcto garantizado

// REGLA monitor: transferencia de visibilidad entre secciones criticas.
synchronized (candado) { x = 42; }       // soltar hb...
// ... otro hilo:
synchronized (candado) { leer(x); }      // ...adquirir: ve x == 42

// REGLA volatile + transitividad: el idioma de la "publicacion segura".
datos = cargarCatalogo();          // (1) escritura normal
listo = true;                      // (2) escritura VOLATILE
// ... otro hilo:
if (listo) {                       // (3) lectura VOLATILE
    usar(datos);                   // (4) ve datos correctamente. ¿Por que?
}
// (1) hb (2) por orden del programa;
// (2) hb (3) por la regla volatile;
// (3) hb (4) por orden del programa;
// por transitividad: (1) hb (4). El acceso a 'datos' es seguro
// AUNQUE 'datos' no sea volatile.

Ese último caso es importante y sorprende: una única variable volatile puede hacer visibles escrituras normales que la preceden. Es lo que se llama publicación segura, y es la base del patrón del apartado 16.

La forma práctica de usar todo esto, sin memorizar la especificación: cada vez que dos hilos toquen el mismo dato y al menos uno escriba, pregúntate "¿qué regla happens-before conecta esas dos acciones?". Si no sabes contestar, no hay ninguna, y tienes una carrera de datos.

  1. volatile: lo que garantiza

volatile es el mecanismo de sincronización más ligero de Java. Aporta dos garantías, ni una más:

Garantía 1: visibilidad. Una escritura volatile se hace visible inmediatamente a cualquier hilo que lea después esa variable. En la práctica, la JVM emite barreras de memoria: la escritura vacía el buffer hacia memoria compartida y la lectura invalida la copia cacheada.

Garantía 2: no reordenación. Las lecturas y escrituras volatile no se reordenan entre sí, y actúan como barrera para las que las rodean: nada que esté antes de una escritura volatile puede moverse después, y nada que esté después de una lectura volatile puede moverse antes.

Además, como efecto derivado: las lecturas y escrituras de long/double volatile son atómicas (adiós al word tearing).

El caso de uso canónico, y prácticamente el único que necesitarás: la bandera de estado escrita por un hilo y leída por otros.

package com.nexussoftware.bibliotech.persistencia;

/**
 * Servicio de importacion con parada limpia mediante bandera volatile.
 * Es el complemento de la interrupcion de 08-02: la bandera propia
 * permite una parada ordenada sin depender del estado de interrupcion.
 */
public class ServicioImportacion implements Runnable {

    // volatile: escrito por el hilo del menu, leido por el hilo trabajador.
    // SIN volatile, el trabajador podria no enterarse NUNCA (apartado 3.2).
    private volatile boolean detener = false;

    // Progreso publicado hacia el hilo del menu. Solo lo escribe ESTE hilo,
    // asi que no hay leer-modificar-escribir concurrente: volatile basta.
    private volatile int lineasProcesadas = 0;

    public void detener() {
        detener = true;
    }

    @Override
    public void run() {
        while (!detener && !Thread.currentThread().isInterrupted()) {
            procesarLote();
            lineasProcesadas += 1000;     // seguro: UN solo escritor
        }
    }

    public int lineasProcesadas() { return lineasProcesadas; }

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

Fíjate en el comentario de lineasProcesadas: += 1000 es leer-modificar-escribir y en general sería inseguro… pero solo hay un escritor. Con un único hilo escribiendo, no hay entrelazado posible entre lectura y escritura de ese hilo consigo mismo, y volatile da a los lectores la visibilidad que necesitan. Este razonamiento es válido y frecuente; lo que lo invalidaría es un segundo escritor.

Cuándo volatile es suficiente, en resumen:

  1. La escritura no depende del valor actual (o hay un único escritor).
  2. No forma parte de un invariante junto con otras variables.
  3. No hace falta bloqueo por ningún otro motivo.

Si se cumplen las tres, volatile es la opción correcta y es mucho más barata que un candado: no bloquea, no crea contención y no puede provocar interbloqueo.

  1. volatile: lo que NO garantiza

Aquí está la confusión más extendida del tema, y conviene destruirla con una demostración.

volatile NO hace atómicas las operaciones compuestas.

public class ContadorVolatileSigueRoto {

    // volatile SI garantiza que los dos hilos vean el valor mas reciente.
    // volatile NO garantiza que 'valor++' sea indivisible.
    private volatile int valor = 0;

    public void incrementar() {
        valor++;      // sigue siendo LEER, SUMAR, ESCRIBIR
    }

    public int valor() { return valor; }

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

        for (int intento = 1; intento <= 3; intento++) {
            ContadorVolatileSigueRoto c = new ContadorVolatileSigueRoto();

            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.printf("Intento %d: esperado %d, real %d, perdidos %d%n",
                    intento, VUELTAS * 2, c.valor(), VUELTAS * 2 - c.valor());
        }
    }
}

Salida:

Intento 1: esperado 2000000, real 1223981, perdidos 776019
Intento 2: esperado 2000000, real 1341102, perdidos 658898
Intento 3: esperado 2000000, real 1189447, perdidos 810553

Sigue perdiendo casi el 40 % de los incrementos. volatile resolvió la visibilidad —los dos hilos ven ahora el valor más reciente en cada lectura— pero el hueco entre leer y escribir sigue ahí, y sigue siendo la carrera del apartado 1.

Los dos ejemplos juntos son la lección completa:

Ejemplo Sin volatile Con volatile
Bandera de parada (apartado 3.2) Se cuelga: nunca ve el cambio Funciona: para inmediatamente
Contador valor++ Pierde incrementos Sigue perdiendo incrementos

Tabla resumen de garantías:

Propiedad volatile synchronized AtomicInteger (08-06)
Visibilidad
No reordenación
Atomicidad de lectura/escritura simple Sí (incl. long/double)
Atomicidad de leer-modificar-escribir No
Exclusión mutua de un bloque No No
Puede provocar interbloqueo No No
Coste Muy bajo Medio Bajo

El otro error frecuente con volatile: creer que protege el objeto al que apunta una referencia.

// 'catalogo' volatile garantiza que ves la referencia mas reciente...
private volatile List<Material> catalogo = new ArrayList<>();

// ...pero NO protege el CONTENIDO de la lista.
catalogo.add(nuevoLibro);   // sigue siendo una carrera de datos

volatile protege la variable, no el objeto. Para el contenido hacen falta candados, una colección concurrente (08-06) o inmutabilidad.

  1. synchronized: el monitor intrínseco

Cada objeto de Java tiene un monitor asociado —también llamado bloqueo intrínseco o candado—. No lo declaras: existe por el hecho de ser un objeto. synchronized es la palabra clave que lo adquiere y lo suelta.

synchronized (objeto) {
    // solo un hilo a la vez puede estar aqui
}

Semántica exacta:

  1. Al entrar, el hilo adquiere el monitor de objeto. Si otro hilo lo tiene, queda BLOCKED (08-03) hasta que se libere.
  2. Al salir —por el final del bloque, por un return, o por una excepción—, el monitor se libera. Esto último es importante: synchronized es a prueba de excepciones por construcción, a diferencia de ReentrantLock.
  3. Se establecen las relaciones happens-before de la regla del monitor: soltar hb adquirir después.

synchronized da dos cosas a la vez, y esa es su virtud:

  • Exclusión mutua: solo un hilo a la vez ejecuta el bloque.
  • Visibilidad: al entrar, el hilo ve todo lo que hizo el hilo anterior que soltó ese mismo monitor.

La segunda se olvida constantemente y es la mitad del valor. Un synchronized no solo evita que dos hilos pisen el dato: garantiza que el segundo vea lo que hizo el primero.

El contador arreglado:

public class ContadorSincronizado {

    private int valor = 0;

    // Metodo sincronizado de instancia: adquiere el monitor de 'this'.
    public synchronized void incrementar() {
        valor++;      // ahora las tres operaciones son indivisibles
                      // respecto a otros hilos que usen ESTE monitor
    }

    // TAMBIEN sincronizado. Si no lo estuviera, un lector podria ver
    // un valor obsoleto: la exclusion mutua sin visibilidad no basta.
    public synchronized int valor() {
        return valor;
    }

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

        for (int intento = 1; intento <= 3; intento++) {
            ContadorSincronizado c = new ContadorSincronizado();
            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.printf("Intento %d: esperado %d, real %d%n",
                    intento, VUELTAS * 2, c.valor());
        }
    }
}

Salida:

Intento 1: esperado 2000000, real 2000000
Intento 2: esperado 2000000, real 2000000
Intento 3: esperado 2000000, real 2000000

Exacto, siempre, en todas las ejecuciones. El problema abierto desde 08-01 está resuelto.

El detalle que se olvida: el getter también va sincronizado. Si valor() no lo estuviera, un lector podría ver un valor obsoleto de su caché, aunque los escritores fueran perfectamente correctos. Todos los accesos a un dato compartido —lecturas incluidas— deben usar el mismo mecanismo de sincronización. Es la regla que más se incumple después del if en lugar del while.

Reentrada. Los monitores de Java son reentrantes: si un hilo ya tiene el monitor, puede volver a adquirirlo sin bloquearse. La JVM lleva un contador de adquisiciones y solo libera cuando llega a cero.

public class Reentrada {

    public synchronized void registrarPrestamo(String isbn) {
        validar(isbn);
        // Llamada a OTRO metodo sincronizado del MISMO objeto.
        // Sin reentrada, el hilo se bloquearia a si mismo: interbloqueo
        // instantaneo consigo mismo.
        actualizarIndice(isbn);
    }

    public synchronized void actualizarIndice(String isbn) { /* ... */ }

    private void validar(String isbn) { /* ... */ }
}

Sin reentrada, la herencia sería inviable: un método sincronizado de una subclase que llame a super.metodoSincronizado() se bloquearía consigo mismo. La reentrada hace que esto simplemente funcione.

  1. Las formas de synchronized

Hay tres, y no son equivalentes.

Forma 1: método sincronizado de instancia. Bloquea this.

public synchronized void anadir(Material m) { /* ... */ }

// Es EXACTAMENTE equivalente a:
public void anadir(Material m) {
    synchronized (this) { /* ... */ }
}

Forma 2: método sincronizado estático. Bloquea el objeto Class.

public static synchronized void registrarEnGlobal(String evento) { /* ... */ }

// Equivalente a:
public static void registrarEnGlobal(String evento) {
    synchronized (Catalogo.class) { /* ... */ }
}

Consecuencia crítica que sorprende a mucha gente: un método de instancia sincronizado y un método estático sincronizado de la misma clase usan monitores distintos y no se excluyen entre sí. Si ambos tocan el mismo estado estático, hay una carrera.

public class DosMonitoresDistintos {
    private static int contadorGlobal = 0;

    // Bloquea 'this'
    public synchronized void incrementarMal() { contadorGlobal++; }

    // Bloquea 'DosMonitoresDistintos.class'
    public static synchronized void incrementarEstatico() { contadorGlobal++; }

    // Estos dos metodos NO se excluyen: usan candados diferentes.
    // El estado estatico que comparten NO esta protegido. BUG.
}

Forma 3: bloque sincronizado con objeto de bloqueo explícito.

private final Object candado = new Object();

public void anadir(Material m) {
    // Trabajo que NO necesita proteccion, fuera del bloqueo:
    validar(m);
    String clave = normalizar(m.isbn());

    synchronized (candado) {
        // Seccion critica: lo minimo imprescindible
        materiales.add(m);
        indice.put(clave, m);
    }

    // Mas trabajo no critico
    registrarEnAuditoria(m);
}

Esta tercera forma es la preferible, por dos razones:

Razón A: granularidad. El método sincronizado bloquea todo el método, incluido el trabajo que no lo necesita. Si validar() tarda 50 ms, todos los demás hilos esperan 50 ms para nada. El bloque protege solo lo imprescindible.

Razón B: control del candado. synchronized (this) expone el monitor al mundo: cualquier código externo puede hacer synchronized (miCatalogo) y bloquear tu clase desde fuera, deliberadamente o por accidente. No es teórico: Vector y Hashtable sincronizan sobre this y ese es uno de los motivos por los que se desaconsejan.

public class Catalogo {
    // Candado PRIVADO y FINAL: nadie fuera puede adquirirlo,
    // y nadie puede reasignarlo (un candado reasignado deja de
    // proteger: dos hilos podrian bloquear objetos distintos).
    private final Object candado = new Object();

    // Y NUNCA uses como candado algo asi:
    //   private final Integer candado = 42;      // <- puede estar internado
    //   private final String candado = "cat";    // <- literal internado: COMPARTIDO
    //   private final Boolean candado = false;   // <- Boolean.FALSE, compartido
    // Un literal String o un Integer pequeno es un objeto UNICO en toda la JVM:
    // otra clase que use el mismo literal comparte tu candado sin saberlo.
}

Tabla comparativa:

Forma Candado Granularidad Expuesto Recomendación
synchronized de instancia this Todo el método Solo en clases pequeñas y controladas
synchronized estático Clase.class Todo el método Solo para estado estático
synchronized (candadoPrivado) Objeto privado La que decidas No Preferida

  1. Qué sincronizar y qué no

Sincronizar de más es tan malo como sincronizar de menos: convierte un programa concurrente en uno secuencial con sobrecoste añadido.

Regla 1: la sección crítica, lo más corta posible.

// MAL: 300 ms de E/S dentro del bloqueo. Todos los demas hilos esperan.
public void registrarPrestamo(Prestamo p) {
    synchronized (candado) {
        prestamos.put(p.id(), p);
        escribirEnDisco(p);              // 300 ms de E/S
        enviarNotificacion(p.empleado()); // 200 ms mas
    }
}

// BIEN: solo el cambio de estado en memoria esta protegido.
public void registrarPrestamo(Prestamo p) {
    synchronized (candado) {
        prestamos.put(p.id(), p);        // microsegundos
    }
    escribirEnDisco(p);                  // fuera del bloqueo
    enviarNotificacion(p.empleado());    // fuera del bloqueo
}

Regla 2: nunca hagas E/S dentro de un bloqueo. Es un caso particular de la regla 1, pero merece su propia entrada porque es el error de rendimiento más frecuente. Una lectura de disco puede tardar 10 ms; una consulta remota, cientos. Mantener un candado durante ese tiempo serializa toda la aplicación.

Regla 3: nunca llames a código ajeno con un candado en la mano. Código ajeno es cualquier cosa que no controles: un método sobrescribible, un callback, un oyente registrado por el usuario.

// PELIGROSO: 'oyentes' contiene codigo que no controlas.
synchronized (candado) {
    for (OyenteCatalogo o : oyentes) {
        o.materialAnadido(m);    // ¿que hace? ¿bloquea? ¿llama de vuelta?
    }
}

// SEGURO: copiar bajo el candado, notificar fuera.
List<OyenteCatalogo> copia;
synchronized (candado) {
    copia = new ArrayList<>(oyentes);
}
for (OyenteCatalogo o : copia) {
    o.materialAnadido(m);        // sin candado: no puede bloquearnos
}

Un oyente que intente adquirir otro candado desde ahí puede provocar un interbloqueo; uno que llame de vuelta a tu clase, una recursión inesperada. El patrón de copiar bajo el candado y notificar fuera es estándar, y en 08-06 verás que CopyOnWriteArrayList lo hace por ti.

Regla 4: no sincronices lo que no se comparte. Sincronizar una variable local o un objeto confinado a un hilo es coste puro. (La JVM a veces lo detecta y elimina el bloqueo —lock elision—, pero no cuentes con ello.)

Regla 5: documenta la política de sincronización. Un comentario en el campo diciendo qué lo protege vale por media hora de lectura de código:

public class RegistroPrestamos {
    private final Object candado = new Object();

    /** Protegido por 'candado'. */
    private final Map<String, Prestamo> porId = new HashMap<>();

    /** Protegido por 'candado'. Invariante: contiene los mismos prestamos que 'porId'. */
    private final Map<Empleado, List<Prestamo>> porEmpleado = new HashMap<>();
}

Ese /** Protegido por 'candado'. */ es la anotación @GuardedBy del libro Java Concurrency in Practice escrita a mano. Cuesta una línea y evita que alguien —tú, en seis meses— toque el campo sin el candado.

  1. Interbloqueo: el ejemplo reproducible

Viste un interbloqueo en 08-03 desde el punto de vista del diagnóstico. Ahora, desde el del diseño.

Un interbloqueo necesita cuatro condiciones simultáneas (condiciones de Coffman):

  1. Exclusión mutua: los recursos no se comparten.
  2. Retener y esperar: un hilo retiene uno y pide otro.
  3. Sin expropiación: no se le puede quitar un candado a un hilo.
  4. Espera circular: existe un ciclo de hilos esperándose.

Con synchronized, las tres primeras están dadas por la propia semántica del lenguaje. La única sobre la que puedes actuar es la cuarta, y esa es toda la estrategia.

Ejemplo realista de BiblioTech: transferir un préstamo entre dos empleados requiere bloquear las cuentas de ambos.

package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.TimeUnit;

public class TransferenciaPrestamos {

    /** Cada empleado tiene su propio candado y su lista de prestamos. */
    static class CuentaEmpleado {
        final String nombre;
        final Object candado = new Object();
        int prestamos;

        CuentaEmpleado(String nombre, int prestamos) {
            this.nombre = nombre;
            this.prestamos = prestamos;
        }
    }

    /**
     * VERSION CON INTERBLOQUEO.
     * Bloquea 'origen' y luego 'destino'. Si dos hilos transfieren
     * en sentidos opuestos, se forma el ciclo.
     */
    static void transferirMal(CuentaEmpleado origen, CuentaEmpleado destino, int n) {
        synchronized (origen.candado) {
            dormir(100);                       // amplia la ventana del fallo
            synchronized (destino.candado) {
                origen.prestamos -= n;
                destino.prestamos += n;
                System.out.printf("  %s -> %s : %d prestamos%n",
                        origen.nombre, destino.nombre, n);
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        CuentaEmpleado marta = new CuentaEmpleado("Marta Ruiz", 5);
        CuentaEmpleado diego = new CuentaEmpleado("Diego Alonso", 5);

        // Hilo 1: marta -> diego  (bloquea marta, luego diego)
        Thread t1 = new Thread(() -> transferirMal(marta, diego, 1), "hilo-marta-diego");
        // Hilo 2: diego -> marta  (bloquea diego, luego marta)  <-- ORDEN INVERSO
        Thread t2 = new Thread(() -> transferirMal(diego, marta, 1), "hilo-diego-marta");

        t1.start(); t2.start();

        t1.join(3000);
        t2.join(3000);

        if (t1.isAlive() || t2.isAlive()) {
            System.out.println("INTERBLOQUEO: los dos hilos siguen vivos y bloqueados.");
            System.out.println("Estado t1: " + t1.getState());
            System.out.println("Estado t2: " + t2.getState());
            System.exit(1);
        }
    }
}

Salida:

INTERBLOQUEO: los dos hilos siguen vivos y bloqueados.
Estado t1: BLOCKED
Estado t2: BLOCKED

Los dos hilos en BLOCKED para siempre, sin excepción, sin traza, sin consumo de CPU. Diagrama de lo ocurrido:

sequenceDiagram
    participant T1 as hilo-marta-diego
    participant CM as candado de Marta
    participant CD as candado de Diego
    participant T2 as hilo-diego-marta

    T1->>CM: adquiere OK
    T2->>CD: adquiere OK
    Note over T1,T2: los dos duermen 100 ms
    T1->>CD: pide - lo tiene T2
    Note over T1: BLOCKED
    T2->>CM: pide - lo tiene T1
    Note over T2: BLOCKED
    Note over T1,T2: espera circular: ninguno suelta.<br/>INTERBLOQUEO PERMANENTE

  1. Las soluciones al interbloqueo

Solución A: orden global de adquisición

Define un orden total sobre todos los candados y adquiérelos siempre en ese orden. Sin espera circular no hay interbloqueo; es una garantía matemática, no una probabilidad.

El orden puede ser cualquiera con tal de que sea total y consistente. Aquí se usa System.identityHashCode, que da un entero estable por objeto:

/**
 * VERSION CORRECTA: orden global de adquisicion.
 * Los candados se toman SIEMPRE en orden creciente de identityHashCode,
 * independientemente de la direccion de la transferencia.
 */
static void transferirBien(CuentaEmpleado origen, CuentaEmpleado destino, int n) {

    int ho = System.identityHashCode(origen);
    int hd = System.identityHashCode(destino);

    if (ho < hd) {
        synchronized (origen.candado) {
            synchronized (destino.candado) {
                mover(origen, destino, n);
            }
        }
    } else if (ho > hd) {
        synchronized (destino.candado) {      // orden INVERTIDO respecto al negocio,
            synchronized (origen.candado) {   // pero CONSISTENTE entre hilos
                mover(origen, destino, n);
            }
        }
    } else {
        // Caso rarisimo: colision de identityHashCode entre dos objetos
        // distintos. Un tercer candado global desempata y garantiza
        // que solo un hilo entre en este camino a la vez.
        synchronized (CANDADO_DESEMPATE) {
            synchronized (origen.candado) {
                synchronized (destino.candado) {
                    mover(origen, destino, n);
                }
            }
        }
    }
}

private static final Object CANDADO_DESEMPATE = new Object();

private static void mover(CuentaEmpleado origen, CuentaEmpleado destino, int n) {
    origen.prestamos -= n;
    destino.prestamos += n;
}

Con esto, los dos hilos del apartado 10 adquieren los candados en el mismo orden, uno de los dos gana, hace su trabajo y suelta. No hay interbloqueo posible, ni en tu portátil ni en producción ni dentro de diez años.

Si tus objetos tienen un identificador natural —un ISBN, un identificador de empleado—, úsalo: es más legible y estable que el identityHashCode.

// Con identificador natural, mucho mas claro:
CuentaEmpleado primero = origen.id().compareTo(destino.id()) < 0 ? origen : destino;
CuentaEmpleado segundo = (primero == origen) ? destino : origen;

synchronized (primero.candado) {
    synchronized (segundo.candado) {
        mover(origen, destino, n);
    }
}

Solución B: tryLock con tiempo límite

Si no puedes imponer un orden —porque los candados los eligen componentes que no controlas—, la alternativa es no esperar indefinidamente: intentar adquirir con plazo y, si falla, soltar todo y reintentar. Requiere ReentrantLock (apartado 13), porque synchronized no tiene tryLock.

import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class TransferenciaConTryLock {

    static class CuentaEmpleado {
        final String nombre;
        final ReentrantLock candado = new ReentrantLock();
        int prestamos;
        CuentaEmpleado(String nombre, int prestamos) {
            this.nombre = nombre; this.prestamos = prestamos;
        }
    }

    /**
     * Adquiere los dos candados con plazo. Si no consigue ambos,
     * suelta lo que tenga, espera un tiempo ALEATORIO y reintenta.
     * El azar es esencial: sin el, dos hilos podrian reintentar
     * sincronizados eternamente (livelock, apartado 12).
     */
    static boolean transferir(CuentaEmpleado origen, CuentaEmpleado destino,
                              int n, int maxIntentos) throws InterruptedException {

        for (int intento = 0; intento < maxIntentos; intento++) {
            boolean tengoOrigen = false;
            boolean tengoDestino = false;
            try {
                tengoOrigen = origen.candado.tryLock(50, TimeUnit.MILLISECONDS);
                if (tengoOrigen) {
                    tengoDestino = destino.candado.tryLock(50, TimeUnit.MILLISECONDS);
                }
                if (tengoOrigen && tengoDestino) {
                    origen.prestamos -= n;
                    destino.prestamos += n;
                    return true;                     // exito
                }
            } finally {
                // SIEMPRE soltar lo adquirido, en orden inverso.
                if (tengoDestino) destino.candado.unlock();
                if (tengoOrigen)  origen.candado.unlock();
            }
            // Retroceso aleatorio antes de reintentar.
            TimeUnit.MILLISECONDS.sleep(
                    ThreadLocalRandom.current().nextInt(10, 60));
        }
        return false;                                // agotados los intentos
    }
}

Comparación de las dos soluciones:

Orden global tryLock con plazo
Garantía Absoluta: el interbloqueo es imposible Probabilística: se evita, no se prohíbe
Coste Ninguno en tiempo de ejecución Reintentos y esperas
Requisito Poder ordenar los candados Poder abandonar y reintentar la operación
Complejidad del código Baja Media-alta
Cuándo usarla Siempre que sea posible Cuando el orden no se puede imponer

La recomendación es clara: orden global siempre que puedas. tryLock es el plan B.

Solución C: la mejor de todas, un solo candado

Antes de complicarse: ¿de verdad hacen falta dos candados? Muchas veces un único candado más grueso resuelve el problema con un coste de contención perfectamente asumible. Un candado no puede interbloquearse consigo mismo.

// Si las transferencias no son el 90% de la carga, esto es
// mas simple, mas seguro y probablemente igual de rapido.
private static final Object CANDADO_TRANSFERENCIAS = new Object();

static void transferir(CuentaEmpleado origen, CuentaEmpleado destino, int n) {
    synchronized (CANDADO_TRANSFERENCIAS) {
        origen.prestamos -= n;
        destino.prestamos += n;
    }
}

Mide antes de optimizar. La granularidad fina es una optimización, y como toda optimización, tiene un coste en complejidad y en riesgo.

  1. Inanición y livelock

Inanición (starvation). Un hilo nunca consigue el recurso que necesita porque otros se lo llevan siempre. Causas habituales:

  • Prioridades muy dispares (08-02: no las uses).
  • Un candado no equitativo que favorece sistemáticamente al hilo que acaba de soltarlo, porque su caché sigue caliente.
  • Un hilo que retiene un candado demasiado tiempo (E/S dentro del bloqueo, apartado 9).

Solución: secciones críticas cortas y, si hace falta de verdad, un candado equitativo:

// El parametro true activa la politica FIFO: el candado se concede
// al hilo que lleva mas tiempo esperando.
// COSTE: mucho menor rendimiento (a menudo 10x o mas), porque impide
// la reentrada oportunista y fuerza cambios de contexto.
// Usalo solo si has medido inanicion real.
ReentrantLock equitativo = new ReentrantLock(true);

Livelock. Los hilos están activos y ejecutando, pero ninguno progresa: reaccionan continuamente unos a otros. Es el caso de dos personas que se cruzan en un pasillo y se apartan las dos hacia el mismo lado, una y otra vez.

En código, el tryLock sin retroceso aleatorio es el ejemplo clásico:

// LIVELOCK: si los dos hilos entran en fase, pueden reintentar
// eternamente, cediendo siempre a la vez.
while (true) {
    if (a.tryLock()) {
        if (b.tryLock()) { hacerTrabajo(); return; }
        a.unlock();          // cede
    }
    // sin espera aleatoria: los dos hilos vuelven a intentarlo
    // exactamente a la vez, otra vez, y otra
}

La solución es el retroceso aleatorio (randomized backoff), como en el ejemplo del apartado 11: dormir un tiempo aleatorio antes de reintentar rompe la sincronía. Es la misma idea que usa Ethernet para las colisiones.

Diferencia con el interbloqueo, que importa al diagnosticar: en un interbloqueo los hilos están en BLOCKED y consumen 0 % de CPU; en un livelock están en RUNNABLE y consumen CPU al máximo. Si tu aplicación no avanza pero la CPU está al 100 %, sospecha livelock, no interbloqueo — y jstack no te lo dirá con un mensaje Found one Java-level deadlock.

  1. ReentrantLock frente a synchronized

java.util.concurrent.locks.ReentrantLock hace lo mismo que synchronized y añade cosas que synchronized no puede hacer. A cambio, hay que soltarlo a mano.

El patrón obligatorio, sin excepciones:

import java.util.concurrent.locks.ReentrantLock;

public class ContadorConLock {

    private final ReentrantLock candado = new ReentrantLock();
    private int valor = 0;

    public void incrementar() {
        candado.lock();
        try {
            valor++;
        } finally {
            // OBLIGATORIO en finally: si el cuerpo lanza una excepcion
            // y el unlock esta fuera, el candado NO se libera JAMAS
            // y toda la aplicacion se bloquea. Es el error numero uno
            // de ReentrantLock, y el motivo por el que synchronized
            // sigue siendo preferible cuando no necesitas nada mas.
            candado.unlock();
        }
    }
}

lock() va FUERA del try. Si estuviera dentro y fallara la adquisición, el finally haría unlock() de un candado que no se tiene: IllegalMonitorStateException. El orden correcto es lock(); try { ... } finally { unlock(); }.

Lo que ReentrantLock añade:

1. tryLock(): adquirir sin esperar.

if (candado.tryLock()) {           // no bloquea nunca
    try { operacion(); } finally { candado.unlock(); }
} else {
    // plan B: encolar, avisar al usuario, reintentar mas tarde
}

2. tryLock(tiempo, unidad): adquirir con plazo. La base de la solución B al interbloqueo.

3. lockInterruptibly(): espera cancelable.

try {
    candado.lockInterruptibly();   // responde a interrupt() mientras espera
    try { operacion(); } finally { candado.unlock(); }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

Esto es imposible con synchronized: un hilo BLOCKED esperando un monitor no responde a interrupt(). Si la operación puede tardar y quieres poder cancelarla, ReentrantLock es la única opción.

4. Equidad opcional: new ReentrantLock(true).

5. Varias Condition por candado (apartado 14).

6. Introspección: isLocked(), getHoldCount(), getQueueLength() — útiles para diagnóstico y métricas.

Tabla de decisión:

Característica synchronized ReentrantLock
Liberación automática , incluso con excepción No: finally obligatorio
Sintaxis Palabra clave, imposible olvidarse Código explícito
Intentar sin bloquear No tryLock()
Plazo de espera No tryLock(t, u)
Espera interrumpible No lockInterruptibly()
Equidad No Opcional
Condiciones de espera Una (wait/notify) Varias (newCondition())
Visible en volcados , - locked <0x...> Sí (con jcmd Thread.print -l)
Rendimiento Igual desde Java 6 Igual
Legibilidad Mayor Menor

La recomendación: usa synchronized por defecto. Es más corto, imposible de olvidar liberar y perfectamente rápido desde que Java 6 introdujo el sesgo y el ensanchamiento de bloqueos. Pasa a ReentrantLock solo cuando necesites tryLock, plazo, interrumpibilidad, equidad o varias condiciones.

  1. Condition: sustituto de wait/notify

Un ReentrantLock puede crear varios objetos Condition, cada uno con su propia cola de espera. Es la solución elegante al problema de notify frente a notifyAll de 08-03: puedes señalizar exactamente al grupo correcto.

Object (con synchronized) Condition (con Lock)
wait() await()
wait(ms) await(t, unidad)
notify() signal()
notifyAll() signalAll()
Una sola cola por objeto Tantas como quieras
awaitUninterruptibly()

Cola acotada con dos condiciones separadas:

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

/**
 * Cola acotada de reservas con dos condiciones separadas.
 *
 * Ventaja sobre wait/notifyAll (08-03): al liberar un hueco solo se
 * despierta a un PRODUCTOR, y al anadir un elemento solo a un CONSUMIDOR.
 * Con notifyAll se despertaba a todos y la mayoria volvia a dormirse.
 *
 * NOTA: en produccion usarias ArrayBlockingQueue (08-06), que es
 * exactamente esto ya implementado y probado.
 */
public class ColaReservasConCondition {

    private final ReentrantLock candado = new ReentrantLock();

    // DOS condiciones sobre el MISMO candado.
    private final Condition hayHueco    = candado.newCondition();
    private final Condition hayElemento = candado.newCondition();

    private final Deque<String> cola = new ArrayDeque<>();
    private final int capacidad;

    public ColaReservasConCondition(int capacidad) {
        this.capacidad = capacidad;
    }

    public void poner(String reserva) throws InterruptedException {
        candado.lock();
        try {
            // WHILE, igual que con wait(): las mismas tres razones de 08-03.
            while (cola.size() == capacidad) {
                hayHueco.await();          // suelta el candado y espera
            }
            cola.addLast(reserva);
            hayElemento.signal();          // despierta a UN consumidor.
                                           // Seguro: en esta cola solo esperan
                                           // consumidores, y todos son
                                           // intercambiables.
        } finally {
            candado.unlock();
        }
    }

    public String tomar() throws InterruptedException {
        candado.lock();
        try {
            while (cola.isEmpty()) {
                hayElemento.await();
            }
            String r = cola.pollFirst();
            hayHueco.signal();             // despierta a UN productor
            return r;
        } finally {
            candado.unlock();
        }
    }

    public int tamano() {
        candado.lock();
        try {
            return cola.size();
        } finally {
            candado.unlock();
        }
    }
}

Con Condition separadas, signal() es seguro, porque todos los hilos de esa cola esperan exactamente la misma condición y son intercambiables. Es la diferencia entre despertar a los diez hilos que esperan —de los que nueve se vuelven a dormir— y despertar al único que puede avanzar.

  1. ReadWriteLock: leer mucho, escribir poco

Un candado exclusivo trata igual a lectores y escritores. Pero dos lectores no se estorban: leer no modifica nada. Si tu estructura se lee cien veces por cada escritura —exactamente el caso del Catalogo de BiblioTech—, un candado exclusivo desperdicia casi todo el paralelismo disponible.

ReentrantReadWriteLock ofrece dos candados coordinados:

  • Candado de lectura: compartido. Muchos hilos a la vez.
  • Candado de escritura: exclusivo. Excluye a lectores y a otros escritores.
Situación ¿Se permite?
Lector + lector , simultáneos
Lector + escritor No
Escritor + escritor No
package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Material;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * Catalogo seguro para varios hilos, optimizado para lectura.
 *
 * Justificacion del ReadWriteLock: en BiblioTech se consulta el catalogo
 * decenas de veces por cada alta (buscar por ISBN, listar, filtrar por tipo),
 * y las altas llegan en lotes durante la importacion.
 */
public class CatalogoSeguro {

    private final ReentrantReadWriteLock candado = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock  lectura  = candado.readLock();
    private final ReentrantReadWriteLock.WriteLock escritura = candado.writeLock();

    /** Protegido por 'candado'. */
    private final List<Material> materiales = new ArrayList<>();
    /** Protegido por 'candado'. Invariante: una entrada por material. */
    private final Map<String, Material> indice = new HashMap<>();
    /** Protegido por 'candado'. Invariante: mismos ISBN que 'indice'. */
    private final Set<String> isbnRegistrados = new HashSet<>();

    // ---------- ESCRITURAS: candado exclusivo ----------

    public boolean anadir(Material m) {
        escritura.lock();
        try {
            if (!isbnRegistrados.add(m.isbn())) {
                return false;                // ya existia
            }
            materiales.add(m);
            indice.put(m.isbn(), m);
            return true;
        } finally {
            escritura.unlock();
        }
    }

    public boolean eliminar(String isbn) {
        escritura.lock();
        try {
            Material m = indice.remove(isbn);
            if (m == null) return false;
            materiales.remove(m);
            isbnRegistrados.remove(isbn);
            return true;
        } finally {
            escritura.unlock();
        }
    }

    /** Reemplazo atomico completo: lo que usa la importacion de 08-02. */
    public void reemplazarTodo(List<Material> nuevos) {
        escritura.lock();
        try {
            materiales.clear();
            indice.clear();
            isbnRegistrados.clear();
            for (Material m : nuevos) {
                if (isbnRegistrados.add(m.isbn())) {
                    materiales.add(m);
                    indice.put(m.isbn(), m);
                }
            }
        } finally {
            escritura.unlock();
        }
    }

    // ---------- LECTURAS: candado compartido ----------

    public Material buscarPorIsbn(String isbn) {
        lectura.lock();
        try {
            return indice.get(isbn);
        } finally {
            lectura.unlock();
        }
    }

    public int tamano() {
        lectura.lock();
        try {
            return materiales.size();
        } finally {
            lectura.unlock();
        }
    }

    /**
     * Devuelve una COPIA. Devolver la lista interna seria un fallo de
     * seguridad de hilos: quien la reciba podria iterarla sin candado
     * mientras otro hilo la modifica -> ConcurrentModificationException
     * (05-02) o algo peor.
     */
    public List<Material> listar() {
        lectura.lock();
        try {
            return new ArrayList<>(materiales);   // copia bajo el candado
        } finally {
            lectura.unlock();
        }
    }
}

Cuándo compensa un ReadWriteLock:

Condición Por qué importa
Lecturas ≫ escrituras (≥ 5:1, idealmente 20:1) Con pocas lecturas, el mayor coste del candado no se amortiza
Las lecturas duran algo Si duran nanosegundos, la sobrecarga domina
Hay contención real Sin varios hilos concurrentes no gana nada

Cuándo NO compensa: operaciones muy cortas, proporción equilibrada de lecturas y escrituras, o poca concurrencia. En esos casos un synchronized sencillo suele ser más rápido, porque ReentrantReadWriteLock mantiene más estado interno.

Degradación y promoción. Se puede degradar —tener el candado de escritura y adquirir el de lectura antes de soltarlo— pero no promocionar: intentar adquirir el de escritura teniendo el de lectura produce un interbloqueo instantáneo consigo mismo.

// PROHIBIDO: promocion. Se cuelga para siempre.
lectura.lock();
escritura.lock();     // espera a que se suelten TODAS las lecturas,
                      // incluida la NUESTRA. Interbloqueo.

// PERMITIDO: degradacion (escritura -> lectura).
escritura.lock();
try {
    modificar();
    lectura.lock();       // adquirir lectura ANTES de soltar escritura
} finally {
    escritura.unlock();   // ahora tenemos solo lectura
}
try {
    leerLoQueAcabamosDeEscribir();
} finally {
    lectura.unlock();
}

Alternativa moderna: StampedLock (Java 8). Añade lectura optimista: lees sin bloquear nada y luego validas si hubo escrituras entretanto; si las hubo, reintentas con lectura real. Es más rápido bajo mucha carga de lectura, pero no es reentrante y su API es fácil de usar mal. Menciónalo, mídelo si tienes un cuello de botella demostrado, y no lo uses por defecto.

  1. Publicación segura e inmutabilidad

Publicar un objeto es hacerlo visible a otros hilos. Hacerlo mal produce un fallo desconcertante: otro hilo puede ver un objeto a medio construir.

// PUBLICACION INSEGURA
public class Registro {
    public static Catalogo instancia;    // sin volatile, sin final

    public static void inicializar() {
        instancia = new Catalogo();      // (1) reservar (2) construir (3) asignar
        // Las fases (2) y (3) pueden REORDENARSE: otro hilo puede ver
        // 'instancia' no nula apuntando a un objeto sin construir.
    }
}

Formas seguras de publicar:

  1. Inicializarlo desde un inicializador estático (la JVM sincroniza la carga de clases).
  2. Guardarlo en un campo volatile o en una AtomicReference (08-06).
  3. Guardarlo en un campo final de un objeto correctamente construido.
  4. Guardarlo en un campo protegido por un candado, y leerlo con el mismo candado.
  5. Ponerlo en una colección concurrente (08-06).

La garantía de los campos final merece un párrafo propio, porque es la que hace que la inmutabilidad funcione:

Si un objeto solo tiene campos final y no deja escapar this durante la construcción, cualquier hilo que obtenga una referencia a él verá sus campos correctamente inicializados, sin necesidad de ninguna sincronización.

Un objeto inmutable es aquel que:

  1. Tiene todos sus campos final.
  2. No expone ningún método que cambie su estado.
  3. No deja escapar this en el constructor.
  4. Si contiene referencias a objetos mutables, hace copias defensivas al entrar y al salir.

Un objeto inmutable es seguro para cualquier número de hilos, sin ningún candado, para siempre. Es la estrategia más potente de toda la concurrencia.

Y aquí viene lo bueno: los record de 04-07 son inmutables por construcción.

package com.nexussoftware.bibliotech.dominio;

/**
 * Ficha inmutable: todos sus campos son final por ser un record.
 * Compartible entre cualquier numero de hilos sin sincronizacion.
 */
public record Ficha(String isbn, String titulo, String autor, int ejemplares) { }

/**
 * ResumenSesion inmutable. Ojo: si un record contiene una coleccion,
 * la coleccion debe ser tambien inmutable, o el record solo es
 * "superficialmente" inmutable.
 */
public record ResumenSesion(long inicioMs, long finMs,
                            int prestamos, int devoluciones,
                            List<String> incidencias) {

    /** Constructor compacto con copia defensiva: hace la lista inmutable. */
    public ResumenSesion {
        incidencias = List.copyOf(incidencias);   // copia INMUTABLE
    }
}

El truco del constructor compacto es importante: sin List.copyOf, quien construyó el record podría seguir modificando la lista original, y el record dejaría de ser inmutable de verdad.

Cómo se cambia el estado de un objeto inmutable: no se cambia. Se crea uno nuevo y se reemplaza la referencia de forma atómica:

public class EstadisticasBiblioTech {

    /** Instantanea inmutable de las estadisticas. */
    public record Instantanea(long prestamos, long devoluciones, long multas) {
        Instantanea conPrestamo()   { return new Instantanea(prestamos + 1, devoluciones, multas); }
        Instantanea conDevolucion() { return new Instantanea(prestamos, devoluciones + 1, multas); }
    }

    // La REFERENCIA es volatile: los lectores siempre ven una
    // instantanea completa y coherente, nunca una a medias.
    private volatile Instantanea actual = new Instantanea(0, 0, 0);

    /** Lectura sin ningun bloqueo: coherente por inmutabilidad. */
    public Instantanea instantanea() {
        return actual;
    }

    /**
     * La escritura SI necesita sincronizacion: 'actual = actual.conPrestamo()'
     * es leer-modificar-escribir, y volatile no lo hace atomico (apartado 6).
     * En 08-06 esto se resolvera con AtomicReference.updateAndGet, sin candado.
     */
    public synchronized void registrarPrestamo() {
        actual = actual.conPrestamo();
    }
}

Este patrón —estado inmutable + referencia volatile— da lecturas sin ningún bloqueo y siempre coherentes. Es enormemente valioso cuando se lee mucho más de lo que se escribe.

  1. Confinamiento: la mejor sincronización es no compartir

Todas las técnicas anteriores gestionan la compartición. La mejor estrategia es eliminarla.

Confinamiento en la pila. Una variable local vive en la pila del hilo: es privada por construcción (08-01). Si construyes un objeto dentro de un método, lo usas y no lo dejas escapar, no hay nada que sincronizar.

public Informe procesarLote(List<String> lineas) {
    // TODO local: cada hilo tiene su propia lista y su propio contador.
    // Ningun candado, ninguna carrera posible.
    List<Material> aceptados = new ArrayList<>();
    int descartados = 0;

    for (String l : lineas) {
        try {
            aceptados.add(lector.aMaterial(l));
        } catch (FormatoInvalidoException e) {
            descartados++;
        }
    }
    // El unico punto de contacto con estado compartido, y esta protegido:
    catalogo.anadirTodos(aceptados);
    return new Informe(aceptados.size(), descartados);
}

Confinamiento en un hilo con ThreadLocal. Cada hilo tiene su propia copia de la variable:

import java.text.NumberFormat;
import java.util.Locale;

public class FormatoMultas {

    // NumberFormat NO es seguro para varios hilos: compartir una instancia
    // produce resultados corruptos bajo carga (un clasico muy dificil de
    // diagnosticar porque falla poco y de forma silenciosa).
    // ThreadLocal da una instancia por hilo: seguro y sin candados.
    private static final ThreadLocal<NumberFormat> FORMATO =
            ThreadLocal.withInitial(() -> NumberFormat.getCurrencyInstance(
                    Locale.forLanguageTag("es-ES")));

    public static String formatear(double importe) {
        return FORMATO.get().format(importe);
    }

    /**
     * IMPORTANTE: en un pool de hilos (08-05) los hilos se reutilizan
     * indefinidamente, asi que un ThreadLocal nunca se libera solo.
     * Si guarda algo grande o sensible, hay que limpiarlo:
     */
    public static void limpiar() {
        FORMATO.remove();
    }
}

Aviso sobre ThreadLocal y pools. Es una fuente conocida de fugas de memoria y de contaminación de datos entre peticiones: un hilo de pool que atendió a Marta Ruiz y no limpió su ThreadLocal puede exponer ese dato a la petición de Diego Alonso. Regla: si usas ThreadLocal en un pool, límpialo en un finally.

Confinamiento por diseño: divide, procesa aparte y combina al final.

// Cada hilo procesa SU trozo y produce SU resultado parcial.
// No hay estado compartido durante el calculo, solo al combinar.
// Es el modelo de ForkJoinPool (08-05) y de los streams paralelos (10-04).
List<Informe> parciales = new ArrayList<>();
// ... cada hilo devuelve su Informe, y al final:
Informe total = combinar(parciales);   // un solo hilo, sin candados

La jerarquía de estrategias, de mejor a peor:

Estrategia Coste de sincronización Complejidad Cuándo
1. No compartir (confinamiento) Ninguno Mínima Siempre que se pueda
2. Compartir inmutable Ninguno Baja Datos que no cambian
3. Compartir con volatile Muy bajo Baja Banderas, un solo escritor
4. Compartir con atómicos (08-06) Bajo Baja Contadores, referencias
5. Compartir con colección concurrente (08-06) Bajo-medio Baja Mapas, listas, colas
6. Compartir con candado Medio Media Invariantes entre varios campos
7. Compartir sin nada Bug

Empieza siempre por arriba y baja solo cuando sea necesario. La mayoría del código concurrente que se escribe en la industria está en el nivel 6 cuando podría estar en el 1 o el 2.

  1. BiblioTech: un catálogo seguro para varios hilos

Aplicación completa. RegistroPrestamos tiene dos mapas que deben mantenerse coherentes entre sí, lo que obliga a un candado —un invariante que abarca dos estructuras no se puede mantener con atómicos ni con colecciones concurrentes independientes—.

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Empleado;
import com.nexussoftware.bibliotech.dominio.Prestamo;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * Registro de prestamos seguro para varios hilos.
 *
 * POLITICA DE SINCRONIZACION
 * --------------------------
 * Todo el estado mutable esta protegido por 'candado'.
 * Las consultas usan el candado de lectura (compartido);
 * las modificaciones, el de escritura (exclusivo).
 *
 * INVARIANTE: 'porId' y 'porEmpleado' contienen exactamente el mismo
 * conjunto de prestamos. Este invariante abarca DOS estructuras, y por
 * eso no basta con usar dos ConcurrentHashMap (08-06): haria falta que
 * las dos actualizaciones fueran una sola operacion atomica, y eso solo
 * lo da un candado.
 */
public class RegistroPrestamosSeguro {

    private final ReentrantReadWriteLock candado = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock  lectura   = candado.readLock();
    private final ReentrantReadWriteLock.WriteLock escritura = candado.writeLock();

    /** Protegido por 'candado'. */
    private final Map<String, Prestamo> porId = new HashMap<>();
    /** Protegido por 'candado'. Invariante: coherente con 'porId'. */
    private final Map<Empleado, List<Prestamo>> porEmpleado = new HashMap<>();

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

    /**
     * Registra un prestamo. Las dos actualizaciones ocurren bajo el mismo
     * candado, asi que ningun lector puede ver el estado intermedio en el
     * que el prestamo esta en 'porId' pero aun no en 'porEmpleado'.
     */
    public void registrar(Prestamo p) {
        escritura.lock();
        try {
            if (porId.putIfAbsent(p.id(), p) != null) {
                throw new IllegalStateException("Prestamo duplicado: " + p.id());
            }
            porEmpleado.computeIfAbsent(p.empleado(), e -> new ArrayList<>()).add(p);
        } finally {
            escritura.unlock();
        }
    }

    public Prestamo devolver(String idPrestamo) {
        escritura.lock();
        try {
            Prestamo p = porId.remove(idPrestamo);
            if (p == null) {
                throw new PrestamoNoEncontradoException(idPrestamo);
            }
            List<Prestamo> deEmpleado = porEmpleado.get(p.empleado());
            if (deEmpleado != null) {
                deEmpleado.remove(p);
                if (deEmpleado.isEmpty()) {
                    porEmpleado.remove(p.empleado());   // no dejar listas vacias
                }
            }
            return p;
        } finally {
            escritura.unlock();
        }
    }

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

    public Prestamo buscar(String idPrestamo) {
        lectura.lock();
        try {
            return porId.get(idPrestamo);
        } finally {
            lectura.unlock();
        }
    }

    /** Devuelve una COPIA: la lista interna nunca sale del candado. */
    public List<Prestamo> prestamosDe(Empleado e) {
        lectura.lock();
        try {
            List<Prestamo> l = porEmpleado.get(e);
            return l == null ? List.of() : new ArrayList<>(l);
        } finally {
            lectura.unlock();
        }
    }

    public int activos() {
        lectura.lock();
        try {
            return porId.size();
        } finally {
            lectura.unlock();
        }
    }

    /**
     * OPERACION COMPUESTA ATOMICA.
     *
     * Este metodo existe precisamente porque hacerlo desde fuera seria
     * un comprobar-luego-actuar inseguro:
     *
     *     if (registro.activos() < MAX) registro.registrar(p);   // BUG
     *
     * Entre la comprobacion y la accion, otro hilo puede registrar y
     * superar el limite. Encapsular la operacion completa dentro del
     * candado es la unica forma correcta.
     */
    public boolean registrarSiHayCupo(Prestamo p, int maximoPorEmpleado) {
        escritura.lock();
        try {
            List<Prestamo> actuales = porEmpleado.get(p.empleado());
            if (actuales != null && actuales.size() >= maximoPorEmpleado) {
                return false;
            }
            porId.put(p.id(), p);
            porEmpleado.computeIfAbsent(p.empleado(), e -> new ArrayList<>()).add(p);
            return true;
        } finally {
            escritura.unlock();
        }
    }
}

Y la prueba de esfuerzo que demuestra que funciona:

package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.TimeUnit;

public class PruebaConcurrenciaRegistro {

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

        final int HILOS = 8;
        final int POR_HILO = 5_000;

        RegistroPrestamosSeguro registro = new RegistroPrestamosSeguro();
        Empleado marta = new Empleado("E-001", "Marta Ruiz");

        Thread[] hilos = new Thread[HILOS];
        long inicio = System.nanoTime();

        for (int h = 0; h < HILOS; h++) {
            final int idHilo = h;
            hilos[h] = new Thread(() -> {
                for (int i = 0; i < POR_HILO; i++) {
                    registro.registrar(new Prestamo(
                            "P-" + idHilo + "-" + i, marta, "978-0000000001"));
                }
            }, "bibliotech-registrador-" + h);
            hilos[h].start();
        }

        for (Thread h : hilos) h.join();

        long ms = (System.nanoTime() - inicio) / 1_000_000;
        int esperado = HILOS * POR_HILO;
        int real = registro.activos();
        int enLista = registro.prestamosDe(marta).size();

        System.out.println("Esperados           : " + esperado);
        System.out.println("En porId            : " + real);
        System.out.println("En porEmpleado      : " + enLista);
        System.out.println("Invariante coherente: " + (real == enLista));
        System.out.println("Tiempo              : " + ms + " ms");
        System.out.println(real == esperado && real == enLista
                ? "CORRECTO"
                : "FALLO: hay una carrera de datos");
    }
}

Salida:

Esperados           : 40000
En porId            : 40000
En porEmpleado      : 40000
Invariante coherente: true
Tiempo              : 187 ms
CORRECTO

Ejecuta esta prueba diez veces. Debe dar CORRECTO las diez. Después quita los lock()/unlock() de registrar y vuelve a ejecutarla: verás recuentos distintos en cada ejecución, los dos mapas descuadrados, y con suerte alguna ConcurrentModificationException o incluso un HashMap corrupto con un bucle infinito en get() —un fenómeno real que se explica en 08-06—.

Errores Comunes y Consejos

Error 1: creer que volatile hace atómico un incremento. El error conceptual número uno del tema. volatile da visibilidad, no atomicidad. Para contadores: synchronized o AtomicInteger (08-06).

Error 2: sincronizar los escritores pero no los lectores. La exclusión mutua sin visibilidad no sirve: un lector sin sincronizar puede ver un valor obsoleto indefinidamente. Todos los accesos, lecturas incluidas, deben usar el mismo mecanismo.

Error 3: usar candados distintos para el mismo dato. Un método de instancia sincronizado y otro estático sincronizado usan monitores diferentes y no se excluyen. Si tocan el mismo estado, hay una carrera.

Error 4: sincronizar sobre un objeto reasignable o compartido sin querer. synchronized (this) expone el candado; synchronized sobre un String literal, un Integer pequeño o un Boolean usa un objeto internado que otra clase puede compartir sin saberlo. Usa private final Object candado = new Object().

Error 5: unlock() fuera del finally. Si el cuerpo lanza, el candado no se libera nunca y la aplicación se bloquea. Es el motivo principal para preferir synchronized.

Error 6: lock() dentro del try. Si falla la adquisición, el finally intenta soltar un candado que no se tiene: IllegalMonitorStateException que enmascara el error real.

Error 7: hacer E/S dentro de un bloqueo. Serializa la aplicación entera. Prepara los datos dentro, escribe fuera.

Error 8: llamar a código ajeno con el candado en la mano. Oyentes, callbacks, métodos sobrescribibles: pueden bloquear, llamar de vuelta o interbloquear. Copia bajo el candado, notifica fuera.

Error 9: adquirir dos candados en orden distinto según el camino. La causa del 90 % de los interbloqueos. Impón un orden global y documéntalo.

Error 10: promocionar de lectura a escritura en un ReadWriteLock. Interbloqueo instantáneo consigo mismo. La degradación sí está permitida; la promoción no.

Error 11: devolver la colección interna desde un método sincronizado. El candado protege el acceso, pero quien recibe la referencia puede iterarla sin candado. Devuelve una copia o una vista inmutable.

Error 12: usar synchronized cuando bastaba con no compartir. Antes de poner un candado, comprueba si el objeto puede ser local, inmutable o confinado a un hilo.

Consejo 1: documenta cada campo compartido con /** Protegido por 'X'. */. Una línea que evita horas de arqueología.

Consejo 2: encapsula las operaciones compuestas dentro de la clase. registrarSiHayCupo existe porque if (activos() < MAX) registrar(p) desde fuera es un comprobar-luego-actuar inseguro. Si tu API obliga al cliente a componer dos llamadas, tu API tiene un bug.

Consejo 3: prueba con presión real. Ocho hilos, decenas de miles de operaciones, y repetido diez veces. Un test de dos hilos y diez operaciones no detecta nada.

Consejo 4: prefiere synchronized por defecto. Pasa a ReentrantLock solo cuando necesites tryLock, plazo, interrumpibilidad, equidad o varias Condition.

Consejo 5: mide antes de afinar la granularidad. Un candado grueso es más simple y más seguro. Divídelo solo cuando hayas demostrado que es el cuello de botella.

Consejo 6: cuando dudes, hazlo inmutable. Un record con campos final no necesita sincronización, no se puede corromper y no puede interbloquear.

Ejercicios

Ejercicio 1: Del contador roto al contador correcto

Escribe ComparativaContadores con cuatro implementaciones de un contador que soporte incrementar() y valor():

  1. ContadorInseguro: int simple.
  2. ContadorVolatile: volatile int.
  3. ContadorSincronizado: synchronized.
  4. ContadorConLock: ReentrantLock.

Somete a cada una a 8 hilos × 500.000 incrementos, comprueba si el resultado es correcto y mide el tiempo con System.nanoTime(). Imprime una tabla con implementación, resultado, error e incrementos por milisegundo. Comenta por qué las dos primeras fallan y por qué las dos últimas tienen tiempos parecidos.

Ejercicio 2: Interbloqueo y su corrección

Modela dos salas de reuniones de BiblioTech (SalaReuniones con su propio candado). Escribe ReservaDobleSala con:

  1. Un método reservarMal(SalaReuniones a, SalaReuniones b) que adquiera los dos candados en el orden en que se le pasan, con un sleep(100) entre ambos, y un main que lo interbloquee de forma reproducible con dos hilos que reserven en sentidos opuestos, detectándolo con join(3000) + isAlive().
  2. Un método reservarBien(...) que aplique el orden global de adquisición usando el identificador de sala, y demuestre que ya no se interbloquea ni en 1.000 intentos con 8 hilos.
  3. Un método reservarConTryLock(...) con ReentrantLock.tryLock(50, MILLISECONDS), retroceso aleatorio y un máximo de intentos, que informe de cuántas veces tuvo que reintentar.

Ejercicio 3: Caché de fichas con ReadWriteLock y medición

Reimplementa CacheFichas de BiblioTech como caché segura para varios hilos con ReentrantReadWriteLock, con Ficha obtener(String isbn) (que consulta la caché y, si falla, la calcula con un sleep(20) simulado y la guarda), void invalidar(String isbn) y int tamano(). Añade contadores de aciertos y fallos protegidos por el mismo candado.

Después escribe una prueba con 10 hilos que realicen 2.000 consultas cada uno sobre un conjunto de 50 ISBN, mida el tiempo total y el porcentaje de aciertos, y compare el resultado con una versión que use un único synchronized en todos los métodos. Explica el resultado obtenido.

Soluciones

Solución al Ejercicio 1

import java.util.concurrent.locks.ReentrantLock;

public class ComparativaContadores {

    interface Contador {
        void incrementar();
        long valor();
        String nombre();
    }

    /** 1. INSEGURO: valor++ es leer-modificar-escribir sin proteccion. */
    static class ContadorInseguro implements Contador {
        private long valor = 0;
        public void incrementar() { valor++; }
        public long valor() { return valor; }
        public String nombre() { return "int simple"; }
    }

    /** 2. VOLATILE: resuelve la visibilidad, NO la atomicidad. */
    static class ContadorVolatile implements Contador {
        private volatile long valor = 0;
        public void incrementar() { valor++; }
        public long valor() { return valor; }
        public String nombre() { return "volatile"; }
    }

    /** 3. SYNCHRONIZED: exclusion mutua + visibilidad. Correcto. */
    static class ContadorSincronizado implements Contador {
        private long valor = 0;
        public synchronized void incrementar() { valor++; }
        // El getter TAMBIEN sincronizado: sin el, un lector podria
        // ver un valor obsoleto de su cache.
        public synchronized long valor() { return valor; }
        public String nombre() { return "synchronized"; }
    }

    /** 4. REENTRANTLOCK: equivalente, con unlock en finally. */
    static class ContadorConLock implements Contador {
        private final ReentrantLock candado = new ReentrantLock();
        private long valor = 0;

        public void incrementar() {
            candado.lock();                 // FUERA del try
            try { valor++; }
            finally { candado.unlock(); }   // SIEMPRE en finally
        }

        public long valor() {
            candado.lock();
            try { return valor; }
            finally { candado.unlock(); }
        }
        public String nombre() { return "ReentrantLock"; }
    }

    static long medir(Contador c, int hilos, int porHilo) throws InterruptedException {
        Thread[] ts = new Thread[hilos];
        long inicio = System.nanoTime();
        for (int i = 0; i < hilos; i++) {
            ts[i] = new Thread(() -> {
                for (int j = 0; j < porHilo; j++) c.incrementar();
            }, "sumador-" + i);
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        return System.nanoTime() - inicio;
    }

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

        final int HILOS = 8;
        final int POR_HILO = 500_000;
        final long ESPERADO = (long) HILOS * POR_HILO;

        Contador[] contadores = {
                new ContadorInseguro(), new ContadorVolatile(),
                new ContadorSincronizado(), new ContadorConLock()
        };

        // Calentamiento: sin el, las primeras implementaciones pagan
        // la compilacion JIT y la comparacion no es justa.
        for (Contador c : contadores) medir(c, 2, 10_000);

        System.out.printf("%-16s | %12s | %10s | %8s | %12s%n",
                "Implementacion", "Resultado", "Perdidos", "ms", "inc/ms");
        System.out.println("-----------------|--------------|------------|----------|-------------");

        for (Contador plantilla : contadores) {
            // Instancia nueva para no arrastrar el calentamiento.
            Contador c = switch (plantilla.nombre()) {
                case "int simple"    -> new ContadorInseguro();
                case "volatile"      -> new ContadorVolatile();
                case "synchronized"  -> new ContadorSincronizado();
                default              -> new ContadorConLock();
            };

            long ns = medir(c, HILOS, POR_HILO);
            long ms = ns / 1_000_000;
            long perdidos = ESPERADO - c.valor();

            System.out.printf("%-16s | %12d | %10d | %8d | %12d%s%n",
                    c.nombre(), c.valor(), perdidos, ms,
                    ms == 0 ? 0 : c.valor() / ms,
                    perdidos == 0 ? "" : "   <-- INCORRECTO");
        }
    }
}

Salida orientativa:

Implementacion   |    Resultado |   Perdidos |       ms |       inc/ms
-----------------|--------------|------------|----------|-------------
int simple       |      1284471 |    2715529 |       31 |        41434   <-- INCORRECTO
volatile         |      1893204 |    2106796 |      142 |        13332   <-- INCORRECTO
synchronized     |      4000000 |          0 |      213 |        18779
ReentrantLock    |      4000000 |          0 |      205 |        19512

Análisis:

  • int simple es el más rápido y el más incorrecto. Va rápido precisamente porque no sincroniza nada: cada núcleo trabaja sobre su caché. Velocidad sin corrección no vale nada.
  • volatile es más lento y sigue fallando. El peor de los mundos: paga las barreras de memoria en cada acceso y no gana atomicidad. Es la demostración práctica del apartado 6.
  • synchronized y ReentrantLock dan el resultado exacto y tienen tiempos casi idénticos. Desde Java 6, synchronized está tan optimizado como ReentrantLock; la elección es de expresividad, no de rendimiento.
  • Los cuatro millones son exactos, siempre. Repite el programa: synchronized y ReentrantLock no fallan nunca. La corrección no es probabilística.

Solución al Ejercicio 2

package com.nexussoftware.bibliotech.servicio;

import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public class ReservaDobleSala {

    /** Sala con candado intrinseco y candado explicito, para las tres versiones. */
    static class SalaReuniones {
        final String id;
        final Object candado = new Object();
        final ReentrantLock lock = new ReentrantLock();
        int reservas = 0;

        SalaReuniones(String id) { this.id = id; }
    }

    // ---------- 1. VERSION CON INTERBLOQUEO ----------

    static void reservarMal(SalaReuniones a, SalaReuniones b) {
        synchronized (a.candado) {
            dormir(100);                    // amplia la ventana del fallo
            synchronized (b.candado) {
                a.reservas++;
                b.reservas++;
            }
        }
    }

    // ---------- 2. ORDEN GLOBAL DE ADQUISICION ----------

    /**
     * Los candados se toman SIEMPRE en orden creciente de 'id'.
     * Sin espera circular no puede haber interbloqueo: es una
     * garantia estructural, no una probabilidad.
     */
    static void reservarBien(SalaReuniones a, SalaReuniones b) {
        SalaReuniones primera = a.id.compareTo(b.id) <= 0 ? a : b;
        SalaReuniones segunda = (primera == a) ? b : a;

        synchronized (primera.candado) {
            dormir(1);
            synchronized (segunda.candado) {
                a.reservas++;
                b.reservas++;
            }
        }
    }

    // ---------- 3. TRYLOCK CON RETROCESO ALEATORIO ----------

    static int reservarConTryLock(SalaReuniones a, SalaReuniones b, int maxIntentos)
            throws InterruptedException {

        for (int intento = 1; intento <= maxIntentos; intento++) {
            boolean tengoA = false, tengoB = false;
            try {
                tengoA = a.lock.tryLock(50, TimeUnit.MILLISECONDS);
                if (tengoA) {
                    tengoB = b.lock.tryLock(50, TimeUnit.MILLISECONDS);
                }
                if (tengoA && tengoB) {
                    a.reservas++;
                    b.reservas++;
                    return intento;                  // exito: devolvemos el intento
                }
            } finally {
                if (tengoB) b.lock.unlock();
                if (tengoA) a.lock.unlock();
            }
            // Retroceso ALEATORIO: sin el, dos hilos en fase reintentarian
            // eternamente a la vez (livelock, apartado 12).
            TimeUnit.MILLISECONDS.sleep(ThreadLocalRandom.current().nextInt(5, 40));
        }
        return -1;                                    // no se consiguio
    }

    // ---------- DEMOSTRACION ----------

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

        // --- 1. Provocar el interbloqueo ---
        System.out.println("=== 1. VERSION CON INTERBLOQUEO ===");
        SalaReuniones s1 = new SalaReuniones("SALA-A");
        SalaReuniones s2 = new SalaReuniones("SALA-B");

        Thread t1 = new Thread(() -> reservarMal(s1, s2), "hilo-A-B");
        Thread t2 = new Thread(() -> reservarMal(s2, s1), "hilo-B-A");
        t1.start(); t2.start();
        t1.join(3000); t2.join(3000);

        if (t1.isAlive() || t2.isAlive()) {
            System.out.println("  INTERBLOQUEADO. t1=" + t1.getState()
                    + "  t2=" + t2.getState());
            System.out.println("  (jstack diria: Found one Java-level deadlock)");
        } else {
            System.out.println("  Esta vez no ha ocurrido; reintenta. "
                    + "La intermitencia es exactamente el problema.");
        }

        // --- 2. Orden global: 1.000 intentos con 8 hilos ---
        System.out.println();
        System.out.println("=== 2. ORDEN GLOBAL DE ADQUISICION ===");
        SalaReuniones a = new SalaReuniones("SALA-A");
        SalaReuniones b = new SalaReuniones("SALA-B");

        Thread[] hilos = new Thread[8];
        for (int i = 0; i < 8; i++) {
            final boolean directo = (i % 2 == 0);
            hilos[i] = new Thread(() -> {
                for (int k = 0; k < 125; k++) {
                    if (directo) reservarBien(a, b);
                    else         reservarBien(b, a);   // sentido opuesto
                }
            }, "reservador-" + i);
            hilos[i].start();
        }
        for (Thread h : hilos) h.join(20_000);

        boolean alguienVivo = false;
        for (Thread h : hilos) alguienVivo |= h.isAlive();

        System.out.println("  Reservas SALA-A: " + a.reservas);
        System.out.println("  Reservas SALA-B: " + b.reservas);
        System.out.println("  Algun hilo bloqueado: " + alguienVivo);
        System.out.println(alguienVivo ? "  FALLO" : "  CORRECTO: 1.000 reservas sin interbloqueo");

        // --- 3. tryLock con retroceso ---
        System.out.println();
        System.out.println("=== 3. TRYLOCK CON RETROCESO ALEATORIO ===");
        SalaReuniones c = new SalaReuniones("SALA-C");
        SalaReuniones d = new SalaReuniones("SALA-D");

        AtomicInteger reintentosTotales = new AtomicInteger();
        AtomicInteger fracasos = new AtomicInteger();

        Thread[] ts = new Thread[4];
        for (int i = 0; i < 4; i++) {
            final boolean directo = (i % 2 == 0);
            ts[i] = new Thread(() -> {
                for (int k = 0; k < 50; k++) {
                    try {
                        int intentos = directo
                                ? reservarConTryLock(c, d, 10)
                                : reservarConTryLock(d, c, 10);
                        if (intentos < 0) fracasos.incrementAndGet();
                        else reintentosTotales.addAndGet(intentos - 1);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        return;
                    }
                }
            }, "trylock-" + i);
            ts[i].start();
        }
        for (Thread t : ts) t.join();

        System.out.println("  Reservas SALA-C   : " + c.reservas);
        System.out.println("  Reservas SALA-D   : " + d.reservas);
        System.out.println("  Reintentos totales: " + reintentosTotales.get());
        System.out.println("  Fracasos          : " + fracasos.get());
        System.out.println("  (los reintentos son el precio de no imponer un orden)");
    }

    static void dormir(long ms) {
        try { TimeUnit.MILLISECONDS.sleep(ms); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

Salida orientativa:

=== 1. VERSION CON INTERBLOQUEO ===
  INTERBLOQUEADO. t1=BLOCKED  t2=BLOCKED
  (jstack diria: Found one Java-level deadlock)

=== 2. ORDEN GLOBAL DE ADQUISICION ===
  Reservas SALA-A: 1000
  Reservas SALA-B: 1000
  Algun hilo bloqueado: false
  CORRECTO: 1.000 reservas sin interbloqueo

=== 3. TRYLOCK CON RETROCESO ALEATORIO ===
  Reservas SALA-C   : 200
  Reservas SALA-D   : 200
  Reintentos totales: 37
  Fracasos          : 0

La comparación entre 2 y 3 es la moraleja: el orden global no necesitó ni un reintento, ni una espera, ni una línea extra de código en tiempo de ejecución. El tryLock funcionó pero pagó 37 reintentos y un montón de complejidad. Cuando puedas ordenar los candados, ordénalos.

Solución al Ejercicio 3

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Ficha;

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

/**
 * Cache de fichas segura para varios hilos, optimizada para lectura.
 *
 * POLITICA: todo el estado esta protegido por 'candado'.
 * Las consultas que aciertan usan el candado de LECTURA (compartido);
 * solo los fallos escalan al de ESCRITURA (exclusivo).
 */
public class CacheFichasSegura {

    private final ReentrantReadWriteLock candado = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock  lectura   = candado.readLock();
    private final ReentrantReadWriteLock.WriteLock escritura = candado.writeLock();

    /** Protegido por 'candado'. */
    private final Map<String, Ficha> cache = new HashMap<>();
    /** Protegidos por 'candado'. */
    private long aciertos = 0;
    private long fallos = 0;

    public Ficha obtener(String isbn) {

        // FASE 1: intento optimista con candado COMPARTIDO.
        // Varios hilos pueden estar aqui a la vez, que es el 96% del trafico.
        lectura.lock();
        try {
            Ficha f = cache.get(isbn);
            if (f != null) {
                // OJO: 'aciertos++' bajo el candado de LECTURA seria una
                // carrera: varios lectores simultaneos harian
                // leer-modificar-escribir a la vez. Lo contamos en la fase 2.
                return f;
            }
        } finally {
            lectura.unlock();
        }

        // FASE 2: fallo. Calculamos FUERA de todo candado (regla 2 del
        // apartado 9: nunca operaciones lentas dentro del bloqueo).
        Ficha calculada = calcular(isbn);

        // FASE 3: publicacion con candado EXCLUSIVO.
        escritura.lock();
        try {
            // Re-comprobamos: entre la fase 1 y la 3 otro hilo pudo
            // calcular la misma ficha. putIfAbsent conserva la primera
            // y mantiene la coherencia del recuento.
            Ficha yaPuesta = cache.putIfAbsent(isbn, calculada);
            if (yaPuesta != null) {
                aciertos++;                  // otro hilo nos gano la carrera
                return yaPuesta;
            }
            fallos++;
            return calculada;
        } finally {
            escritura.unlock();
        }
    }

    public void invalidar(String isbn) {
        escritura.lock();
        try { cache.remove(isbn); }
        finally { escritura.unlock(); }
    }

    public int tamano() {
        lectura.lock();
        try { return cache.size(); }
        finally { lectura.unlock(); }
    }

    public String estadisticas() {
        lectura.lock();
        try {
            long total = aciertos + fallos;
            return String.format("aciertos=%d fallos=%d tasa=%.1f%%",
                    aciertos, fallos, total == 0 ? 0.0 : 100.0 * aciertos / total);
        } finally {
            lectura.unlock();
        }
    }

    /** Simula el coste real de construir una ficha (consulta + formateo). */
    private Ficha calcular(String isbn) {
        try { TimeUnit.MILLISECONDS.sleep(20); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        return new Ficha(isbn, "Titulo de " + isbn, "Autor", 3);
    }

    // ---------- VERSION DE CONTRASTE: un solo candado exclusivo ----------

    public static class CacheFichasSincronizada {
        private final Map<String, Ficha> cache = new HashMap<>();

        // TODO el metodo sincronizado, INCLUIDO el calculo de 20 ms.
        // Es el error de la regla 2 del apartado 9, a proposito.
        public synchronized Ficha obtener(String isbn) {
            Ficha f = cache.get(isbn);
            if (f == null) {
                try { TimeUnit.MILLISECONDS.sleep(20); }
                catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                f = new Ficha(isbn, "Titulo de " + isbn, "Autor", 3);
                cache.put(isbn, f);
            }
            return f;
        }

        public synchronized int tamano() { return cache.size(); }
    }

    // ---------- PRUEBA ----------

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

        final int HILOS = 10;
        final int CONSULTAS = 2_000;
        final int ISBNS = 50;

        // --- Version con ReadWriteLock ---
        CacheFichasSegura rw = new CacheFichasSegura();
        long msRw = ejecutar(HILOS, CONSULTAS, ISBNS, isbn -> rw.obtener(isbn));

        System.out.println("=== ReadWriteLock ===");
        System.out.println("  Tiempo   : " + msRw + " ms");
        System.out.println("  Entradas : " + rw.tamano());
        System.out.println("  " + rw.estadisticas());

        // --- Version con synchronized total ---
        CacheFichasSincronizada sy = new CacheFichasSincronizada();
        long msSy = ejecutar(HILOS, CONSULTAS, ISBNS, isbn -> sy.obtener(isbn));

        System.out.println();
        System.out.println("=== synchronized total ===");
        System.out.println("  Tiempo   : " + msSy + " ms");
        System.out.println("  Entradas : " + sy.tamano());

        System.out.println();
        System.out.printf("Relacion: %.1fx a favor del ReadWriteLock%n",
                (double) msSy / msRw);
    }

    interface Consulta { Ficha obtener(String isbn); }

    static long ejecutar(int hilos, int consultas, int isbns, Consulta c)
            throws InterruptedException {
        Thread[] ts = new Thread[hilos];
        long inicio = System.nanoTime();
        for (int i = 0; i < hilos; i++) {
            ts[i] = new Thread(() -> {
                for (int k = 0; k < consultas; k++) {
                    c.obtener("978-" + String.format("%010d", k % isbns));
                }
            }, "consultor-" + i);
            ts[i].start();
        }
        for (Thread t : ts) t.join();
        return (System.nanoTime() - inicio) / 1_000_000;
    }
}

Salida orientativa:

=== ReadWriteLock ===
  Tiempo   : 152 ms
  Entradas : 50
  aciertos=19 fallos=50 tasa=27.5%

=== synchronized total ===
  Tiempo   : 1043 ms
  Entradas : 50

Relacion: 6.9x a favor del ReadWriteLock

Análisis del resultado:

  1. La diferencia de 7× no viene del ReadWriteLock en sí, sino de sacar el cálculo fuera del candado. En la versión sincronizada, los 20 ms de cálculo ocurren con el candado exclusivo tomado, así que los 50 fallos iniciales se serializan: 50 × 20 ms = 1 segundo mínimo, con nueve hilos parados esperando. Es la regla 2 del apartado 9 costando un factor de siete.
  2. El ReadWriteLock aporta la segunda mitad: una vez llena la caché, las 19.950 consultas restantes son aciertos que se atienden en paralelo con el candado compartido. Con un candado exclusivo se serializarían también, aunque cada una durase microsegundos.
  3. Los 19 "aciertos" contados en la fase 3 son la carrera benigna: dos hilos fallaron sobre el mismo ISBN a la vez y ambos lo calcularon; el putIfAbsent conserva uno solo. Se hace trabajo duplicado pero el resultado es correcto. Evitarlo por completo requeriría bloquear durante el cálculo —justo lo que queremos evitar— o un ConcurrentHashMap.computeIfAbsent, que lo resuelve elegantemente y es 08-06.

Conclusión

Esta era la lección que sostiene el módulo, y con ella cierras el problema que abriste en 08-01.

Sabes por qué falla el contador. contador++ no es una operación: son tres instrucciones de bytecode —getfield, iadd, putfield— y cualquier entrelazado entre ellas pierde trabajo. Reconoces los dos patrones canónicos —leer-modificar-escribir y comprobar-luego-actuar— y sabes que ver cualquiera de ellos sobre estado compartido es ver un bug. Sabes qué operaciones son atómicas en Java y cuáles no, incluido el caso de long y double, cuya escritura la especificación permite partir en dos mitades.

Conoces el modelo de memoria de Java, que es lo que separa entender la concurrencia de aplicarla por superstición. Sabes que la memoria no es un cuaderno compartido: hay cachés por núcleo, el compilador reordena amparado en una garantía que solo vale dentro de un hilo, y la CPU también reordena. De ahí la consecuencia que más cuesta aceptar y que viste ejecutándose: un hilo puede escribir detener = true y otro no enterarse nunca, porque el JIT sacó la lectura fuera del bucle. Y sabes que la garantía real se expresa con la relación happens-before, con sus reglas —orden del programa, monitor, volatile, start, join, campos final, transitividad—, y con la pregunta que hay que hacerse ante cada dato compartido: "¿qué regla happens-before conecta estas dos acciones?". Si no hay respuesta, hay una carrera de datos.

Sabes exactamente qué hace volatile y qué no. Garantiza visibilidad y no reordenación, y hace atómicas las lecturas y escrituras de 64 bits. No hace atómico un incremento. Los dos ejemplos lo demuestran de forma incontestable: la bandera de parada que sin volatile cuelga el programa para siempre, y el contador que con volatile sigue perdiendo el 40 % de los incrementos —más lento y igual de incorrecto—.

Dominas synchronized. El monitor intrínseco que todo objeto lleva dentro, la liberación automática incluso ante excepciones, la reentrada que hace posible la herencia, y las dos garantías que da a la vez: exclusión mutua y visibilidad, esta última tan importante como la primera y sistemáticamente olvidada —por eso el getter también va sincronizado—. Conoces las tres formas y por qué la del bloque con candado privado y final gana a las otras dos: granularidad y no exponer el candado al mundo. Y conoces las trampas: el método de instancia y el estático usan monitores distintos, y un String literal o un Integer pequeño como candado es un objeto compartido con toda la JVM.

Tienes las cinco reglas de qué sincronizar: sección crítica corta, nunca E/S dentro del bloqueo, nunca código ajeno con el candado en la mano —copiar dentro, notificar fuera—, no sincronizar lo que no se comparte, y documentar la política con un /** Protegido por 'candado'. */ que cuesta una línea y ahorra horas.

Sabes provocar y resolver un interbloqueo. Las cuatro condiciones de Coffman, de las que solo la espera circular está en tu mano; el ejemplo reproducible de dos transferencias en sentidos opuestos, con los dos hilos en BLOCKED para siempre, sin excepción y sin consumo de CPU; y las tres soluciones por orden de preferencia: orden global de adquisición —garantía absoluta, coste cero, la respuesta correcta casi siempre—, tryLock con plazo y retroceso aleatorio cuando no puedes ordenar, y un solo candado más grueso cuando la contención lo permite. Con las dos patologías vecinas: la inanición, que se combate con secciones cortas y, si de verdad hace falta, con un candado equitativo caro; y el livelock, que se distingue del interbloqueo porque consume CPU al 100 % y jstack no lo declara.

Conoces el resto de la caja de herramientas: ReentrantLock con su lock(); try { } finally { unlock(); } obligatorio, y las cinco cosas que aporta —tryLock, plazo, lockInterruptibly, equidad y varias condiciones—, con la recomendación de usar synchronized por defecto y pasar a Lock solo cuando necesites una de ellas. Condition como sustituto de wait/notify, con la ventaja decisiva de tener varias colas de espera por candado, que hace seguro el signal() y elimina el despertar masivo de notifyAll. Y ReadWriteLock, con su tabla de compatibilidad, la proporción de al menos 5:1 que lo justifica, y la regla que se olvida: la degradación está permitida, la promoción es un interbloqueo instantáneo consigo mismo.

Y, sobre todo, tienes las dos estrategias que ganan a todas las anteriores. La inmutabilidad: un objeto con todos sus campos final que no deja escapar this es seguro para cualquier número de hilos, sin candados, para siempre —y los record de 04-07 lo son por construcción, con el detalle del constructor compacto que copia las colecciones—. Y el confinamiento: la mejor sincronización es no compartir, ya sea en la pila, con ThreadLocal —con su aviso sobre pools y fugas— o dividiendo el trabajo y combinando al final. Con la jerarquía que deberías recorrer siempre de arriba abajo: no compartir, compartir inmutable, volatile, atómico, colección concurrente, candado.

BiblioTech ya soporta dos empleados a la vez. CatalogoSeguro protege sus tres estructuras con un ReadWriteLock que deja pasar a todos los lectores simultáneamente y serializa solo las altas, y devuelve copias en lugar de sus colecciones internas. RegistroPrestamosSeguro mantiene el invariante entre porId y porEmpleado bajo un único candado —porque un invariante que abarca dos estructuras no se puede sostener con piezas independientes—, y expone registrarSiHayCupo como operación compuesta atómica, porque obligar al cliente a escribir if (activos() < MAX) registrar(p) sería regalarle una carrera. Ocho hilos y cuarenta mil operaciones dan el resultado exacto, diez veces de diez. El caso C de 08-01 está cerrado.

Pero mira cómo has llegado hasta aquí: candados a mano, finally que no se pueden olvidar, órdenes de adquisición que hay que documentar y respetar, y —lo más caro de todo— un hilo creado a mano por cada tarea. Los 200 avisos de BiblioTech siguen necesitando 200 hilos, cada uno con su megabyte de pila. Escribir concurrencia correcta a este nivel es posible, pero es artesanal y frágil, y toda la industria lleva veinte años sin hacerlo así.

En la próxima lección, Utilidades de Concurrencia, subes por fin de nivel. Verás ExecutorService y las fábricas de Executors —pools fijos, elásticos, de un solo hilo y programados—, con la tabla de cuándo usar cada uno y la advertencia sobre las colas ilimitadas que tumban aplicaciones; cómo construir un ThreadPoolExecutor a medida con su ThreadFactory para nombrar hilos y su política de rechazo; el ciclo de vida correcto de un ejecutor con shutdown, shutdownNow y awaitTermination; Callable y Future, que por fin permiten a una tarea devolver un resultado y propagar su excepción, con get() bloqueante y con plazo, y cancel(true) que no es otra cosa que la interrupción de 08-02; ScheduledExecutorService para los avisos periódicos de vencimiento; y los sincronizadores —CountDownLatch, CyclicBarrier, Semaphore— que sustituyen las coordinaciones a mano por piezas probadas. Al terminar, los doscientos avisos de BiblioTech se enviarán con un pool acotado, con barra de progreso y con cancelación limpia, en unos pocos segundos y con ocho hilos en lugar de doscientos.

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