W3docs

Comunicación entre hilos en Java

Coordina hilos Java con wait, notify y notifyAll sobre monitores compartidos y cuándo preferir primitivas de nivel superior.

La exclusión mutua garantiza un estado compartido seguro. Sin embargo, no permite que un hilo señalice a otro que el estado ha cambiado. Para eso existe el trío wait, notify y notifyAll de java.lang.Object. Son la primitiva de coordinación de más bajo nivel que expone Java: todo mecanismo de nivel superior (colas bloqueantes, latches, semáforos, Condition) se construye sobre esta idea: un hilo espera dentro de un monitor hasta que otro hilo le indica que despierte.

El código moderno raramente llama a wait/notify directamente. En su lugar se usan BlockingQueue, CountDownLatch o Condition. Pero es necesario conocer el mecanismo subyacente porque (a) sigue siendo lo que usan esas clases internamente, (b) está en todas las bibliotecas que leerás, y (c) cuando algo falla en código de alto nivel, el diagnóstico a menudo llega hasta un notify que se perdió.

El trío

Definidos en java.lang.Object, por lo que todo objeto los tiene:

void wait() throws InterruptedException;
void wait(long timeoutMillis) throws InterruptedException;
void notify();
void notifyAll();

La regla fundamental: solo puedes llamar a estos métodos mientras posees el monitor del objeto sobre el que los llamas. Es decir, debes estar dentro de un bloque synchronized (obj) { ... } (o un método synchronized que bloquee sobre el mismo obj). Llamar a obj.wait() sin mantener el monitor de obj lanza IllegalMonitorStateException de inmediato.

synchronized (lock) {
  lock.wait();                                  // ok — we hold lock
  lock.notify();                                // ok — same
}
lock.wait();                                    // IllegalMonitorStateException

Esta regla es lo que hace funcionar la API: el wait y el notify ocurren garantizadamente con el lock mantenido, por lo que el estado sobre el que hablan es consistente.

Qué hace realmente wait()

wait() no es "dormir". Realiza tres cosas de forma atómica:

  1. Libera el monitor del objeto sobre el que se lo llamó.
  2. Suspende el hilo actual en el conjunto de espera de ese monitor.
  3. Al ser despertado (por notify/notifyAll/interrupt/tiempo de espera) re-adquiere el monitor antes de retornar.

La parte de "libera y suspende atómicamente" es lo que hace seguro a wait: un notify que llegue entre "decidimos esperar" y "empezamos a esperar de verdad" de otro modo se perdería. Con wait, esa brecha no existe.

Después de que wait() retorna, estás de vuelta dentro del bloque synchronized con el lock mantenido — por eso el código después de wait() puede leer el estado compartido de forma segura.

Qué hacen notify() y notifyAll()

notify() elige un hilo (cuál es decisión de la JVM — típicamente no FIFO) del conjunto de espera y lo mueve de WAITING/TIMED_WAITING a BLOCKED. El hilo notificado sigue esperando el monitor; el notificador todavía lo mantiene. El hilo notificado solo puede re-adquirirlo cuando el notificador sale del bloque synchronized.

notifyAll() despierta todos los hilos del conjunto de espera de la misma forma. Todos pasan a BLOCKED; todos se alinean para el lock; lo re-adquieren de a uno a medida que el lock queda disponible.

notify es más rápido (se despierta un hilo) pero peligroso: si despiertas al hilo equivocado (cuya condición no está satisfecha), vuelve a wait() y nada útil ocurre. notifyAll es más seguro (algún esperador que puede progresar lo hará) pero más costoso. Usa notifyAll por defecto; cambia a notify solo cuando puedas demostrar que todos los esperadores son intercambiables.

El patrón obligatorio con bucle while

La regla más importante sobre wait:

Llama siempre a wait() dentro de un bucle while que vuelva a verificar la condición.

synchronized (lock) {
  while (!conditionHolds()) {
    lock.wait();
  }
  // now condition holds AND we own the lock
}

Tres razones para usar el bucle en lugar de un if:

  1. Despertares espúreos. La JVM puede despertar un wait sin motivo alguno. El bucle los captura.
  2. notifyAll despierta a más de uno. Cuando todos compiten por el lock, el que gana puede no tener nada útil que hacer — alguien más ya consumió el recurso. El bucle lo envía de vuelta a wait.
  3. Otro estado puede cambiar. Entre el notify y el momento en que re-adquieres el lock, alguien más con el lock pudo haber deshecho aquello que esperabas. El bucle vuelve a verificar.

if (!condition) wait() es el bug más común en código wait/notify. Funciona en las pruebas; falla en producción a las 3 de la madrugada.

El clásico productor–consumidor

El caso de uso canónico de wait/notify es un buffer acotado:

class Buffer<T> {
  private final Object lock = new Object();
  private final Object[] data;
  private int count, head, tail;

  Buffer(int capacity) { data = new Object[capacity]; }

  void put(T item) throws InterruptedException {
    synchronized (lock) {
      while (count == data.length) lock.wait();             // wait for room
      data[tail] = item;
      tail = (tail + 1) % data.length;
      count++;
      lock.notifyAll();                                      // wake any consumer
    }
  }

  @SuppressWarnings("unchecked")
  T take() throws InterruptedException {
    synchronized (lock) {
      while (count == 0) lock.wait();                        // wait for an item
      T item = (T) data[head];
      data[head] = null;
      head = (head + 1) % data.length;
      count--;
      lock.notifyAll();                                      // wake any producer
      return item;
    }
  }
}

