Fundamentos de la concurrencia en Java: el marco AQS y el ciclo de vida de los hilos

El estudio del paquete java.util.concurrent (j.u.c.) en Java nos conduce inevitablemente al framework AQS (AbstractQueuedSynchronizer). Su diseño es tan elegante que ha sido objeto de estudio y elogio. Para comprender AQS a fondo, es esencial primero sentar unas bases sólidas sobre los hilos (threads), ya que conceptos erróneos o simplificados sobre ellos son comunes. Este artículo busca establecer esa base, analizando de manera exhaustiva el ciclo de vida y los conceptos clave de los hilos en Java.

Estados de un Hilo en Java

El estado de un hilo es un tema central en la programación concurrente. Desde la perspectiva de la JVM (Máquina Virtual de Java), un hilo puede estar en uno de seis estados oficiales. Para un análisis más granular, podemos descomponer el estado RUNNABLE en dos desde el punto de vista del sistema operativo: listo para ejecutarse y en ejecución. Esto nos da siete estados conceptuales.

Definición de los Estados

  • NEW: El hilo ha sido creado mediatne new Thread(), pero aún no se ha iniciado con start(). El sistema operativo no es consciente de su existencia.
  • RUNNABLE (Listo): El hilo ha sido iniciado y está compitiendo por tiempo de CPU. Está listo para ejecutarse cuando el planificador del SO le asigna un quantum.
  • RUNNABLE (Ejecutando): Es el único estado en el que el hilo está efectivamente ejecutando su código en un núcleo de CPU. Puede transicionar voluntariamente al estado Listo llamando a Thread.yield().
  • BLOCKED: El hilo está esperando pasivaemnte para adquirir un monitor (un bloque synchronized). No tiene concepto de tiempo y no es interrumpible en este estado de espera. La reactivación depende exclusivamente de que otro hilo libere el bloqueo.
  • WAITING: El hilo está esperando activamente que otro hilo realice una acción específica. Este estado se alcanza al llamar a Object.wait(), Thread.join() o LockSupport.park(). La diferencia clave con BLOCKED es que aquí el hilo sabe que va a esperar.
  • TIMED_WAITING: Similar al estado WAITING, pero con un límite de tiempo. Se entra mediante métodos como Thread.sleep(long), Object.wait(long), Thread.join(long) o LockSupport.parkNanos(long).
  • TERMINATED: La ejecución del método run() del hilo ha finalizado. El hilo ya no puede ser reiniciado y está pendiente de ser recolectado por el garbage collector.

Transiciones entre Estados

Las transiciones no son automáticas ni aleatorias; están gobernadas por acciones específicas del código o del planificador del sistema.

  • NEW → RUNNABLE (Listo): Ocurre al invocar thread.start().
  • RUNNABLE (Listo) ↔ RUNNABLE (Ejecutando): Controlado por el planificador del sistema operativo. La JVM no puede intervenir directamente.
  • RUNNABLE (Ejecutando) → BLOCKED: Sucede cuando un hilo intenta adquirir un monitor (synchronized) que ya está bloqueado por otro hilo.
  • RUNNABLE (Ejecutando) → WAITING / TIMED_WAITING: El hilo transiciona de forma voluntaria al llamar a los métodos mencionados anteriormente (wait, park, sleep, etc.).
  • WAITING / TIMED_WAITING → BLOCKED: Ocurre en un escenario particular con Object.wait(). Cuando un hilo es despertado con notify() o notifyAll() desde el estado WAITING, no comienza a ejecutarse de inmediato. Primero debe competir por re-adquirir el monitor del objeto. Si falla, entra en estado BLOCKED en la cola de monitores.
  • Cualquier estado activo → TERMINATED: Sucede cuando el método run() finaliza su ejecución normalmente o por una excepción no capturada.

El siguiente ejemplo ilustra la transición a BLOCKED por competencia por un monitor:

// Demostración de estado BLOCKED
Object lock = new Object();
Thread hiloA = new Thread(() -> {
    synchronized (lock) {
        while (true) { /* Se ejecuta indefinidamente, manteniendo el bloqueo */ }
    }
});
hiloA.start();

Thread.sleep(100); // Aseguramos que hiloA esté corriendo

Thread hiloB = new Thread(() -> {
    synchronized (lock) { // Intenta obtener el bloqueo que hiloA tiene
        System.out.println("Accediendo al recurso compartido.");
    }
});
hiloB.start();
Thread.sleep(100);

