En la lección anterior arrancaste hilos con start(), los esperaste con join() y los cancelaste con interrupt(). En el camino aparecieron dos valores de getState()NEW y TERMINATED— sin explicar qué son ni cuántos más hay.

Esta lección los explica todos. Un hilo de Java está siempre en exactamente uno de seis estados, y saber en cuál está y por qué es la diferencia entre diagnosticar un problema de producción en cinco minutos o en dos días. Cuando una aplicación "se cuelga", la pregunta no es "¿por qué no funciona?" sino "¿en qué estado están sus hilos y qué están esperando?". Esa pregunta se responde con un volcado de hilos, y leerlo es una habilidad que vale por sí sola el precio de esta lección.

Verás también el mecanismo de coordinación de más bajo nivel que existe en Java —wait, notify y notifyAll—, que es lo que hay debajo de todas las utilidades cómodas de 08-05 y 08-06; por qué hay que envolverlo siempre en un bucle while y por qué un if ahí es un error real, no una preferencia de estilo; y por qué tres métodos de Thread que parecían muy útiles —stop, suspend y resume— llevan más de veinte años marcados como peligrosos.

Advertencia de nivel. wait/notify es la única parte de este módulo que probablemente nunca escribirás en producción: BlockingQueue y CountDownLatch lo hacen mejor y sin errores. Se estudia porque explica cómo funcionan esas herramientas y porque aparece constantemente al leer código ajeno y volcados de hilos.

Contenido

  1. Los seis estados de Thread.State
  2. El diagrama completo del ciclo de vida
  3. NEW y TERMINATED: los extremos
  4. RUNNABLE: el matiz que confunde a todo el mundo
  5. BLOCKED frente a WAITING: la distinción que hace legible un volcado
  6. TIMED_WAITING: esperar con plazo
  7. wait, notify y notifyAll
  8. El bucle while obligatorio
  9. notify frente a notifyAll
  10. yield y onSpinWait
  11. Los métodos retirados: stop, suspend, resume
  12. Diagnóstico en la práctica: el volcado de hilos
  13. Leer un interbloqueo detectado por la JVM
  14. Inspección desde el propio programa
  15. BiblioTech: seguir el estado de sus hilos
  16. Errores Comunes y Consejos
  17. Ejercicios

  1. Los seis estados de Thread.State

Thread.State es un enum —de los de 04-07— con seis constantes. Un hilo está siempre en una y solo una de ellas.

Estado Significado Cómo se llega Cómo se sale
NEW Objeto Thread creado, aún no arrancado new Thread(...) start()
RUNNABLE Ejecutando, o listo para ejecutar y esperando núcleo start(); volver de una espera Terminar run(); bloquearse; esperar
BLOCKED Esperando a adquirir el monitor de un objeto Entrar en synchronized ocupado El monitor queda libre y se adquiere
WAITING Esperando indefinidamente una señal de otro hilo wait(), join(), LockSupport.park() notify/notifyAll, fin del hilo esperado, interrupción
TIMED_WAITING Igual que WAITING pero con plazo sleep(ms), wait(ms), join(ms), parkNanos Señal, plazo agotado o interrupción
TERMINATED run() ha terminado (bien o con excepción) Retorno o excepción no capturada de run() Nunca: es terminal

Dos hechos que conviene fijar desde el principio:

  • TERMINATED es irreversible. Un hilo terminado no se puede reiniciar. start() sobre él lanza IllegalThreadStateException. Esa es la razón profunda por la que existen los pools: si el hilo no puede reutilizarse, hay que reutilizar el hilo, no el objeto — es decir, mantenerlo vivo dándole tareas nuevas.
  • Thread.State es una vista de la JVM, no del sistema operativo. El estado que ves es lo que la JVM sabe; el planificador del sistema tiene su propia idea, más fina.

  1. El diagrama completo del ciclo de vida

stateDiagram-v2
    [*] --> NEW : new Thread(tarea)

    NEW --> RUNNABLE : start()

    RUNNABLE --> BLOCKED : entra en synchronized ocupado
    BLOCKED --> RUNNABLE : obtiene el monitor

    RUNNABLE --> WAITING : wait()
    RUNNABLE --> WAITING : join() sin plazo
    RUNNABLE --> WAITING : LockSupport.park()
    WAITING --> RUNNABLE : notify o notifyAll
    WAITING --> RUNNABLE : termina el hilo esperado
    WAITING --> RUNNABLE : interrupt()

    RUNNABLE --> TIMED_WAITING : sleep(ms)
    RUNNABLE --> TIMED_WAITING : wait(ms)
    RUNNABLE --> TIMED_WAITING : join(ms)
    TIMED_WAITING --> RUNNABLE : plazo agotado
    TIMED_WAITING --> RUNNABLE : notify o interrupt

    RUNNABLE --> TERMINATED : run() retorna
    RUNNABLE --> TERMINATED : excepcion no capturada

    TERMINATED --> [*]

Dos observaciones estructurales sobre este diagrama:

Primera: todo pasa por RUNNABLE. No hay ninguna transición directa entre BLOCKED, WAITING y TIMED_WAITING. Un hilo que está esperando una señal y luego necesita un monitor pasa por RUNNABLE en medio. Esto importa al leer volcados: un hilo que sale de wait() debe readquirir el monitor antes de continuar, y en ese instante puede quedar BLOCKED.

Segunda: solo hay una salida de TERMINATED, y es hacia fuera. No hay flecha de vuelta. Es el punto que hace que "reutilizar un hilo" sea imposible por diseño.

Este pequeño programa recorre casi todos los estados:

import java.util.concurrent.TimeUnit;

public class RecorridoDeEstados {

    private static final Object candado = new Object();

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

        Thread observado = new Thread(() -> {
            try {
                TimeUnit.MILLISECONDS.sleep(300);      // TIMED_WAITING
                synchronized (candado) {               // BLOCKED (si esta ocupado)
                    candado.wait(500);                 // TIMED_WAITING
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "hilo-observado");

        System.out.println("Antes de start()   : " + observado.getState());  // NEW
        observado.start();
        System.out.println("Justo tras start() : " + observado.getState());  // RUNNABLE

        TimeUnit.MILLISECONDS.sleep(100);
        System.out.println("Durmiendo          : " + observado.getState());  // TIMED_WAITING

        // Tomamos el candado para que el hilo observado quede BLOCKED al pedirlo.
        synchronized (candado) {
            TimeUnit.MILLISECONDS.sleep(400);
            System.out.println("Candado ocupado    : " + observado.getState()); // BLOCKED
        }

        TimeUnit.MILLISECONDS.sleep(100);
        System.out.println("Dentro de wait(500): " + observado.getState());  // TIMED_WAITING

        observado.join();
        System.out.println("Tras join()        : " + observado.getState());  // TERMINATED
    }
}

Salida:

Antes de start()   : NEW
Justo tras start() : RUNNABLE
Durmiendo          : TIMED_WAITING
Candado ocupado    : BLOCKED
Dentro de wait(500): TIMED_WAITING
Tras join()        : TERMINATED

Este programa es intrínsecamente frágil y es honesto decirlo: depende de que los sleep del observador caigan en los momentos justos. En una máquina cargada podría imprimir otra cosa. Sirve para ver los estados, no como técnica de diagnóstico: para eso están los volcados del apartado 12.

  1. NEW y TERMINATED: los extremos

NEW es el estado del objeto Thread recién construido. El hilo del sistema operativo todavía no existe: new Thread(...) solo reserva un objeto Java. El hilo nativo se crea en start().

De aquí sale el diagnóstico del error de 08-02: si un hilo aparece como NEW cuando esperabas que estuviera trabajando, es que nadie llamó a start() —o que se llamó a run() por error—.

TERMINATED es el estado tras el fin de run(), por cualquiera de sus dos vías:

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

        Thread bien = new Thread(() -> System.out.println("[bien] trabajo hecho"), "bien");
        Thread mal  = new Thread(() -> { throw new RuntimeException("fallo"); }, "mal");

        bien.start(); bien.join();
        mal.start();  mal.join();

        System.out.println("bien -> " + bien.getState());   // TERMINATED
        System.out.println("mal  -> " + mal.getState());    // TERMINATED
    }
}

Ambos acaban en TERMINATED. El estado no distingue el éxito del fallo, y esa es exactamente la trampa de 08-02: join() retorna con normalidad tanto si el hilo hizo su trabajo como si murió por una excepción. Para distinguirlos necesitas o bien un manejador de excepciones no capturadas, o bien un Future (08-05), que sí guarda la excepción y te la relanza en get().

  1. RUNNABLE: el matiz que confunde a todo el mundo