Algunos aspectos que este código hace bien:

  • El mismo lock para ambos métodos (lock). Un monitor protege todo el estado.
  • Ambas esperas están dentro de bucles while.
  • notifyAll en ambos lados — porque tanto productores como consumidores esperan en el mismo monitor y despertar solo uno podría ser el tipo incorrecto.
  • Lock mantenido mientras se espera (el wait lo libera internamente y lo re-adquiere antes de retornar).

En producción usarías BlockingQueue en lugar de escribir esto a mano. Pero ese patrón es lo que BlockingQueue hace internamente.

Por qué notifyAll es la opción más segura por defecto

Si reemplazaras notifyAll con notify en el buffer anterior, tendrías un bug sutil. Dos consumidores y un productor esperan en el mismo monitor. El productor llama a notify; la JVM elige un hilo; si elige a un consumidor cuando el aviso era para "la cola tiene espacio" (irrelevante para los consumidores), el consumidor vuelve a verificar su condición (la cola podría seguir vacía), regresa a wait, y el productor que debería despertar nunca lo hace. Cola bloqueada, sin excepciones.

Para usar notify de forma segura necesitas: que todos los esperadores esperen la misma condición, que sean intercambiables, y que el protocolo garantice progreso. Es un requisito estricto. Usa notifyAll por defecto; usa notify cuando la mejora de rendimiento importa y puedes demostrar el invariante.

Las alternativas obsoletas

Hay código antiguo que usa Thread.suspend() y Thread.resume(). No los uses. Fueron deprecados en Java 1.2 porque dejan locks mantenidos y rompen invariantes. El mecanismo wait/notify es la única forma segura de hacer que un hilo espere a otro usando solo métodos de Object.

También existe Thread.sleep — pero sleep no libera locks. Un hilo que duerme dentro de un bloque synchronized bloquea a todos los demás hilos que quieran el mismo lock hasta que despierte. Usa wait (que sí libera) para cualquier escenario de "esperar que algo suceda"; reserva sleep para "esperar un tiempo fijo sin mantener nada importante".

Qué usar en producción

wait/notify son correctos pero propensos a errores. El código moderno prefiere los bloques de construcción de nivel superior:

NecesidadUsar
Productor–consumidor acotadoArrayBlockingQueue, LinkedBlockingQueue
Esperar a que N cosas terminenCountDownLatch
Esperar a que N partes se encuentrenCyclicBarrier, Phaser
Múltiples variables de condición en un lockCondition (de ReentrantLock.newCondition())
Permisos de recursosSemaphore
Resultado futuro de uso únicoCompletableFuture

Cada uno tiene el bucle while correcto, la semántica correcta de notifyAll/signalAll, y el manejo correcto de interrupciones incorporado. Los veremos todos en esta parte del libro.

Un ejemplo trabajado: productor–consumidor con wait y notifyAll

El programa a continuación ejecuta dos productores y tres consumidores sobre el buffer acotado anterior. Los productores insertan 1000 elementos cada uno; los consumidores se ejecutan hasta que colectivamente han tomado 2000.

java— editable, runs on the server

Lo que se puede extraer de la ejecución:

  • Las sumas coincidieron. Cada elemento insertado por un productor fue tomado exactamente por un consumidor; nada se duplicó, nada se perdió. Esta es la propiedad de corrección del productor–consumidor, lograda con un solo monitor y el par wait/notifyAll.
  • El buffer tenía solo 4 posiciones, así que los productores lo llenaban constantemente y los consumidores lo vaciaban constantemente. Los bucles while les permiten suspenderse y volver a suspenderse a medida que la cola se recicla. Sin wait, los productores habrían girado sobre count == capacity, quemando CPU; con él, duermen hasta que el consumidor les señaliza.
  • El notifyAll se llamó sobre el mismo lock que mantenían tanto productores como consumidores. Ese es el mecanismo de coordinación completo: un monitor, exclusión mutua y señalización, con el bucle while captando cualquier despertar que no fuera relevante.
  • El wait final fuera de synchronized lanzó IllegalMonitorStateException de inmediato. Esa es la aplicación de la regla por parte de la JVM: solo puedes esperar/notificar en un monitor que actualmente posees. Si ves esta excepción, la ruta de código llegó a wait sin pasar primero por synchronized.
  • La misma forma — buffer acotado, exclusión mutua, señal en cada cambio de estado — es lo que ArrayBlockingQueue hace internamente, excepto que usa dos Conditions (una para "no lleno," otra para "no vacío") en lugar de un gran notifyAll. Esa es la forma correcta de escribir esto en producción; la versión wait/notifyAll es el mecanismo subyacente sobre el que se construye toda clase de nivel superior.

Qué sigue

El siguiente capítulo, Java Deadlock, examina el modo de fallo que hace sutil al bloqueo — dos hilos cada uno sosteniendo lo que el otro quiere — y las estrategias para prevenirlo.

Práctica

Práctica
¿Por qué `obj.wait()` siempre debe llamarse desde dentro de un bloque `synchronized (obj)` (o un método `synchronized` que bloquee sobre `obj`)?
¿Por qué `obj.wait()` siempre debe llamarse desde dentro de un bloque `synchronized (obj)` (o un método `synchronized` que bloquee sobre `obj`)?
Was this page helpful?