W3docs

Java Deadlock

Qué son los deadlocks en Java, cómo ocurren y los patrones que los previenen.

Un deadlock es el modo de fallo del bloqueo. Dos o más hilos retienen cada uno un bloqueo que el otro necesita; ninguno puede avanzar; no se lanza ninguna excepción; nada en el registro dice "estamos atascados". Desde el exterior, el programa parece no hacer nada — exactamente el mismo síntoma externo que un bucle ocupado o una llamada de red larga.

Los deadlocks ocurren en cualquier programa que adquiere más de un bloqueo a la vez. Son aterradoramente fáciles de escribir y aterradoramente difíciles de reproducir — la planificación que desencadena uno puede aparecer una vez a la semana en producción y nunca en las pruebas. La estrategia correcta no es "depurarlos cuando ocurren" sino "estructurar el código para que no puedan ocurrir".

Las cuatro condiciones (condiciones de Coffman)

Un deadlock requiere que las cuatro sean verdaderas al mismo tiempo:

  1. Exclusión mutua. Algún recurso (un bloqueo) solo puede ser retenido por un hilo a la vez.
  2. Retener y esperar. Un hilo retiene al menos un recurso mientras espera adquirir otro.
  3. Sin preferencia. Los recursos no pueden ser arrebatados al hilo que los retiene; el hilo debe liberarlos voluntariamente.
  4. Espera circular. Hay un ciclo en el grafo de espera — A espera el bloqueo de B, B espera el bloqueo de C, ..., Z espera el bloqueo de A.

Romper cualquiera de ellas hace que los deadlocks sean imposibles. Las técnicas de prevención estándar rompen una de las cuatro:

  • Ordenación de bloqueos (la más común): rompe la espera circular adquiriendo siempre los bloqueos en un orden globalmente acordado.
  • tryLock con tiempo de espera: rompe el retener-y-esperar cediendo si no se puede obtener el segundo bloqueo con suficiente rapidez.
  • Un único bloqueo grande: rompe la estructura de múltiples bloqueos por completo. Rudimentario pero funciona para poca contención.
  • Datos sin bloqueo / inmutables: rompe la exclusión mutua eliminando el recurso. Los átomos y colecciones concurrentes que se ven más adelante en esta parte del libro siguen este enfoque.

El ejemplo de las dos cuentas

La demostración canónica:

void transfer(Account from, Account to, int amount) {
  synchronized (from) {
    synchronized (to) {
      from.debit(amount);
      to.credit(amount);
    }
  }
}

// Thread A: transfer(accountX, accountY, 100)
// Thread B: transfer(accountY, accountX, 100)

Planificación:

  1. El hilo A adquiere el monitor de accountX.
  2. El hilo B adquiere el monitor de accountY.
  3. El hilo A intenta adquirir accountY — bloqueado, retenido por B.
  4. El hilo B intenta adquirir accountX — bloqueado, retenido por A.

Ningún hilo liberará nunca. Ambos están BLOCKED para siempre. La solución:

void transfer(Account from, Account to, int amount) {
  Account first  = from.id() < to.id() ? from : to;
  Account second = from.id() < to.id() ? to   : from;
  synchronized (first) {
    synchronized (second) {
      from.debit(amount);
      to.credit(amount);
    }
  }
}

Ambos hilos ahora adquieren accountX y luego accountY independientemente de la dirección de la transferencia. La espera circular no puede formarse.

La clave de ordenación no tiene que ser un idSystem.identityHashCode(obj) funciona como un desempate estable para cualquier objeto, pero las colisiones son posibles, por lo que el código en producción típicamente usa una clave real (el ID de la base de datos, el ID de usuario, etc.) y recurre a un bloqueo de desempate cuando las claves coinciden.

Ordenación de bloqueos en todo el programa

La ordenación de bloqueos solo funciona si cada ruta de código que toma dos bloqueos del mismo tipo los toma en el mismo orden. Un único método rebelde que hace synchronized (b) { synchronized (a) { ... } } es suficiente para reintroducir el deadlock.

La manera de hacer cumplir esto consistentemente en una base de código más grande:

  • Documentar el orden. "Siempre adquirir parent antes que child." Comentarlo en la clase.
  • Canalizar a través de un único ayudante. Todas las llamadas de "transferencia" pasan por un método que realiza la ordenación — de modo que un sitio de llamada individual no puede equivocarse.
  • -XX:+PrintConcurrentLocks en un volcado de hilos es una manera de inspeccionar los grafos reales de adquisición de bloqueos en producción.

La disciplina importa tanto como la regla.

tryLock con tiempo de espera

Cuando no se puede garantizar el orden — diferentes bibliotecas, diferentes equipos, grafos de objetos complejos — ReentrantLock.tryLock(timeout, unit) ofrece una salida:

boolean done = false;
while (!done) {
  if (firstLock.tryLock(100, TimeUnit.MILLISECONDS)) {
    try {
      if (secondLock.tryLock(100, TimeUnit.MILLISECONDS)) {
        try {
          doWork();
          done = true;
        } finally { secondLock.unlock(); }
      }
    } finally { firstLock.unlock(); }
  }
  // back off briefly, retry — eventually we'll get both
}