System.out.println("Estado de hiloA: " + hiloA.getState()); // RUNNABLE
System.out.println("Estado de hiloB: " + hiloB.getState()); // BLOCKED

Este segundo ejemplo muestra la transición de WAITING a BLOCKED tras un notifyAll():

// Demostración de transición WAITING -> BLOCKED
Object monitor = new Object();
Runnable tarea = () -> {
    synchronized (monitor) {
        try {
            monitor.wait(); // Entra en WAITING y libera el monitor
            // Al ser despertado, intenta re-adquirir el monitor antes de continuar
            while (true) { /* Simula trabajo post-despertar */ }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
};

Thread t1 = new Thread(tarea, "Hilo-1");
Thread t2 = new Thread(tarea, "Hilo-2");
t1.start();
t2.start();
Thread.sleep(200);

synchronized (monitor) {
    monitor.notifyAll(); // Despierta a ambos hilos
}
Thread.sleep(200);

System.out.println("Estado Hilo-1: " + t1.getState()); // Puede ser BLOCKED o RUNNABLE
System.out.println("Estado Hilo-2: " + t2.getState()); // Depende de quién gane la re-adquisición

Conceptos Fundamentales sobre Hilos

La lección de los métodos deprecated

La API de hilos en Java tiene métodos que han sido desaconsejados (@Deprecated) por ser peligrosos. Analizarlos nos ayuda a entender por qué el control de hilos debe ser copoerativo.

Thread.stop(): Este método termina un hilo de forma abrupta. Su peligro radica en que puede interrumpir al hilo en medio de una operación atómica (como una serie de actualizaciones dentro de un bloque synchronized), dejando los datos en un estado inconsistente e impredecible. Además, no garantiza la liberación de bloqueos adquiridos por mecanismos del paquete j.u.c., como ReentrantLock.

Thread.suspend() / Thread.resume(): suspend() pausa un hilo pero no libera los monitores que haya adquirido. Si otro hilo necesita ese mismo monitor para llamar a resume(), se produce un deadlock. La solución moderna es el mecanismo de interrupción.

Interrupción de Hilos

La interrupción no fuerza la terminación de un hilo. Es un mecanismo cooperativo: un hilo solicita a otro que finalice, pero es el hilo objetivo quien decide cómo y cuándo responder a esa solicitud. La solicitud se materializa como un flag booleano en el objeto Thread.

Los métodos que están en estados de espera (WAITING, TIMED_WAITING) suelen estar diseñados para responder a la interrupción. Al detectar el flag de interrupción, estos métodos típicamente lanzan una InterruptedException o, en el caso de LockSupport.park(), retornan inmediatamente.

Ejemplo de respuesta a interrupción:

Thread hiloWorker = new Thread(() -> {
    try {
        while (!Thread.currentThread().isInterrupted()) {
            // Realizar trabajo...
            System.out.println("Trabajando...");
            Thread.sleep(1000); // Punto de interrupción, lanza InterruptedException
        }
    } catch (InterruptedException e) {
        // El hilo fue interrumpido mientras dormía
        System.out.println("Hilo interrumpido. Finalizando tareas de limpieza.");
        // La bandera de interrupción se limpia al entrar al catch, a menudo se re-setea.
        Thread.currentThread().interrupt();
    }
    System.out.println("Hilo terminado limpiamente.");
});
hiloWorker.start();

Thread.sleep(3000); // Dejar que trabaje un poco
hiloWorker.interrupt(); // Solicitar finalización

Comparativa: wait/notify vs park/unpark

Ambos son mecanismos para suspender y reanudar hilos, pero con diferencias importantes:

Característica wait/notify park/unpark
Precisión en la reanudación notify() reanuda un hilo aleatorio de la cola de espera, o notifyAll() reanuda todos. unpark(thread) reanuda un hilo específico.
Orden de ejecución notify() debe ejecutarse después de wait(). Si ocurre antes, el wait() puede quedar bloqueado indefinidamente. unpark() puede llamarse antes de park(). Actúa como un "permiso" que se almacena. Si park() encuentra el permiso, no se bloquea.
Manejo de Interrupciones Lanza InterruptedException y debe manejarse. No lanza excepción. park() retorna silenciosamente. Se debe usar Thread.interrupted() para distinguir entre un unpark() y una interrupción.
Contexto Requiere sincronización sobre un monitor (synchronized). La reanudación ocurre dentro de un bloque synchronized. No requiere un monitor. Más ligero y flexible, usado como base en la implementación de AQS.

Etiquetas: Java Concurrency AQS Thread States Thread Interruption Java Monitors

Publicado el 6-20 01:19