Synchronisation Java
Coordonnez l'accès à l'état partagé entre les threads Java avec le mot-clé synchronized et les verrous intrinsèques.
L'introduction au multithreading a mis en garde contre trois modes d'échec — les races, les bugs de visibilité et les deadlocks. synchronized est la première réponse de Java aux deux premiers. Il offre à un bloc de code deux garanties simultanées : l'exclusion mutuelle (un seul thread à la fois peut s'y trouver) et la visibilité mémoire (les écritures effectuées à l'intérieur du bloc par un thread sont visibles par le prochain thread qui y entre). Ces deux garanties combinées suffisent à rendre une grande quantité de code multithread correct.
Ce chapitre est conceptuel — ce que fait synchronized, ce qu'est un moniteur intrinsèque, quelles races il corrige et lesquelles il ne corrige pas. Le chapitre suivant, blocs synchronized, présente les formes syntaxiques et comment choisir entre elles.
La race que le mot-clé existe pour corriger
class Counter {
int n;
void increment() { n++; }
}
Counter c = new Counter();
// Thread A and Thread B both call c.increment() a million times.
// After both finish, what is c.n?n++ est une seule ligne source et trois opérations bytecode : charger n, ajouter 1, stocker n. Si le thread A charge n=42, puis que le thread B charge n=42 avant que A ne stocke, les deux ajoutent 1 et stockent tous les deux 43. Un incrément est perdu. Exécutez le programme un million de fois par thread et c.n est systématiquement inférieur à 2_000_000.
synchronized est la solution :
class Counter {
int n;
synchronized void increment() { n++; }
}Maintenant un seul thread à la fois exécute increment sur ce Counter. L'autre attend à la porte. Résultat : c.n == 2_000_000, à chaque exécution.
Ce qu'est un moniteur
Chaque objet Java possède, caché dans la JVM, un verrou associé appelé le moniteur intrinsèque (ou verrou moniteur). C'est simplement une structure de données de type long avec deux éléments d'état : un thread propriétaire (ou null) et une file d'attente. Un thread qui entre dans un bloc synchronized :
- Tente d'acquérir le moniteur de l'objet sur lequel porte le
synchronized. - Si le moniteur n'est pas possédé, le prend (maintenant
owner == self) et continue. - Si le moniteur est possédé par un autre thread, ce thread passe à l'état
BLOCKEDet rejoint la file d'attente. - Quand le propriétaire sort du bloc, la JVM libère le moniteur et un des threads en attente l'obtient.
Le moniteur est par objet. Deux instances de Counter ont deux moniteurs séparés ; les threads opérant sur des Counter différents ne se bloquent pas mutuellement. C'est important — la synchronisation porte sur l'objet, pas sur « la méthode ».
synchronized (someObject) {
// critical section: only one thread at a time
// holds someObject's monitor inside this block
}synchronized sur une méthode d'instance est un raccourci pour synchronized (this). Sur une méthode statique, c'est un raccourci pour synchronized (Counter.class) — le moniteur de l'objet Class.
La visibilité, pas seulement l'exclusion
L'exclusion mutuelle est la partie évidente. La partie moins évidente — et plus importante — est la relation happens-before que la JVM vous offre gratuitement :
Tout ce qu'un thread fait avant de relâcher un moniteur est garanti d'être visible pour tout thread qui acquiert ensuite le même moniteur.
Cette phrase est ce qui rend synchronized correct, pas seulement « premier arrivé, premier servi ». Sans elle, deux threads peuvent utiliser un bloc synchronized, s'entendre sur l'exclusion mutuelle, et voir quand même les écritures de l'autre dans le mauvais ordre — parce que les caches CPU et le JIT sont sinon libres de réordonner. La paire relâchement/acquisition installe une barrière mémoire qui force le CPU et le JIT à vider et recharger.
L'implication : tout champ qu'un programme multithread lit ou écrit en dehors d'un bloc synchronized (et pas via volatile, un atomic, ou une autre primitive java.util.concurrent) n'a aucune garantie de visibilité. Un thread peut écrire done = true et un autre thread peut voir done = false indéfiniment. Nous y reviendrons quand nous aborderons volatile et le modèle mémoire Java.
Ce que synchronized ne corrige pas
Quatre choses que les débutants attendent souvent de synchronized qu'il ne fournit pas :
- Il ne verrouille pas les données.
synchronized (list)n'empêche pas d'autres codes de toucherlist; il empêche un autre thread de détenir le même moniteur. Si un autre chemin de code opère surlistsans acquérir le même moniteur, la protection disparaît. - Il ne se compose pas entre objets.
synchronized (a); synchronized (b);sont deux acquisitions séparées ; si un autre thread les acquiert dans l'ordre inverse, vous avez un deadlock. - Il n'accélère rien. Les verrous sont purement une surcharge. Utilisez-les uniquement là où la correction l'exige.
- Il ne corrige pas toutes les races. Les actions composées comme « vérifier puis agir » restent en compétition même si chaque opération individuelle est synchronisée.
if (map.containsKey(k)) map.put(k, v)est cassé même sicontainsKeyetputsont individuellement thread-safe — l'intervalle entre les deux appels n'est pas protégé. UtilisezputIfAbsentou un seul bloc synchronisé englobant les deux.
Réentrance
Le moniteur intrinsèque est réentrant : un thread qui détient déjà un moniteur peut entrer dans un autre bloc synchronized sur le même objet sans se bloquer lui-même. C'est pourquoi ceci fonctionne :
class Account {
synchronized void deposit(int x) { balance += x; }
synchronized void transferTo(Account other, int x) {
deposit(-x); // re-enters same monitor — fine
other.deposit(x); // acquires other's monitor too
}
}Si les moniteurs n'étaient pas réentrants, l'appel interne à deposit se bloquerait sur le moniteur que l'appel externe détient déjà — un self-deadlock immédiat. La réentrance rend sûr l'appel d'une autre méthode synchronisée sur le même objet.
L'autre côté : chaque acquisition nécessite une libération correspondante. La JVM maintient un compteur ; le moniteur est libéré quand le compteur tombe à zéro.
Sur quoi synchroniser
Quelques règles qui préviennent la plupart des bugs d'utilisation incorrecte des verrous :
- Synchronisez sur un objet verrou privé, pas sur
this. Le code externe peut aussi fairesynchronized (yourInstance); cela permet à un appelant de tenir votre verrou aussi longtemps qu'il le souhaite. Unfinal Object lock = new Object();privé vous appartient et personne d'autre ne peut le prendre. - Ne synchronisez pas sur des littéraux
Stringou des primitives encadrées. Ils sont internés/mis en cache ; deux blocssynchronized ("foo")dans différentes parties de votre code partagent un moniteur avec quiconque a aussi dit"foo". - Ne synchronisez pas sur une référence qui peut changer.
synchronized (myField)oùmyFieldpeut être réassigné représente deux moniteurs différents au fil du temps. Le compilateur ne peut pas le détecter ; le bug est silencieux. - Gardez la section critique petite. Plus vous faites de choses dans un bloc
synchronized, plus longtemps les autres attendent. Tenez le verrou pendant que vous modifiez l'état partagé, pas pendant les E/S environnantes.
Un exemple concret : avec et sans le verrou
Le programme ci-dessous exécute la même charge de travail de compteur partagé de trois façons : sans synchronisation, avec une méthode synchronized, et avec un bloc synchronized sur un objet verrou dédié. Les chiffres montrent que la première forme perd des mises à jour et pas les deux autres.
Ce qu'il faut retenir de l'exécution :
- La ligne
unsafea systématiquement perdu des mises à jour — la valeur finale était quelque part en dessous du1_000_000attendu. Deux threads effectuantn++entrent en compétition sur la lecture-modification-écriture ; certains incréments disparaissent. Même quand le test passe sur une seule exécution par chance, le JIT, le planificateur OS, ou un CPU différent finira par le révéler. La mutation non synchronisée d'un champ partagé est cassée. - Les deux variantes sécurisées ont produit le comptage exact attendu, à chaque fois. L'exclusion mutuelle est la partie évidente de ce que fait
synchronized; la partie moins visible est que la valeur lue parvalue()est la dernière écrite parincrement— c'est la garantie de visibilité. Sans la paire de moniteurs, la lecture pourrait légitimement voir une copie en cache périmée. - Les chiffres de temps d'exécution pour
sync methodetsync blockétaient tous les deux nettement plus élevés que pourunsafe. Les verrous ne sont pas gratuits — chaque entrée/sortie effectue une barrière mémoire et (sous contention) un changement de contexte de thread. Synchronisez là où la correction l'exige ; ne saupoudrez pas pour la « sécurité ». - La variante
sync block on private lockest ce qu'utilise le code de production. La formesync methodverrouille surthis, que tout appelant externe peut aussi acquérir — ils peuvent vous affamer en tenant votre propre verrou. Un objet verrou privé que vous n'exposez jamais est le vôtre uniquement. - Le bloc de réentrance s'est exécuté sans deadlock.
outer()détenait déjà le moniteur dethis;inner()l'a ré-entré sans se bloquer. C'est pourquoi une méthode synchronisée peut librement appeler une autre méthode synchronisée sur le même objet — sans réentrance, la moitié de la bibliothèque standard provoquerait des deadlocks.
La suite
Le chapitre suivant, Blocs synchronized Java, détaille les formes syntaxiques — méthode, bloc, statique — et les règles pour choisir le bon objet verrou. Pour une coordination de niveau supérieur entre les threads, voir la communication inter-thread (wait/notify).