Si el segundo bloqueo no se puede obtener en 100 ms, el hilo libera el primer bloqueo y lo intenta de nuevo más tarde. La condición de retener-y-esperar se rompe — ningún hilo se bloquea para siempre, incluso si ambos intentan los mismos bloqueos en órdenes opuestos.

El costo son los reintentos ocupados y el código de retroceso circundante. Usa la ordenación de bloqueos cuando puedas; recurre a tryLock cuando no puedas.

Cómo detectar un deadlock en tiempo de ejecución

Dos herramientas principales.

Volcado de hilos. jstack <pid> o kill -3 <pid> imprime el estado y la pila de cada hilo. Un deadlock aparece claramente: dos hilos con estado BLOCKED, cada uno - waiting to lock <0x...> sobre un objeto que el otro muestra como - locked <0x...>. La JVM de Java es incluso lo suficientemente amable como para señalar los ciclos obvios al final del volcado:

Found one Java-level deadlock:
=============================
"thread-2":
  waiting to lock monitor 0x00007fcd0e..., which is held by "thread-1"
"thread-1":
  waiting to lock monitor 0x00007fcd0e..., which is held by "thread-2"

ThreadMXBean.findDeadlockedThreads(). Una versión programática — útil para embeber en un endpoint de verificación de salud:

ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] deadlocked = mx.findDeadlockedThreads();
if (deadlocked != null) log.error("deadlock detected: {} threads", deadlocked.length);

Esto encuentra únicamente deadlocks en monitores intrínsecos y ReentrantLock. No encuentra livelocks ni casos generales de "el hilo simplemente es lento".

Livelock y inanición — primos del deadlock

Dos modos de fallo que parecen deadlocks pero no lo son:

  • Livelock. Los hilos siguen cambiando de estado pero no avanzan. El caso clásico: dos llamantes de tryLock reintentan indefinidamente porque ninguno cederá primero. La CPU está ocupada; el trabajo no se está realizando.
  • Inanición. Un hilo está técnicamente RUNNABLE o puede ser despertado, pero el planificador / la política de bloqueo nunca lo deja ejecutarse realmente. Los bloqueos injustos bajo alta contención pueden hacer que un escritor pase hambre mientras los lectores pasan en masa.

Ambos tienen el mismo síntoma superficial que un deadlock ("nada parece estar avanzando") pero el diagnóstico es diferente — el volcado de hilos no muestra BLOCKED en un ciclo mutuo; muestra hilos dando vueltas o simplemente uno esperando perpetuamente.

Un ejemplo trabajado: deadlock creado y luego prevenido

El programa a continuación ejecuta el patrón de transferencia en ambas direcciones — primero con la versión de bloqueo anidado roto (que producirá un deadlock bajo contención), y luego con la corrección de ordenación de bloqueos que lo previene. La versión rota está envuelta en un tiempo de espera de vigilancia para que la demostración no se cuelgue para siempre.

java— editable, runs on the server

Qué tomar de la ejecución:

  • La variante BROKEN no completó las 100 transferencias. Bajo contención, t1 terminó reteniendo a y esperando por b mientras t2 retenía b y esperaba por a. El vigilante alcanzó su límite de 3 segundos; findDeadlockedThreads() confirmó el ciclo. Eso es un deadlock — sin excepción, sin registro, sin nada malo en ninguna línea de código individual.
  • La variante FIXED terminó limpiamente. La regla de ordenación (first = id-mínimo, second = id-máximo) significa que ambos hilos adquieren a primero y b segundo, independientemente de la dirección de la transferencia. El ciclo no puede formarse porque ambos hilos recorren el grafo de bloqueos en la misma dirección.
  • Thread.sleep(1) dentro del primer synchronized de la versión rota hace que el deadlock sea muy reproducible. En código real, casi nunca se ve este tipo de espera explícita — pero I/O, GC, o un cambio de contexto pueden producir la misma ventana. Por eso los deadlocks se reproducen de manera intermitente en producción y nunca en las pruebas.
  • ThreadMXBean.findDeadlockedThreads() devolvió un array no nulo para la variante rota y confirmó el conteo de hilos en ciclo. Esa llamada es tu red de seguridad para la detección en proceso — conéctala a un endpoint de salud y te enterarás del deadlock antes que el usuario.
  • Después de que el vigilante declaró que la variante rota estaba atascada, el programa interrumpió ambos hilos. interrupt() no despierta un hilo bloqueado en un monitor synchronized — solo despierta hilos en sleep, wait, join, o LockSupport.park. Por eso interrumpir un deadlock no lo desatasca; tendrías que matar la JVM (o usar ReentrantLock.lockInterruptibly).

Qué sigue

El siguiente capítulo, Java volatile, aborda la mitad de visibilidad de la historia de seguridad — la palabra clave que soluciona "un hilo escribe, otro hilo lee el valor antiguo para siempre" sin involucrar bloqueos.

Práctica

Práctica
¿Qué estrategia rompe directamente la condición de Coffman de 'espera circular' y es la técnica de prevención de deadlocks más común en Java?
¿Qué estrategia rompe directamente la condición de Coffman de 'espera circular' y es la técnica de prevención de deadlocks más común en Java?
Was this page helpful?