Muchos manuales dibujan un estado RUNNING separado de RUNNABLE. Java no lo tiene. Thread.State.RUNNABLE agrupa dos situaciones muy distintas:

  1. Ejecutando ahora mismo en un núcleo.
  2. Listo para ejecutar, en la cola del planificador, esperando que le den un núcleo.

La JVM no las distingue porque no puede: quién ejecuta en cada instante lo decide el planificador del sistema operativo, sin informar a la JVM. Con ocho hilos RUNNABLE en una máquina de cuatro núcleos, en cualquier instante hay a lo sumo cuatro ejecutando de verdad y al menos cuatro esperando turno, y todos aparecen como RUNNABLE.

Hay una tercera situación, aún más contraintuitiva:

Un hilo bloqueado en E/S clásica aparece como RUNNABLE.

Un hilo parado en InputStream.read() esperando al disco lleva un rato sin ejecutar ni una instrucción, pero getState() dice RUNNABLE. La razón es que la JVM no tiene ni idea de que ese hilo está aparcado dentro de una llamada al sistema: desde su punto de vista, el hilo está ejecutando código nativo.

// Este hilo puede pasar 30 segundos aqui y su estado sera RUNNABLE
// todo el tiempo, aunque no consuma ni un ciclo de CPU.
Thread lector = new Thread(() -> {
    try (BufferedReader br = Files.newBufferedReader(Path.of("datos/enorme.csv"))) {
        String linea;
        while ((linea = br.readLine()) != null) { procesar(linea); }
    } catch (IOException e) { /* ... */ }
}, "bibliotech-lector");

Consecuencia práctica para diagnosticar: ver muchos hilos RUNNABLE no significa que la CPU esté saturada. Hay que mirar la traza de pila:

  • Si la cima es java.net.SocketInputStream.socketRead0 o FileInputStream.readBytes, ese hilo está esperando E/S, no calculando.
  • Si la cima es código tuyo en un bucle, entonces sí está consumiendo CPU.

Esta distinción es la que separa "necesito más núcleos" de "necesito más hilos porque estoy esperando al disco" —la tabla de tareas limitadas por CPU y por E/S de 08-01, vista ahora desde el otro lado—.

  1. BLOCKED frente a WAITING: la distinción que hace legible un volcado

Esta es la distinción más valiosa de la lección para el trabajo real.

BLOCKED WAITING
Qué espera Un monitor (la llave de un synchronized) Una señal de otro hilo
Cómo se llega Intentar entrar en un synchronized ocupado wait(), join(), LockSupport.park()
Cómo se sale El dueño del monitor lo suelta Otro hilo llama a notify/notifyAll, termina el hilo esperado, o llega una interrupción
¿Depende de otro hilo? Sí, del que tiene el monitor Sí, del que debe señalizar
En un volcado waiting to lock <0x...> in Object.wait() / parking to wait for <0x...>
Qué suele indicar Contención: muchos hilos peleando por un candado Coordinación: cola vacía, tarea pendiente, join
Si dura mucho Sospecha de interbloqueo o sección crítica larga Normal en un pool ocioso; sospechoso si nadie va a señalizar

La forma corta de recordarlo:

BLOCKED = "no me dejan entrar". WAITING = "estoy esperando a que me avisen".

Demostración de los dos estados a la vez:

import java.util.concurrent.TimeUnit;

public class BloqueadoVsEsperando {

    private static final Object candado = new Object();

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

