W3docs

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 :

  1. Tente d'acquérir le moniteur de l'objet sur lequel porte le synchronized.
  2. Si le moniteur n'est pas possédé, le prend (maintenant owner == self) et continue.
  3. Si le moniteur est possédé par un autre thread, ce thread passe à l'état BLOCKED et rejoint la file d'attente.
  4. 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 :

  1. Il ne verrouille pas les données. synchronized (list) n'empêche pas d'autres codes de toucher list ; il empêche un autre thread de détenir le même moniteur. Si un autre chemin de code opère sur list sans acquérir le même moniteur, la protection disparaît.
  2. 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.
  3. Il n'accélère rien. Les verrous sont purement une surcharge. Utilisez-les uniquement là où la correction l'exige.
  4. 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 si containsKey et put sont individuellement thread-safe — l'intervalle entre les deux appels n'est pas protégé. Utilisez putIfAbsent ou 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 faire synchronized (yourInstance) ; cela permet à un appelant de tenir votre verrou aussi longtemps qu'il le souhaite. Un final Object lock = new Object(); privé vous appartient et personne d'autre ne peut le prendre.
  • Ne synchronisez pas sur des littéraux String ou des primitives encadrées. Ils sont internés/mis en cache ; deux blocs synchronized ("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)myField peut ê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.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • La ligne unsafe a systématiquement perdu des mises à jour — la valeur finale était quelque part en dessous du 1_000_000 attendu. Deux threads effectuant n++ 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 par value() est la dernière écrite par increment — 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 method et sync block étaient tous les deux nettement plus élevés que pour unsafe. 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 lock est ce qu'utilise le code de production. La forme sync method verrouille sur this, 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 de this ; 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).

Pratique

Pratique
Deux threads appellent chacun `counter.increment()` sur un `Counter` dont le champ `n` est un `int` non synchronisé. Après que les deux ont terminé 1 000 000 d'incréments, que montre typiquement `counter.n` ?
Deux threads appellent chacun `counter.increment()` sur un `Counter` dont le champ `n` est un `int` non synchronisé. Après que les deux ont terminé 1 000 000 d'incréments, que montre typiquement `counter.n` ?
Was this page helpful?