Deadlock en Java
Ce que sont les deadlocks en Java, comment ils se produisent et les techniques pour les prévenir.
Un deadlock est le mode d'échec du verrouillage. Deux threads ou plus détiennent chacun un verrou dont l'autre a besoin ; aucun ne peut progresser ; aucune exception n'est levée ; rien dans le journal n'indique « nous sommes bloqués ». De l'extérieur, le programme semble ne rien faire — exactement le même symptôme externe qu'une boucle occupée ou un long appel réseau.
Les deadlocks surviennent dans tout programme qui acquiert plus d'un verrou à la fois. Ils sont terriblement faciles à écrire et terriblement difficiles à reproduire — l'ordonnancement qui en déclenche un peut n'apparaître qu'une fois par semaine en production et jamais lors des tests. La bonne stratégie n'est pas « les déboguer quand ils arrivent » mais « structurer le code pour qu'ils ne puissent pas arriver ».
Les quatre conditions (conditions de Coffman)
Un deadlock requiert que les quatre conditions suivantes soient vraies simultanément :
- Exclusion mutuelle. Une ressource (un verrou) ne peut être détenue que par un seul thread à la fois.
- Rétention et attente. Un thread détient au moins une ressource tout en attendant d'en acquérir une autre.
- Absence de préemption. Les ressources ne peuvent pas être retirées du thread qui les détient ; le thread doit les libérer volontairement.
- Attente circulaire. Il existe un cycle dans le graphe d'attente — A attend le verrou de B, B attend le verrou de C, ..., Z attend le verrou de A.
Rompre l'une de ces conditions rend les deadlocks impossibles. Les techniques de prévention standard brisent chacune l'une des quatre :
- Ordonnancement des verrous (le plus courant) : rompre l'attente circulaire en acquérant toujours les verrous dans un ordre convenu globalement.
tryLockavec délai d'attente : rompre la rétention-et-attente en abandonnant si on ne peut pas obtenir le second verrou assez rapidement.- Un verrou global unique : éliminer entièrement la structure à plusieurs verrous. Rudimentaire mais efficace pour une faible contention.
- Données sans verrou / immuables : rompre l'exclusion mutuelle en supprimant la ressource. Les atomiques et les collections concurrentes présentés plus loin dans cette partie du livre suivent cette approche.
L'exemple des deux comptes
La démonstration canonique :
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)Ordonnancement :
- Le thread A acquiert le moniteur de
accountX. - Le thread B acquiert le moniteur de
accountY. - Le thread A tente d'acquérir
accountY— bloqué, détenu par B. - Le thread B tente d'acquérir
accountX— bloqué, détenu par A.
Aucun thread ne se libérera jamais. Les deux sont BLOCKED indéfiniment. La correction :
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);
}
}
}Les deux threads acquièrent maintenant accountX puis accountY quelle que soit la direction du transfert. L'attente circulaire ne peut pas se former.
La clé d'ordonnancement n'a pas besoin d'être un id — System.identityHashCode(obj) fonctionne comme discriminant stable pour n'importe quel objet, mais des collisions sont possibles, si bien que le code de production utilise généralement une vraie clé (l'identifiant de base de données, l'identifiant utilisateur, etc.) et se replie sur un verrou de départage en cas d'égalité de clés.
Ordonnancement des verrous dans tout le programme
L'ordonnancement des verrous ne fonctionne que si chaque chemin de code qui prend deux verrous du même type les prend dans le même ordre. Une seule méthode non conforme qui fait synchronized (b) { synchronized (a) { ... } } suffit à faire réapparaître le deadlock.
La façon d'appliquer cela de manière cohérente dans une base de code plus grande :
- Documenter l'ordre. « Acquérir toujours
parentavantchild. » Commentez-le sur la classe. - Centraliser dans un unique helper. Tous les appels « transfer » passent par une méthode qui gère l'ordonnancement — ainsi un site d'appel individuel ne peut pas se tromper.
-XX:+PrintConcurrentLocksdans un thread dump est un moyen d'inspecter les graphes d'acquisition de verrous réels en production.
La discipline compte autant que la règle.
tryLock avec délai d'attente
Quand vous ne pouvez pas garantir l'ordonnancement — bibliothèques différentes, équipes différentes, graphes d'objets complexes — ReentrantLock.tryLock(timeout, unit) vous offre une échappatoire :
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 le second verrou ne peut pas être obtenu en 100 ms, le thread libère le premier verrou et réessaie plus tard. La condition de rétention-et-attente est rompue — aucun thread ne se bloque indéfiniment, même si les deux essaient les mêmes verrous dans des ordres opposés.
Le coût est la gestion des nouvelles tentatives et le code de recul associé. Utilisez l'ordonnancement des verrous quand vous le pouvez ; optez pour tryLock quand ce n'est pas possible.
Comment détecter un deadlock à l'exécution
Deux outils principaux.
Thread dump. jstack <pid> ou kill -3 <pid> imprime l'état et la pile de chaque thread. Un deadlock apparaît clairement : deux threads avec l'état BLOCKED, chacun - waiting to lock <0x...> sur un objet que l'autre montre - locked <0x...>. La JVM Java est même assez aimable pour signaler les cycles évidents au bas du dump :
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(). Une version programmatique — utile pour l'intégrer dans un endpoint de contrôle de santé :
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
long[] deadlocked = mx.findDeadlockedThreads();
if (deadlocked != null) log.error("deadlock detected: {} threads", deadlocked.length);Cela ne trouve que les deadlocks sur les moniteurs intrinsèques et ReentrantLock. Cela ne détecte pas les livelocks ni les cas « le thread est juste lent ».
Livelock et famine — les cousins du deadlock
Deux modes d'échec qui ressemblent à des deadlocks mais n'en sont pas :
- Livelock. Les threads continuent de changer d'état mais ne progressent pas. Le cas classique : deux appelants
tryLockréessaient indéfiniment parce qu'aucun ne cédera en premier. Le CPU est occupé ; le travail n'avance pas. - Famine. Un thread est techniquement
RUNNABLEou réveillable mais l'ordonnanceur / la politique de verrous ne lui permet jamais de s'exécuter réellement. Des verrous inéquitables sous forte contention peuvent affamer un thread écrivain tandis que les lecteurs défilent.
Les deux ont le même symptôme de surface qu'un deadlock (« rien ne semble progresser ») mais le diagnostic est différent — le thread dump ne montre pas de BLOCKED dans un cycle mutuel ; il montre des threads qui s'agitent ou un seul qui attend perpétuellement.
Un exemple complet : deadlock créé puis prévenu
Le programme ci-dessous exécute le schéma de transfert dans les deux sens — d'abord avec la version défectueuse à verrous imbriqués (qui produira un deadlock sous contention), puis avec le correctif par ordonnancement des verrous qui l'empêche. La version défectueuse est encapsulée dans un délai d'attente de surveillance afin que la démonstration ne se bloque pas indéfiniment.
Ce qu'il faut retenir de l'exécution :
- La variante
BROKENn'a pas complété les 100 transferts. Sous contention,t1s'est retrouvé à déteniraet à attendrebtandis quet2détenaitbet attendaita. Le surveillance a atteint son délai de 3 secondes ;findDeadlockedThreads()a confirmé le cycle. C'est un deadlock — pas d'exception, pas de journal, rien de défectueux dans aucune ligne de code individuelle. - La variante
FIXEDs'est terminée proprement. La règle d'ordonnancement (first = id-min, second = id-max) signifie que les deux threads acquièrentaen premier etben second, quelle que soit la direction du transfert. Le cycle ne peut pas se former car les deux threads parcourent le graphe de verrous dans la même direction. Thread.sleep(1)à l'intérieur du premiersynchronizedde la version défectueuse rend le deadlock très reproductible. Dans du code réel, on ne voit presque jamais ce type desleepexplicite — mais les E/S, le GC ou un changement de contexte peuvent produire la même fenêtre. C'est pourquoi les deadlocks se reproduisent de manière intermittente en production et jamais lors des tests.ThreadMXBean.findDeadlockedThreads()a retourné un tableau non null pour la variante défectueuse et a confirmé le nombre de threads en cycle. Cet appel est votre filet de sécurité pour la détection en cours de processus — intégrez-le dans un endpoint de santé et vous serez averti du deadlock avant l'utilisateur.- Après que la surveillance a déclaré la variante défectueuse bloquée, le programme a interrompu les deux threads.
interrupt()ne réveille pas un thread bloqué sur un moniteursynchronized— il ne réveille que les threads danssleep,wait,joinouLockSupport.park. C'est pourquoi interrompre un deadlock ne le débloque pas ; il faudrait tuer la JVM (ou utiliserReentrantLock.lockInterruptibly).
Prochaine étape
Le chapitre suivant, Java volatile, aborde la moitié visibilité de l'histoire de la sécurité — le mot-clé qui résout « un thread écrit, un autre thread lit indéfiniment l'ancienne valeur » sans impliquer de verrous.