        // Hilo 1: toma el candado y se queda dentro haciendo wait().
        // Suelta el monitor al entrar en wait(), pero queda WAITING.
        Thread esperador = new Thread(() -> {
            synchronized (candado) {
                try {
                    candado.wait();          // WAITING (y SUELTA el monitor)
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "hilo-esperador");

        // Hilo 2: toma el candado y lo retiene durmiendo.
        // sleep NO suelta el monitor (08-02, apartado 8).
        Thread acaparador = new Thread(() -> {
            synchronized (candado) {
                try {
                    TimeUnit.SECONDS.sleep(5);   // TIMED_WAITING, con el monitor
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "hilo-acaparador");

        // Hilo 3: quiere el candado y no lo consigue -> BLOCKED.
        Thread bloqueado = new Thread(() -> {
            synchronized (candado) {
                System.out.println("[bloqueado] por fin he entrado");
            }
        }, "hilo-bloqueado");

        esperador.start();
        TimeUnit.MILLISECONDS.sleep(200);   // que llegue a wait()
        acaparador.start();
        TimeUnit.MILLISECONDS.sleep(200);   // que tome el candado
        bloqueado.start();
        TimeUnit.MILLISECONDS.sleep(200);   // que choque con el candado

        System.out.println("esperador  : " + esperador.getState());
        System.out.println("acaparador : " + acaparador.getState());
        System.out.println("bloqueado  : " + bloqueado.getState());

        // Limpieza
        acaparador.interrupt();
        synchronized (candado) { candado.notifyAll(); }
    }
}

Salida:

esperador  : WAITING
acaparador : TIMED_WAITING
bloqueado  : BLOCKED

Los tres estados a la vez, y cada uno por un motivo distinto. Fíjate en el detalle crucial de esperador: entró en el synchronized, o sea, tenía el monitor, y al llamar a wait() lo soltó. Por eso acaparador pudo entrar después. Esta es la diferencia esencial entre wait() y sleep():

Thread.sleep(ms) objeto.wait()
¿Suelta el monitor? No
Dónde se puede llamar En cualquier sitio Solo dentro de synchronized sobre ese objeto
Cómo despierta Plazo agotado o interrupción notify/notifyAll, plazo, interrupción o espurio
Estado resultante TIMED_WAITING WAITING o TIMED_WAITING
Para qué sirve Pausar Coordinar

  1. TIMED_WAITING: esperar con plazo

TIMED_WAITING es la versión con plazo de WAITING. Los métodos que llevan a él:

Thread.sleep(1000);                        // TIMED_WAITING
TimeUnit.SECONDS.sleep(1);                 // idem
objeto.wait(1000);                         // TIMED_WAITING, sueltas el monitor
hilo.join(1000);                           // TIMED_WAITING
LockSupport.parkNanos(1_000_000_000L);     // TIMED_WAITING
bloqueo.tryLock(1, TimeUnit.SECONDS);      // TIMED_WAITING (08-04)
cola.poll(1, TimeUnit.SECONDS);            // TIMED_WAITING (08-06)

En un volcado, TIMED_WAITING (sleeping) y TIMED_WAITING (on object monitor) se distinguen entre sí, lo cual es útil: el primero es un hilo que decidió pausarse, el segundo es un hilo esperando una señal con plazo.

El detalle de diagnóstico que salva horas: un hilo permanentemente en TIMED_WAITING (sleeping) dentro de un bucle es casi siempre un sondeo (polling), y casi siempre es un diseño mejorable:

// ANTIPATRON: sondeo. El hilo despierta 10 veces por segundo
// solo para comprobar si hay algo, y normalmente no lo hay.
while (colaReservas.isEmpty()) {
    Thread.sleep(100);
}
Reserva r = colaReservas.poll();

// CORRECTO: bloquearse hasta que haya algo, sin despertar en vano (08-06).
Reserva r = colaReservas.take();   // BlockingQueue

  1. wait, notify y notifyAll

Estos tres métodos no están en Thread: están en Object, la clase de 03-09. Cualquier objeto de Java puede servir de punto de coordinación, porque cualquier objeto tiene un monitor asociado.

Método Qué hace
objeto.wait() Suelta el monitor de objeto y deja el hilo en WAITING hasta recibir una señal
objeto.wait(ms) Igual, pero despierta también al agotarse el plazo (TIMED_WAITING)
objeto.notify() Despierta un hilo (arbitrario) de los que esperan en objeto
objeto.notifyAll() Despierta todos los hilos que esperan en objeto

La regla número uno, que el compilador no comprueba:

Los tres métodos deben llamarse con el monitor de ese objeto en la mano, es decir, dentro de un bloque o método synchronized sobre ese mismo objeto. Si no, se lanza IllegalMonitorStateException en tiempo de ejecución.

Object candado = new Object();

// MAL: sin el monitor -> IllegalMonitorStateException
candado.wait();

// BIEN
synchronized (candado) {
    candado.wait();
}

Por qué esa regla existe —y es una pregunta de entrevista clásica—: sin ella habría una condición de carrera irreparable. Imagina que pudieras hacer wait() sin el monitor:

  1. El consumidor comprueba cola.isEmpty()true.
  2. Justo aquí el productor añade un elemento y llama a notify().
  3. El consumidor llama a wait()… y se pierde la señal, porque llegó antes de que empezara a esperar.

Resultado: el consumidor espera para siempre con la cola llena. Exigir el monitor hace que "comprobar la condición" y "empezar a esperar" sean una sola operación indivisible desde el punto de vista del productor, porque este necesita el mismo monitor para señalizar.

Ejemplo mínimo de coordinación:

import java.util.ArrayDeque;
import java.util.Deque;

public class CoordinacionBasica {

    // El objeto de bloqueo Y de senalizacion: la propia cola.
    private static final Deque<String> reservas = new ArrayDeque<>();

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

        Thread consumidor = new Thread(() -> {
            try {
                synchronized (reservas) {
                    // BUCLE while, no if. Apartado 8.
                    while (reservas.isEmpty()) {
                        System.out.println("[consumidor] cola vacia, espero");
                        reservas.wait();          // suelta el monitor y espera
                    }
                    System.out.println("[consumidor] atiendo: " + reservas.poll());
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "bibliotech-consumidor");

        Thread productor = new Thread(() -> {
            try {
                Thread.sleep(500);
                synchronized (reservas) {
                    reservas.add("Reserva de Marta Ruiz: 978-0000000001");
                    System.out.println("[productor] reserva anadida, aviso");
                    reservas.notifyAll();          // despierta a los que esperan
                }                                  // el monitor se suelta AQUI
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "bibliotech-productor");

        consumidor.start();
        productor.start();
        consumidor.join();
        productor.join();
    }
}

Salida:

[consumidor] cola vacia, espero
[productor] reserva anadida, aviso
[consumidor] atiendo: Reserva de Marta Ruiz: 978-0000000001

Un detalle que se pasa por alto: notifyAll() no suelta el monitor. El hilo señalizador sigue dentro del synchronized hasta la llave de cierre. El consumidor despierta, pero no puede continuar hasta que el monitor quede libre; mientras tanto pasa de WAITING a BLOCKED. Es la transición del apartado 2 —todo pasa por RUNNABLE— vista en acción, y la razón por la que conviene señalizar al final del bloque, no al principio.

Nota de precedencia. synchronized se estudia a fondo en 08-04: qué es exactamente un monitor, la reentrada, el bloqueo privado y la granularidad. Aquí basta con la regla operativa: para llamar a wait, notify o notifyAll sobre un objeto, hay que estar dentro de un synchronized sobre ese mismo objeto.

  1. El bucle while obligatorio

Esta es la regla que más se incumple y la que produce errores más difíciles de reproducir:

wait() va SIEMPRE dentro de un bucle while que comprueba la condición. Nunca dentro de un if.

// MAL - un bug real, no una cuestion de estilo
synchronized (reservas) {
    if (reservas.isEmpty()) {
        reservas.wait();
    }
    Reserva r = reservas.poll();   // puede ser null
    atender(r);                    // NullPointerException
}

// BIEN
synchronized (reservas) {
    while (reservas.isEmpty()) {
        reservas.wait();
    }
    Reserva r = reservas.poll();   // garantizado no nulo
    atender(r);
}

Hay tres motivos independientes, y cada uno bastaría por sí solo:

Motivo 1: despertares espurios. La especificación de Java permite explícitamente que wait() retorne sin que nadie haya llamado a notify. No es un fallo de implementación: es una concesión deliberada a cómo funcionan las primitivas de sincronización de los sistemas operativos subyacentes (pthread_cond_wait tiene la misma advertencia). Son raros, pero ocurren, y ocurren más en máquinas cargadas —o sea, en producción y no en tu portátil—.

Motivo 2: notifyAll despierta a todos, pero solo uno puede ganar. Con tres consumidores esperando y un solo elemento en la cola, notifyAll() despierta a los tres. El primero en readquirir el monitor se lleva el elemento; los otros dos vuelven a comprobar y —con while— vuelven a esperar. Con if, siguen adelante y encuentran la cola vacía.

Motivo 3: la condición puede haber cambiado entre la señal y el despertar. Entre notify() y el momento en que el hilo despertado readquiere el monitor pasa tiempo, y en ese tiempo otro hilo puede haber entrado y consumido el elemento. La señal dice "algo cambió", no "tu condición se cumple ahora".

El tercer motivo es el más importante y el que hace del while una necesidad lógica, no una precaución: notify no transmite información sobre el estado, solo dice "vuelve a mirar". La única fuente de verdad es la condición, y por eso hay que reevaluarla.

Demostración del fallo con if:

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.TimeUnit;

public class ElBugDelIf {

    private static final Deque<Integer> cola = new ArrayDeque<>();

    static Runnable consumidorConIf = () -> {
        try {
            synchronized (cola) {
                if (cola.isEmpty()) {       // BUG: deberia ser while
                    cola.wait();
                }
                Integer v = cola.poll();
                System.out.println("[" + Thread.currentThread().getName()
                        + "] consume: " + v
                        + (v == null ? "   <-- FALLO: cola vacia" : ""));
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    };

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

        // Tres consumidores esperando...
        for (int i = 1; i <= 3; i++) {
            new Thread(consumidorConIf, "consumidor-" + i).start();
        }
        TimeUnit.MILLISECONDS.sleep(300);

        // ...y UN solo elemento con notifyAll.
        synchronized (cola) {
            cola.add(42);
            cola.notifyAll();      // despierta a los TRES
        }

        TimeUnit.MILLISECONDS.sleep(500);
    }
}

Salida:

[consumidor-1] consume: 42
[consumidor-2] consume: null   <-- FALLO: cola vacia
[consumidor-3] consume: null   <-- FALLO: cola vacia

Dos de los tres consumidores obtienen null y en un programa real eso sería un NullPointerException en la siguiente línea. Cambia if por while y ejecuta de nuevo: los consumidores 2 y 3 vuelven a esperar en silencio, que es exactamente lo correcto.

  1. notify frente a notifyAll

notify() notifyAll()
A cuántos despierta A uno, elegido arbitrariamente A todos los que esperan en ese objeto
Coste Menor Mayor: todos despiertan y compiten por el monitor
Riesgo Señal perdida: puede despertar al hilo equivocado Ninguno de corrección; solo ineficiencia
Cuándo es seguro Todos los que esperan son intercambiables y esperan la misma condición Siempre

La recomendación es tajante: usa notifyAll() salvo que puedas demostrar que es seguro usar notify().

El peligro de notify() es el despertar perdido. Si en el mismo objeto esperan hilos por condiciones distintas —unos porque la cola está vacía, otros porque está llena—, notify() puede despertar precisamente a uno que no puede avanzar. Ese hilo comprueba su condición, sigue sin cumplirse, vuelve a wait()… y la señal se ha consumido. El hilo que sí podía avanzar nunca fue avisado, y el sistema se queda parado sin ningún error ni excepción.

// PELIGROSO: dos condiciones distintas esperando en el MISMO objeto
synchronized (buffer) {
    while (buffer.estaLleno()) buffer.wait();   // productores
    // ...
    buffer.notify();                            // puede despertar a OTRO productor
}
synchronized (buffer) {
    while (buffer.estaVacio()) buffer.wait();   // consumidores
    // ...
    buffer.notify();                            // puede despertar a OTRO consumidor
}

En ese diseño, notify() puede acabar en un bloqueo total del sistema. Con notifyAll() el problema desaparece: todos comprueban, los que pueden avanzan, los que no vuelven a esperar. Se paga un poco de coste por una garantía de corrección; casi siempre es el mejor cambio posible.

La solución elegante —una espera por cada condición— existe: son los objetos Condition de ReentrantLock, que permiten signal() sobre exactamente el grupo correcto de hilos. Se ven en 08-04.

  1. yield y onSpinWait

Dos métodos menores, en una nota.

Thread.yield() es una sugerencia al planificador: "si hay otro hilo listo, dale paso". No garantiza nada; el planificador puede ignorarlo por completo, y el hilo puede volver a ser elegido inmediatamente. No cambia el estado (sigue RUNNABLE) y no suelta ningún monitor. No lo uses para corrección; su lugar legítimo son las pruebas de estrés, donde forzar más cambios de contexto ayuda a que aparezcan las condiciones de carrera latentes.

// Uso legitimo: en una prueba, para provocar entrelazados distintos
// y aumentar la probabilidad de que un bug de carrera se manifieste.
for (int i = 0; i < 1000; i++) {
    contador.incrementar();
    if (i % 10 == 0) Thread.yield();
}

Thread.onSpinWait() (Java 9) es una pista para la CPU dentro de una espera activa muy corta: emite una instrucción como PAUSE en x86, que reduce el consumo energético y mejora el rendimiento al salir del bucle. Solo tiene sentido en bucles de espera de nanosegundos y en código de muy bajo nivel:

// Espera activa MUY corta, en codigo de alto rendimiento.
// En codigo de aplicacion normal esto es un antipatron:
// consume un nucleo entero. Usa wait/notify o una BlockingQueue.
while (!listo) {
    Thread.onSpinWait();
}

Ninguno de los dos resuelve un problema de coordinación. Si crees que necesitas yield() para que tu programa funcione, lo que necesitas es sincronización.

  1. Los métodos retirados: stop, suspend, resume

Thread tiene tres métodos que hacían exactamente lo que uno querría —parar un hilo, pausarlo y reanudarlo— y que están marcados como obsoletos desde Java 1.2 (1998). En Java 20 stop() pasó a lanzar UnsupportedOperationException siempre, y suspend/resume fueron marcados para eliminación. Vale la pena entender por qué, porque el motivo enseña algo real.

Thread.stop(): mataba el hilo lanzándole un ThreadDeath en el punto en que estuviera.

El problema: suelta todos sus monitores instantáneamente, en mitad de lo que estuviera haciendo.

// Supongamos que el hilo esta AQUI cuando le llega stop():
synchronized (catalogo) {
    catalogo.eliminarIsbn(isbn);          // ya se ha ejecutado
    // <-- stop() cae justo aqui
    catalogo.eliminarDelIndice(isbn);     // NUNCA se ejecuta
}

Resultado: el ISBN se quitó del conjunto pero no del índice. El catálogo queda incoherente, el monitor se libera y los demás hilos entran tan tranquilos a leer una estructura corrupta. Y no hay ninguna excepción ni traza que lo indique: el fallo aparece más tarde, en otro sitio, sin relación aparente.

Es exactamente el problema que resuelve la cancelación cooperativa de 08-02: interrupt() no interrumpe donde le da la gana, sino en los puntos que el propio hilo ha designado, donde el estado es coherente.

Thread.suspend() y resume(): pausaban y reanudaban un hilo.

El problema: suspend() no suelta los monitores. Un hilo suspendido dentro de un synchronized mantiene el candado indefinidamente. Si el hilo que iba a llamar a resume() necesita ese mismo candado para llegar hasta ahí, interbloqueo perfecto, sin error ni traza.

// La receta del interbloqueo:
hiloTrabajador.suspend();     // suspendido reteniendo el monitor de 'catalogo'
synchronized (catalogo) {     // main queda BLOCKED aqui, para siempre
    hiloTrabajador.resume();  // esta linea nunca se alcanza
}

Qué se usa en su lugar:

Método retirado Sustituto
stop() interrupt() + protocolo cooperativo (08-02)
suspend() / resume() Bandera volatile + wait/notify, o Semaphore/CyclicBarrier (08-05)
destroy() Nunca llegó a implementarse; eliminado

Pausa y reanudación bien hechas:

public class TareaPausable implements Runnable {

    private final Object monitor = new Object();
    private boolean pausada = false;      // protegido por 'monitor'
    private volatile boolean detenida = false;

    public void pausar()  { synchronized (monitor) { pausada = true; } }

    public void reanudar() {
        synchronized (monitor) {
            pausada = false;
            monitor.notifyAll();          // despierta al trabajador
        }
    }

    public void detener() { detenida = true; }

    @Override
    public void run() {
        try {
            while (!detenida && !Thread.currentThread().isInterrupted()) {

                // PUNTO DE PAUSA elegido por el propio hilo: aqui el
                // estado es coherente y no se retiene ningun candado
                // de negocio. Nada que ver con suspend().
                synchronized (monitor) {
                    while (pausada && !detenida) {
                        monitor.wait();   // suelta 'monitor', queda WAITING
                    }
                }

                procesarUnLote();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    private void procesarUnLote() { /* trabajo */ }
}

La diferencia esencial con suspend(): el hilo elige dónde se pausa. En ese punto el estado del negocio es coherente y no se retiene ningún candado del catálogo. Es el mismo principio que la interrupción cooperativa.

  1. Diagnóstico en la práctica: el volcado de hilos

Llegamos a la parte que usarás de verdad. Un volcado de hilos (thread dump) es una foto instantánea de todos los hilos de la JVM: su nombre, su estado, su traza de pila y los candados que tienen o esperan. Es la herramienta número uno para diagnosticar una aplicación colgada o lenta.

12.1 Cómo obtenerlo

Opción A: Ctrl+\ en el terminal (Linux/macOS; en Windows, Ctrl+Break). La JVM imprime el volcado en System.err. No requiere herramientas y no mata el proceso.

Opción B: jstack, incluido en el JDK. Es la opción profesional:

# 1. Localizar el proceso Java
jps -l
# 48213 com.nexussoftware.bibliotech.BiblioTechApp
# 48291 jdk.jcmd/sun.tools.jps.Jps

# 2. Volcar sus hilos
jstack 48213

# 3. Guardarlo para analizarlo con calma
jstack 48213 > /tmp/volcado-1.txt

# 4. Tomar TRES volcados separados por 5 segundos:
#    comparar los tres distingue un hilo atascado de uno que avanza
for i in 1 2 3; do jstack 48213 > /tmp/volcado-$i.txt; sleep 5; done

Opción C: jcmd, más moderno y con más información:

jcmd 48213 Thread.print
jcmd 48213 Thread.print -l    # incluye los candados poseídos

El consejo operativo que más vale de todo el apartado: toma siempre TRES volcados separados por unos segundos. Un volcado aislado te dice dónde está cada hilo en ese instante, y un hilo puede estar de paso. Tres volcados donde el mismo hilo aparece en la misma línea significan que está atascado, no ocupado. Es la diferencia entre un diagnóstico y una conjetura.

12.2 Anatomía de una entrada

"bibliotech-importador" #21 prio=5 os_prio=0 cpu=1243.55ms elapsed=18.32s tid=0x00007f3c1c0e1800 nid=0x5f2a waiting for monitor entry  [0x00007f3c0a1fe000]
   java.lang.Thread.State: BLOCKED (on object monitor)
        at com.nexussoftware.bibliotech.servicio.Catalogo.anadir(Catalogo.java:88)
        - waiting to lock <0x000000071ab34d18> (a com.nexussoftware.bibliotech.servicio.Catalogo)
        at com.nexussoftware.bibliotech.persistencia.TareaImportacion.run(TareaImportacion.java:64)
        at java.base/java.lang.Thread.run(Thread.java:1583)

Campo a campo:

Campo Significado
"bibliotech-importador" El nombre del hilo. Aquí se cobra el consejo de 08-02
#21 Identificador del hilo en la JVM
prio=5 Prioridad Java (recuerda 08-02: apenas significa nada)
cpu=1243.55ms CPU consumida en total. Un hilo "atascado" con CPU creciente está en un bucle, no bloqueado
elapsed=18.32s Tiempo de vida del hilo
nid=0x5f2a Identificador nativo, en hexadecimal. Se cruza con top -H para ver qué hilo quema CPU
waiting for monitor entry Resumen textual del estado
java.lang.Thread.State: BLOCKED El estado del apartado 1
- waiting to lock <0x...> Qué candado espera, con su identidad y su clase
at ... La traza de pila: dónde está exactamente

Las tres líneas que hay que mirar primero, en este orden: el estado, el - waiting to lock / - locked, y la primera línea at de tu propio código (ignorando las de java.base).

Formas habituales:

--- Un hilo TRABAJANDO de verdad (consume CPU) ---
"bibliotech-calculo" #24 ... cpu=8420.11ms ... runnable
   java.lang.Thread.State: RUNNABLE
        at com.nexussoftware.bibliotech.servicio.CalculadoraMultas.recalcular(CalculadoraMultas.java:52)

--- Un hilo esperando E/S: RUNNABLE pero SIN consumir CPU ---
"bibliotech-lector" #25 ... cpu=12.30ms ... runnable
   java.lang.Thread.State: RUNNABLE
        at java.base/java.io.FileInputStream.readBytes(Native Method)
        at java.base/java.io.BufferedInputStream.read(BufferedInputStream.java:...)

--- Un hilo de pool OCIOSO: normal, no es un problema ---
"bibliotech-avisos-3" #31 ... cpu=340.02ms ... waiting on condition
   java.lang.Thread.State: WAITING (parking)
        - parking to wait for <0x000000071b0c4470> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
        at java.base/java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:...)
        at java.base/java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:...)

--- Un hilo DORMIDO ---
"bibliotech-monitor" #33 ... sleeping
   java.lang.Thread.State: TIMED_WAITING (sleeping)
        at java.base/java.lang.Thread.sleep(Native Method)

Cómo leer estos cuatro patrones:

  • RUNNABLE con cpu alta y creciente entre volcados: trabajando o en un bucle infinito. Compara los tres volcados: si la traza no cambia y la CPU sube, es un bucle sin salida.
  • RUNNABLE con cpu baja y readBytes en la cima: esperando E/S. No es un problema de CPU; es el caso de tarea limitada por E/S de 08-01.
  • WAITING (parking) en getTask de un ThreadPoolExecutor: hilo de pool esperando trabajo. Es lo normal, no lo persigas. Los verás en 08-05 a decenas.
  • BLOCKED (on object monitor) en varios hilos sobre el mismo <0x...>: contención. Si además dura entre los tres volcados, busca quién posee ese candado —aparecerá con - locked <ese mismo 0x...>— y por qué tarda tanto.

  1. Leer un interbloqueo detectado por la JVM

Lo mejor de los volcados es que la JVM detecta los interbloqueos de monitores por ti y los explica al final del volcado. Este es el aspecto que tiene, con código de BiblioTech:

public class InterbloqueoBiblioTech {

    private static final Object CATALOGO = new Object();
    private static final Object REGISTRO = new Object();

    public static void main(String[] args) {

        // Hilo A: catalogo -> registro
        new Thread(() -> {
            synchronized (CATALOGO) {
                dormir(200);
                synchronized (REGISTRO) {
                    System.out.println("A: nunca llega aqui");
                }
            }
        }, "bibliotech-prestamos").start();

        // Hilo B: registro -> catalogo   (ORDEN INVERSO: la causa)
        new Thread(() -> {
            synchronized (REGISTRO) {
                dormir(200);
                synchronized (CATALOGO) {
                    System.out.println("B: nunca llega aqui");
                }
            }
        }, "bibliotech-devoluciones").start();
    }

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

Este programa se cuelga: no imprime nada, no lanza ninguna excepción, no consume CPU y no termina nunca. Es el peor tipo de fallo: silencioso y total. Lanza jstack sobre él y el final del volcado dice:

Found one Java-level deadlock:
=============================
"bibliotech-prestamos":
  waiting to lock monitor 0x00007f3c14006e00 (object 0x000000071ab34d18, a java.lang.Object),
  which is held by "bibliotech-devoluciones"
"bibliotech-devoluciones":
  waiting to lock monitor 0x00007f3c14009480 (object 0x000000071ab34d28, a java.lang.Object),
  which is held by "bibliotech-prestamos"

Java stack information for the threads listed above:
===================================================
"bibliotech-prestamos":
        at InterbloqueoBiblioTech.lambda$main$0(InterbloqueoBiblioTech.java:14)
        - waiting to lock <0x000000071ab34d18> (a java.lang.Object)
        - locked <0x000000071ab34d28> (a java.lang.Object)
        at java.base/java.lang.Thread.run(Thread.java:1583)
"bibliotech-devoluciones":
        at InterbloqueoBiblioTech.lambda$main$1(InterbloqueoBiblioTech.java:25)
        - waiting to lock <0x000000071ab34d28> (a java.lang.Object)
        - locked <0x000000071ab34d18> (a java.lang.Object)
        at java.base/java.lang.Thread.run(Thread.java:1583)

Found 1 deadlock.

Cómo se lee, en tres pasos:

  1. Found one Java-level deadlock es la palabra clave a buscar. Si aparece, no hay nada que discutir: hay un interbloqueo.
  2. La sección de arriba da el ciclo: prestamos espera un candado que tiene devoluciones, y devoluciones espera un candado que tiene prestamos. Un ciclo de dos; pueden ser de tres o más.
  3. Las trazas de pila dan las líneas exactasInterbloqueoBiblioTech.java:14 y :25— y, sobre todo, la combinación - locked <A> + - waiting to lock <B> en un hilo y - locked <B> + - waiting to lock <A> en el otro. Eso es la firma del interbloqueo por orden inverso de adquisición, y la solución —imponer un orden global— es 08-04.

Dos limitaciones importantes de la detección automática:

  • Detecta interbloqueos de monitores synchronized y de ReentrantLock (a través de ThreadMXBean), pero no detecta esperas mutuas lógicas: dos hilos esperando cada uno una señal del otro con wait() no producen un "deadlock" declarado, aunque el efecto sea el mismo. Ahí solo verás dos hilos en WAITING para siempre.
  • No detecta el livelock (08-04), en el que los hilos sí avanzan pero sin progresar.

  1. Inspección desde el propio programa

A veces conviene inspeccionar los hilos desde dentro de la aplicación, típicamente para un endpoint de diagnóstico o para volcar el estado antes de abortar.

Thread.getAllStackTraces() devuelve un mapa de todos los hilos vivos con sus trazas:

import java.util.Map;

public class VolcadoInterno {

    /** Vuelca todos los hilos vivos y sus trazas. Util en un manejador global. */
    public static void volcarHilos() {
        Map<Thread, StackTraceElement[]> todos = Thread.getAllStackTraces();

        System.out.println("=== VOLCADO INTERNO: " + todos.size() + " hilos ===");
        todos.forEach((hilo, traza) -> {
            System.out.printf("%n\"%s\" id=%d estado=%s demonio=%s%n",
                    hilo.getName(), hilo.threadId(), hilo.getState(), hilo.isDaemon());
            // Solo las 5 primeras lineas: lo demas rara vez aporta
            int n = Math.min(5, traza.length);
            for (int i = 0; i < n; i++) {
                System.out.println("        at " + traza[i]);
            }
            if (traza.length > n) {
                System.out.println("        ... " + (traza.length - n) + " mas");
            }
        });
    }
}

ThreadMXBean (de java.lang.management) da mucha más información, incluida la detección programática de interbloqueos:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;

public class DetectorInterbloqueos {

    private static final ThreadMXBean MX = ManagementFactory.getThreadMXBean();

    /** Devuelve un informe de interbloqueo, o null si no hay ninguno. */
    public static String detectar() {
        long[] ids = MX.findDeadlockedThreads();   // null si no hay
        if (ids == null) {
            return null;
        }
        StringBuilder sb = new StringBuilder("INTERBLOQUEO DETECTADO:\n");
        for (ThreadInfo info : MX.getThreadInfo(ids, true, true)) {
            sb.append("  Hilo '").append(info.getThreadName())
              .append("' (").append(info.getThreadState()).append(")\n")
              .append("    espera : ").append(info.getLockName()).append('\n')
              .append("    en manos de: ").append(info.getLockOwnerName()).append('\n');
        }
        return sb.toString();
    }

    /**
     * Vigilante que comprueba cada 10 segundos si hay interbloqueo.
     * Demonio: es puramente informativo y no debe impedir el cierre (08-02).
     */
    public static void arrancarVigilante() {
        Thread vigilante = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                String informe = detectar();
                if (informe != null) {
                    System.err.println(informe);
                }
                try {
                    Thread.sleep(10_000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }, "bibliotech-vigilante-interbloqueos");
        vigilante.setDaemon(true);
        vigilante.start();
    }
}

Un vigilante así, registrando en el logger de 06-07, convierte un "la aplicación se ha colgado y no sabemos por qué" en una entrada de log que dice exactamente qué dos hilos y qué dos candados. Merece las treinta líneas que cuesta.

Herramientas gráficas. jconsole (incluida en el JDK) y VisualVM (descarga aparte) muestran en tiempo real el número de hilos, su estado, un gráfico de actividad y un botón de "Detectar interbloqueo". Para una primera exploración son mucho más cómodas que jstack; para un análisis serio o para un sistema sin interfaz gráfica, jstack sigue siendo la herramienta. Java Flight Recorder + JDK Mission Control es el escalón profesional, con muestreo continuo y bajo impacto.

  1. BiblioTech: seguir el estado de sus hilos

Aplicación práctica: un monitor que muestra el estado de los hilos de BiblioTech mientras trabajan.

package com.nexussoftware.bibliotech.infraestructura;

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

/**
 * Monitor de hilos de BiblioTech.
 *
 * Es un hilo DEMONIO: puramente informativo, no debe impedir el cierre
 * de la aplicacion (08-02, apartado 7).
 */
public class MonitorHilos implements Runnable {

    private final List<Thread> vigilados;
    private final long periodoMs;

    public MonitorHilos(List<Thread> vigilados, long periodoMs) {
        this.vigilados = List.copyOf(vigilados);   // copia inmutable: seguro
        this.periodoMs = periodoMs;
    }

    @Override
    public void run() {
        try {
            while (!Thread.currentThread().isInterrupted()) {

                boolean algunoVivo = false;
                StringBuilder sb = new StringBuilder("[monitor] ");

                for (Thread h : vigilados) {
                    Thread.State e = h.getState();
                    sb.append(String.format("%-24s %-14s | ", h.getName(), e));
                    if (e != Thread.State.TERMINATED && e != Thread.State.NEW) {
                        algunoVivo = true;
                    }
                }
                System.out.println(sb);

                if (!algunoVivo) {
                    System.out.println("[monitor] todos los hilos han terminado");
                    return;
                }
                TimeUnit.MILLISECONDS.sleep(periodoMs);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    /** Arranca el monitor como demonio y devuelve su hilo. */
    public static Thread arrancar(List<Thread> vigilados, long periodoMs) {
        Thread t = new Thread(new MonitorHilos(vigilados, periodoMs),
                "bibliotech-monitor-hilos");
        t.setDaemon(true);
        t.start();
        return t;
    }
}

Uso, sobre la importación de 08-02 y una tarea de avisos:

package com.nexussoftware.bibliotech.presentacion;

import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class DemostracionEstados {

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

        Catalogo catalogo = new Catalogo();

        Thread importador = new Thread(
                new TareaImportacion(Path.of("datos/inventario.csv"), catalogo),
                "bibliotech-importador");

        Thread avisos = new Thread(() -> {
            for (int i = 1; i <= 20; i++) {
                try {
                    // E/S simulada: en un volcado, este hilo apareceria
                    // en TIMED_WAITING (sleeping).
                    TimeUnit.MILLISECONDS.sleep(150);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        }, "bibliotech-avisos");

        // El monitor arranca ANTES: asi captura el estado NEW.
        MonitorHilos.arrancar(List.of(importador, avisos), 400);

        TimeUnit.MILLISECONDS.sleep(500);
        importador.start();
        avisos.start();

        importador.join();
        avisos.join();
        TimeUnit.MILLISECONDS.sleep(500);   // margen para la ultima linea
    }
}

Salida:

[monitor] bibliotech-importador    NEW            | bibliotech-avisos        NEW            |
[monitor] bibliotech-importador    RUNNABLE       | bibliotech-avisos        TIMED_WAITING  |
[monitor] bibliotech-importador    TIMED_WAITING  | bibliotech-avisos        TIMED_WAITING  |
[monitor] bibliotech-importador    RUNNABLE       | bibliotech-avisos        TIMED_WAITING  |
[monitor] bibliotech-importador    TERMINATED     | bibliotech-avisos        TIMED_WAITING  |
[monitor] bibliotech-importador    TERMINATED     | bibliotech-avisos        TERMINATED     |
[monitor] todos los hilos han terminado

Lo que esta salida enseña:

  • El importador alterna entre RUNNABLE (leyendo y validando líneas) y TIMED_WAITING (las pausas simuladas). Es el perfil de una tarea mixta.
  • El hilo de avisos está casi siempre en TIMED_WAITING: es el perfil de una tarea limitada por E/S. Esta es exactamente la observación empírica que justifica usar muchos más hilos que núcleos para los avisos (08-01, apartado 6) y que se explotará en 08-05.
  • El monitor detecta el fin de los dos y termina solo. Como es demonio, aunque no lo hiciera no impediría el cierre de la JVM.

Errores Comunes y Consejos

Error 1: creer que RUNNABLE significa "consumiendo CPU". Un hilo bloqueado en read() aparece como RUNNABLE y no consume nada. Mira la traza de pila y el campo cpu= del volcado, no solo el estado.

Error 2: usar if en lugar de while alrededor de wait(). Un bug, no un estilo. Despertares espurios, notifyAll con varios esperando, y condiciones que cambian entre la señal y el despertar: tres motivos independientes.

Error 3: llamar a wait/notify sin el monitor. IllegalMonitorStateException en tiempo de ejecución. El compilador no ayuda aquí.

Error 4: usar notify() cuando hay hilos esperando por condiciones distintas. Señal perdida y bloqueo silencioso. Usa notifyAll(), o Condition de 08-04 si el rendimiento lo justifica.

Error 5: llamar a notifyAll() al principio del bloque sincronizado. No suelta el monitor: los despertados pasan a BLOCKED y esperan igual. Señaliza lo más cerca posible del final del bloque.

Error 6: usar stop, suspend o resume. Estado corrupto e interbloqueos garantizados. stop() ni siquiera funciona ya en Java 20+.

Error 7: sacar un único volcado de hilos y sacar conclusiones. Un hilo puede estar de paso por esa línea. Toma tres, separados por cinco segundos, y compara.

Error 8: perseguir hilos WAITING en un pool. Los hilos ociosos de un ThreadPoolExecutor están en WAITING (parking) dentro de getTask(). Es su estado normal cuando no hay trabajo.

Error 9: sondear con sleep en un bucle. Consume CPU para nada, y añade latencia igual al periodo de sondeo. Bloquéate en la condición: wait, BlockingQueue o CountDownLatch.

Consejo 1: aprende a leer un volcado antes de necesitarlo. Provoca un interbloqueo a propósito con el código del apartado 13 y lánzale jstack. Cuando ocurra en producción a las tres de la mañana, agradecerás haberlo visto antes.

Consejo 2: cruza nid del volcado con top -H -p <pid>. top -H muestra los hilos con su consumo de CPU en decimal; conviértelo a hexadecimal y búscalo como nid= en el volcado. Así identificas exactamente qué hilo Java está quemando un núcleo.

Consejo 3: instala un detector de interbloqueos con ThreadMXBean. Treinta líneas y un hilo demonio convierten un cuelgue misterioso en una entrada de log concreta.

Consejo 4: no uses wait/notify en código nuevo. Úsalo para entender y para leer código ajeno. Para escribir, usa BlockingQueue (08-06), CountDownLatch o Semaphore (08-05): hacen lo mismo, sin los tres errores clásicos de este apartado.

Ejercicios

Ejercicio 1: Observador de estados

Escribe ObservadorEstados que lance un hilo llamado hilo-bajo-observacion cuyo trabajo pase deliberadamente por al menos cuatro estados distintos: TIMED_WAITING (durmiendo), BLOCKED (por un candado que main retiene), WAITING (por un wait() sin plazo) y TERMINATED. Un segundo hilo, demonio y llamado observador, debe muestrear el estado del primero cada 50 ms e imprimir solo los cambios de estado (no repetir el mismo estado en líneas consecutivas), junto con el tiempo transcurrido desde el arranque medido con System.nanoTime().

Ejercicio 2: Cola de reservas con wait/notifyAll

Implementa ColaReservasCoordinada, una cola acotada de reservas de BiblioTech usando exclusivamente synchronized, wait y notifyAll (nada de java.util.concurrent). Debe tener:

  • Capacidad máxima fija (por ejemplo 5).
  • poner(Reserva r): si está llena, espera hasta que haya sitio.
  • tomar(): si está vacía, espera hasta que haya algo.
  • El bucle while correcto en los dos métodos, y notifyAll tras cada modificación.
  • Soporte de interrupción: propagar InterruptedException.

Escribe un main con dos productores y tres consumidores que demuestre que la cola nunca supera su capacidad y que nadie obtiene null.

Ejercicio 3: Provocar y diagnosticar un interbloqueo

Escribe InterbloqueoDiagnosticado que:

  1. Provoque deliberadamente un interbloqueo entre dos hilos llamados bibliotech-prestamos y bibliotech-devoluciones, que toman los candados CATALOGO y REGISTRO en orden opuesto.
  2. Lance un tercer hilo demonio, bibliotech-detector, que use ThreadMXBean.findDeadlockedThreads() para detectarlo, imprimir un informe con los nombres de los hilos, los candados implicados y quién posee cada uno, y luego termine el programa con System.exit(1).
  3. Documente en un comentario cómo obtendrías el mismo diagnóstico con jstack y qué línea buscarías.

Después, escribe una segunda versión, SinInterbloqueo, que resuelva el problema imponiendo un orden global de adquisición de los candados, y comprueba que el detector ya no encuentra nada.

Soluciones

Solución al Ejercicio 1

import java.util.concurrent.TimeUnit;

public class ObservadorEstados {

    private static final Object candado = new Object();
    private static final Object senal = new Object();

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

        final long inicio = System.nanoTime();

        Thread observado = new Thread(() -> {
            try {
                // 1) TIMED_WAITING
                TimeUnit.MILLISECONDS.sleep(400);

                // 2) BLOCKED: main tiene 'candado' tomado
                synchronized (candado) {
                    // (dentro apenas hacemos nada)
                }

                // 3) WAITING: espera indefinida hasta que main senalice
                synchronized (senal) {
                    senal.wait();
                }

                // 4) TERMINATED al salir de run()
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "hilo-bajo-observacion");

        Thread observador = new Thread(() -> {
            Thread.State anterior = null;
            try {
                while (true) {
                    Thread.State actual = observado.getState();

                    // Solo imprimimos los CAMBIOS: si no, la salida
                    // seria ilegible con cientos de lineas repetidas.
                    if (actual != anterior) {
                        long ms = (System.nanoTime() - inicio) / 1_000_000;
                        System.out.printf("%5d ms  %-16s%n", ms, actual);
                        anterior = actual;
                    }
                    if (actual == Thread.State.TERMINATED) {
                        return;
                    }
                    TimeUnit.MILLISECONDS.sleep(50);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "observador");

        // Demonio: es informativo, no debe impedir el cierre de la JVM.
        observador.setDaemon(true);
        observador.start();

        // Estado NEW visible durante 200 ms antes de arrancar.
        TimeUnit.MILLISECONDS.sleep(200);
        observado.start();

        // A los 600 ms tomamos el candado 500 ms: el observado, que
        // llega sobre los 400 ms, quedara BLOCKED.
        TimeUnit.MILLISECONDS.sleep(400);
        synchronized (candado) {
            TimeUnit.MILLISECONDS.sleep(500);
        }

        // Le dejamos entrar en wait() y luego lo despertamos.
        TimeUnit.MILLISECONDS.sleep(400);
        synchronized (senal) {
            senal.notifyAll();
        }

        observado.join();
        TimeUnit.MILLISECONDS.sleep(200);   // margen para la ultima linea
    }
}

Salida:

    0 ms  NEW
  201 ms  RUNNABLE
  252 ms  TIMED_WAITING
  604 ms  BLOCKED
 1104 ms  RUNNABLE
 1155 ms  WAITING
 1508 ms  TERMINATED

Los seis estados en orden, con sus tiempos. Fíjate en el paso de BLOCKED a WAITING: pasa por RUNNABLE a los 1104 ms, exactamente como decía el diagrama del apartado 2. No hay transiciones directas entre estados de espera.

Solución al Ejercicio 2

package com.nexussoftware.bibliotech.servicio;

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.TimeUnit;

/**
 * Cola acotada de reservas implementada con synchronized/wait/notifyAll.
 *
 * NOTA DIDACTICA: en produccion usarias una ArrayBlockingQueue (08-06),
 * que hace exactamente esto mejor y sin margen de error. Esta clase existe
 * para ver el mecanismo por dentro.
 */
public class ColaReservasCoordinada {

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

    public ColaReservasCoordinada(int capacidad) {
        if (capacidad <= 0) {
            throw new IllegalArgumentException("capacidad debe ser positiva");
        }
        this.capacidad = capacidad;
    }

    /**
     * Anade una reserva. Si la cola esta llena, espera.
     * Propaga InterruptedException: quien llama decide (08-02).
     */
    public void poner(String reserva) throws InterruptedException {
        synchronized (cola) {
            // WHILE, no if: al despertar hay que RE-COMPROBAR, porque
            // otro productor pudo llenar el hueco antes que nosotros.
            while (cola.size() == capacidad) {
                cola.wait();
            }
            cola.addLast(reserva);

            // notifyAll y no notify: en esta cola esperan hilos por DOS
            // condiciones distintas (llena y vacia). Con notify podriamos
            // despertar a un productor cuando el que puede avanzar es
            // un consumidor, y perder la senal (apartado 9).
            cola.notifyAll();
        }
    }

    /** Extrae una reserva. Si la cola esta vacia, espera. Nunca devuelve null. */
    public String tomar() throws InterruptedException {
        synchronized (cola) {
            while (cola.isEmpty()) {
                cola.wait();
            }
            String r = cola.pollFirst();
            cola.notifyAll();
            return r;
        }
    }

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

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

        final int CAPACIDAD = 5;
        final int POR_PRODUCTOR = 10;
        ColaReservasCoordinada cola = new ColaReservasCoordinada(CAPACIDAD);

        Runnable productor = () -> {
            String yo = Thread.currentThread().getName();
            try {
                for (int i = 1; i <= POR_PRODUCTOR; i++) {
                    cola.poner(yo + "-reserva-" + i);
                    System.out.printf("  [+] %-14s pone %2d   (tamano=%d)%n",
                            yo, i, cola.tamano());
                    TimeUnit.MILLISECONDS.sleep(30);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };

        Runnable consumidor = () -> {
            String yo = Thread.currentThread().getName();
            try {
                while (!Thread.currentThread().isInterrupted()) {
                    String r = cola.tomar();
                    // Sin 'while' en tomar(), esta comprobacion fallaria.
                    if (r == null) {
                        throw new IllegalStateException("null: bug de coordinacion");
                    }
                    System.out.printf("  [-] %-14s toma %-28s (tamano=%d)%n",
                            yo, r, cola.tamano());
                    TimeUnit.MILLISECONDS.sleep(70);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        };

        Thread p1 = new Thread(productor, "productor-1");
        Thread p2 = new Thread(productor, "productor-2");
        Thread c1 = new Thread(consumidor, "consumidor-1");
        Thread c2 = new Thread(consumidor, "consumidor-2");
        Thread c3 = new Thread(consumidor, "consumidor-3");

        c1.start(); c2.start(); c3.start();
        p1.start(); p2.start();

        p1.join();
        p2.join();

        // Dejamos que los consumidores vacien y los paramos con interrupt.
        TimeUnit.MILLISECONDS.sleep(1000);
        c1.interrupt(); c2.interrupt(); c3.interrupt();
        c1.join(); c2.join(); c3.join();

        System.out.println("Reservas pendientes al final: " + cola.tamano());
    }
}

Salida (fragmento):

  [+] productor-1   pone  1   (tamano=1)
  [+] productor-2   pone  1   (tamano=1)
  [-] consumidor-1  toma productor-1-reserva-1       (tamano=1)
  [-] consumidor-2  toma productor-2-reserva-1       (tamano=0)
  [+] productor-1   pone  2   (tamano=1)
  ...
Reservas pendientes al final: 0

Tres puntos didácticos:

  1. El tamaño nunca supera 5. La condición while (cola.size() == capacidad) en poner lo garantiza. Si los productores fueran mucho más rápidos, se bloquearían en lugar de hacer crecer la cola sin límite: eso es contrapresión, y es una propiedad valiosísima que se retoma con BlockingQueue en 08-06.
  2. Ningún consumidor obtiene null. El while en tomar lo garantiza. Cambia ese while por un if y ejecuta: con tres consumidores y notifyAll, la IllegalStateException salta en segundos.
  3. El tamaño impreso puede no cuadrar con la operación. System.out.printf se ejecuta fuera del bloque sincronizado, así que entre poner y consultar el tamaño otro hilo pudo actuar. No es un bug de la cola: es la demostración de que una lectura fuera de la sección crítica es solo una foto de un instante ya pasado. Es exactamente el problema de "comprobar-luego-actuar" de 08-06.

Solución al Ejercicio 3

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.TimeUnit;

/**
 * Provoca un interbloqueo y lo diagnostica desde dentro del programa.
 *
 * DIAGNOSTICO EXTERNO EQUIVALENTE:
 *   1) jps -l                        -> localizar el pid
 *   2) jstack <pid> > volcado.txt    -> volcar los hilos
 *   3) buscar en volcado.txt la linea "Found one Java-level deadlock:"
 *      Debajo aparecen los dos hilos, el candado que espera cada uno
 *      y quien lo posee, mas las trazas con "- locked" y
 *      "- waiting to lock" del mismo par de direcciones en orden inverso.
 */
public class InterbloqueoDiagnosticado {

    // Dos candados que representan dos recursos del dominio.
    private static final Object CATALOGO = new Object();
    private static final Object REGISTRO = new Object();

    public static void main(String[] args) {

        arrancarDetector();

        // Hilo A: CATALOGO -> REGISTRO
        new Thread(() -> {
            synchronized (CATALOGO) {
                dormir(300);                       // deja tiempo al otro hilo
                synchronized (REGISTRO) {          // se queda BLOCKED aqui
                    System.out.println("A completado (nunca ocurre)");
                }
            }
        }, "bibliotech-prestamos").start();

        // Hilo B: REGISTRO -> CATALOGO  <-- ORDEN INVERSO: la causa raiz
        new Thread(() -> {
            synchronized (REGISTRO) {
                dormir(300);
                synchronized (CATALOGO) {          // se queda BLOCKED aqui
                    System.out.println("B completado (nunca ocurre)");
                }
            }
        }, "bibliotech-devoluciones").start();
    }

    /** Vigilante demonio que detecta interbloqueos y aborta con informe. */
    private static void arrancarDetector() {
        Thread detector = new Thread(() -> {
            ThreadMXBean mx = ManagementFactory.getThreadMXBean();
            while (!Thread.currentThread().isInterrupted()) {
                long[] ids = mx.findDeadlockedThreads();   // null si no hay
                if (ids != null) {
                    System.err.println();
                    System.err.println("========================================");
                    System.err.println("  INTERBLOQUEO DETECTADO (" + ids.length + " hilos)");
                    System.err.println("========================================");

                    // true, true: incluir monitores y sincronizadores poseidos
                    for (ThreadInfo info : mx.getThreadInfo(ids, true, true)) {
                        System.err.printf("%nHilo   : %s (%s)%n",
                                info.getThreadName(), info.getThreadState());
                        System.err.printf("Espera : %s%n", info.getLockName());
                        System.err.printf("Poseido por: %s%n", info.getLockOwnerName());
                        System.err.println("Traza (2 primeras lineas):");
                        StackTraceElement[] traza = info.getStackTrace();
                        for (int i = 0; i < Math.min(2, traza.length); i++) {
                            System.err.println("    at " + traza[i]);
                        }
                    }
                    System.err.println();
                    System.err.println("CAUSA: los dos hilos adquieren los mismos dos");
                    System.err.println("candados en ORDEN OPUESTO. Solucion en 08-04:");
                    System.err.println("imponer un orden global de adquisicion.");
                    System.exit(1);
                }
                dormir(500);
            }
        }, "bibliotech-detector");

        detector.setDaemon(true);     // no debe impedir el cierre
        detector.start();
    }

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

Salida:

========================================
  INTERBLOQUEO DETECTADO (2 hilos)
========================================

Hilo   : bibliotech-prestamos (BLOCKED)
Espera : java.lang.Object@5b6f7412
Poseido por: bibliotech-devoluciones
Traza (2 primeras lineas):
    at InterbloqueoDiagnosticado.lambda$main$0(InterbloqueoDiagnosticado.java:31)
    at java.base/java.lang.Thread.run(Thread.java:1583)

Hilo   : bibliotech-devoluciones (BLOCKED)
Espera : java.lang.Object@2d1ef81a
Poseido por: bibliotech-prestamos
Traza (2 primeras lineas):
    at InterbloqueoDiagnosticado.lambda$main$1(InterbloqueoDiagnosticado.java:42)
    at java.base/java.lang.Thread.run(Thread.java:1583)

CAUSA: los dos hilos adquieren los mismos dos candados en ORDEN OPUESTO.
Solucion en 08-04: imponer un orden global de adquisicion.

Y la versión corregida, con orden global de adquisición:

/**
 * Misma funcionalidad, sin interbloqueo posible.
 *
 * REGLA: TODOS los hilos adquieren los candados en el MISMO orden,
 * siempre CATALOGO antes que REGISTRO. Con un orden total sobre los
 * candados no puede formarse un ciclo de espera, y sin ciclo no hay
 * interbloqueo. Es la solucion que se desarrolla en 08-04.
 */
public class SinInterbloqueo {

    private static final Object CATALOGO = new Object();
    private static final Object REGISTRO = new Object();

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

        Runnable prestar = () -> {
            synchronized (CATALOGO) {          // 1º CATALOGO
                dormir(300);
                synchronized (REGISTRO) {      // 2º REGISTRO
                    System.out.println("[" + Thread.currentThread().getName()
                            + "] prestamo registrado");
                }
            }
        };

        Runnable devolver = () -> {
            synchronized (CATALOGO) {          // 1º CATALOGO (antes era REGISTRO)
                dormir(300);
                synchronized (REGISTRO) {      // 2º REGISTRO
                    System.out.println("[" + Thread.currentThread().getName()
                            + "] devolucion registrada");
                }
            }
        };

        Thread a = new Thread(prestar,  "bibliotech-prestamos");
        Thread b = new Thread(devolver, "bibliotech-devoluciones");
        a.start(); b.start();
        a.join();  b.join();
        System.out.println("Ambos hilos han terminado: NO hay interbloqueo");
    }

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

Salida:

[bibliotech-prestamos] prestamo registrado
[bibliotech-devoluciones] devolucion registrada
Ambos hilos han terminado: NO hay interbloqueo

El cambio es de una sola línea —invertir el orden de los dos synchronized en devolver— y elimina por completo una clase de fallo que puede tumbar una aplicación en producción sin dejar rastro. Es la mejor relación coste/beneficio de todo el módulo, y en 08-04 se convierte en una regla de diseño explícita.

Conclusión

Ya no ves un hilo como una caja negra que arranca y acaba: sabes exactamente en qué puede estar y qué lo mueve de un sitio a otro.

Los seis estados de Thread.State y sus transiciones: NEW antes de start(), RUNNABLE cuando está ejecutando o listo para ejecutar, BLOCKED esperando un monitor, WAITING esperando una señal, TIMED_WAITING esperando con plazo, y TERMINATED como estado terminal e irreversible —que es la razón profunda de que un Thread no se pueda reutilizar y de que existan los pools—. Con la observación estructural que ordena el diagrama: todas las transiciones pasan por RUNNABLE; un hilo que despierta de wait() tiene que readquirir el monitor, y en ese momento puede quedar BLOCKED.

Conoces los dos matices que separan a quien sabe leer un sistema en producción de quien no. El primero: RUNNABLE no significa "consumiendo CPU", porque agrupa ejecutar, esperar turno y —esto sorprende siempre— estar bloqueado en E/S clásica; hay que mirar la traza de pila y el campo cpu=, no el estado. El segundo: BLOCKED es "no me dejan entrar" y WAITING es "espero a que me avisen", contención frente a coordinación, y esa distinción convierte un volcado ilegible en un diagnóstico.

Dominas el mecanismo de coordinación de bajo nivel: wait, notify y notifyAll viven en Object, se llaman siempre con el monitor en la mano —o IllegalMonitorStateException—, y wait() suelta el monitor mientras que sleep() no, que es la diferencia que define para qué sirve cada uno. Y tienes la regla que más se incumple: wait() va siempre dentro de un while, nunca dentro de un if, por tres motivos independientes —despertares espurios, notifyAll con varios esperando y un solo elemento, y condiciones que cambian entre la señal y el despertar—, con la idea que los resume: notify no dice "tu condición se cumple", dice "vuelve a mirar". Y la recomendación derivada: notifyAll por defecto, porque notify con hilos esperando por condiciones distintas produce señales perdidas y bloqueos silenciosos.

Sabes por qué stop, suspend y resume llevan retirados desde 1998: el primero suelta todos los monitores en mitad de una operación y deja estructuras corruptas sin dar ningún error; los otros dos retienen los monitores mientras el hilo está pausado, lo que produce interbloqueos perfectos. La alternativa es la misma idea en los dos casos: el hilo elige dónde se detiene, que es la cancelación cooperativa de 08-02 y el punto de pausa explícito con wait/notify.

Y, sobre todo, sabes diagnosticar. Obtener un volcado con Ctrl+\, jstack o jcmd; leer una entrada campo a campo —nombre, estado, cpu=, - locked y - waiting to lock, traza—; reconocer los cuatro perfiles habituales —trabajando, esperando E/S, hilo de pool ocioso, bloqueado—; y encontrar el Found one Java-level deadlock que la JVM escribe por ti, con el ciclo de espera y las líneas exactas del código. Con la regla operativa que evita conclusiones falsas: tres volcados separados por unos segundos, porque un hilo puede estar de paso pero no puede estar de paso tres veces en la misma línea. Y con Thread.getAllStackTraces y ThreadMXBean.findDeadlockedThreads() para diagnosticar desde dentro, más jconsole, VisualVM y JFR para hacerlo con interfaz gráfica.

BiblioTech ya se puede observar. MonitorHilos muestra el estado de los hilos de importación y de avisos mientras trabajan, y esa salida enseña algo que hasta ahora era teoría: el importador alterna RUNNABLE y TIMED_WAITING —tarea mixta—, mientras que el hilo de avisos está casi siempre en TIMED_WAITING —tarea limitada por E/S—. Es la confirmación empírica de la tabla de 08-01 y la justificación de todo lo que viene.

Porque ahora ya sabes observar el problema, y llega el momento de resolverlo. Has visto un interbloqueo real, has visto tres consumidores obtener null por un if mal puesto, y sigues teniendo pendiente el contador que perdía medio millón de incrementos desde 08-01.

En la próxima lección, Sincronización —la lección central del módulo—, se resuelve todo eso. Verás por qué contador++ son en realidad tres operaciones y qué entrelazado exacto pierde los incrementos; el modelo de memoria de Java, con las cachés por núcleo, la reordenación de instrucciones y la relación happens-before que lo gobierna todo; qué garantiza volatile —visibilidad— y qué no —atomicidad—, con los dos ejemplos que lo demuestran; synchronized en todas sus formas, el monitor intrínseco, el bloqueo privado y la granularidad correcta; el interbloqueo reproducible y sus dos soluciones —orden global y tryLock con plazo—; ReentrantLock con sus Condition, ReadWriteLock para estructuras que se leen mucho y se escriben poco, y la mejor estrategia de todas: no compartir. Al terminar, el Catalogo y el RegistroPrestamos de BiblioTech serán seguros para varios hilos, y el caso C de 08-01 —dos empleados trabajando a la vez— dejará de ser una amenaza.

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