W3docs

Méthodes et blocs synchronisés en Java

Utilisez les méthodes et blocs synchronized en Java pour protéger les sections critiques et choisir le bon objet de verrouillage.

Le chapitre précédent a établi ce que fait synchronized. Ce chapitre est consacré à la syntaxe — les trois formes que peut prendre le mot-clé, quel verrou chaque forme utilise, et comment choisir la bonne. La forme choisie a des conséquences sur les performances et l'exactitude ; "mettre simplement synchronized sur la méthode" fonctionne dans les cas triviaux et échoue lorsque la classe grandit.

Trois formes, trois objets de verrouillage

FormeObjet de verrouillageQuand l'utiliser
synchronized void method()thisClasses petites et simples. Verrou public acceptable.
synchronized static void method()ClassName.classMutation d'un état par classe depuis n'importe quelle instance.
synchronized (obj) { ... }objPresque tout le reste. Utilisez un verrou privé pour la sécurité.

La troisième forme est la plus flexible. Les deux premières en sont des formes abrégées.

synchronized sur une méthode d'instance

public synchronized void deposit(int x) {
  balance += x;
}

Se compile en un bloc qui se verrouille sur this. Un seul thread à la fois peut exécuter n'importe quelle méthode d'instance synchronisée sur cet objet spécifique. (Des instances différentes d'Account ont des références this différentes et donc des moniteurs différents.) Les méthodes statiques et les méthodes non synchronisées ne sont pas affectées.

L'écueil. this fait partie de la référence publique. Tout code qui possède une référence à l'objet peut faire synchronized (account) { ... } et tenir le même verrou qu'account.deposit(). Cela inclut les harnais de test, les débogueurs, le code de framework et tout autre site d'appel que vous ne contrôlez pas. Un appelant malveillant peut tenir votre verrou aussi longtemps qu'il le souhaite et vous serez bloqué.

Dans les petites classes, vous serez le seul appelant — c'est acceptable. Dans les bibliothèques, dans le code que d'autres personnes utiliseront, ou dans les classes que vous pourriez refactoriser plus tard, préférez un objet de verrou privé.

synchronized sur une méthode statique

public class Counters {
  private static int total;

  public static synchronized void bump() {
    total++;
  }
}

Se compile en un bloc qui se verrouille sur Counters.class. Le moniteur est global par classe — chaque thread, chaque instance, se bat pour le même verrou lors de l'appel à bump(). La même mise en garde que pour this s'applique : tout autre code peut également faire synchronized (Counters.class) { ... } et tenir le verrou.

Pour l'état par classe, cette forme convient dans les petites classes utilitaires. Pour les plus grandes, préférez un verrou statique privé :

public class Counters {
  private static final Object LOCK = new Object();
  private static int total;

  public static void bump() {
    synchronized (LOCK) { total++; }
  }
}

synchronized sur un objet explicite — la forme de production

public class Cache {
  private final Object lock = new Object();
  private final Map<String, String> data = new HashMap<>();

  public String get(String k) {
    synchronized (lock) {
      return data.get(k);
    }
  }

  public void put(String k, String v) {
    synchronized (lock) {
      data.put(k, v);
    }
  }
}

Deux propriétés que cette forme vous offre :

  • Verrou privé. Aucun appelant ne peut l'acquérir ; personne ne peut vous bloquer.
  • Portée chirurgicale. Seul l'intérieur du bloc tient le verrou. Tout ce qui est à l'extérieur — validation des arguments, formatage de la valeur de retour, journalisation — s'exécute sans contention.

Pour la même raison que vous gardez les champs private final privés, vous gardez votre verrou privé. L'objet de verrouillage fait partie de votre implémentation, pas de votre interface.

Règle : gardez la section critique étroite

Plus il y a de code qui s'exécute en tenant un verrou, plus vous créez de contention. Le bon modèle est de faire le minimum nécessaire à l'intérieur du bloc :

// Bad: I/O inside the lock — everybody waits while one thread talks to disk
public synchronized void load(String k) {
  String v = Files.readString(Path.of("/tmp/" + k));         // bad
  cache.put(k, v);
}

// Good: read outside the lock, lock only the mutation
public void load(String k) throws IOException {
  String v = Files.readString(Path.of("/tmp/" + k));
  synchronized (lock) {
    cache.put(k, v);
  }
}

Le principe général : verrouiller uniquement lors de la modification de l'état partagé, jamais lors d'un travail arbitraire susceptible de bloquer.

Actions composées et double verrouillage

synchronized protège un bloc. Si deux opérations ensemble doivent être atomiques, les deux doivent être dans le même bloc :

// Wrong: the if and the put are individually synchronised by HashMap... no they're not,
// but even if they were, the gap between them is not.
if (!map.containsKey(k)) {                            // someone else could insert here
  map.put(k, v);
}

// Right: one block protects both ops
synchronized (lock) {
  if (!map.containsKey(k)) {
    map.put(k, v);
  }
}

// Even better: a single atomic operation
map.putIfAbsent(k, v);                                // for ConcurrentHashMap, fully atomic

La course entre containsKey et put — connue sous le nom de course vérifier-puis-agir — est la source de plus de bogues de concurrence que le verrouillage lui-même. Chaque fois que vous écrivez if (...) doThing(), demandez-vous : entre le if et le doThing, un autre thread peut-il changer la réponse ? Si oui, atomisez.

Les verrous ne se composent pas — attention à l'ordre des verrous

Deux blocs synchronized acquis dans des ordres différents par des threads différents peuvent provoquer un interblocage :

// Thread A
synchronized (account1) {
  synchronized (account2) { transfer(account1, account2, 100); }
}

// Thread B simultaneously
synchronized (account2) {
  synchronized (account1) { transfer(account2, account1, 100); }
}

Chaque thread tient un verrou et attend l'autre. Les deux threads sont bloqués (BLOCKED) pour toujours. La solution est un ordre cohérent — toujours acquérir les verrous dans le même ordre global :

void transfer(Account a, Account b, int x) {
  Account first  = a.id() < b.id() ? a : b;          // ordering by stable key
  Account second = a.id() < b.id() ? b : a;
  synchronized (first) {
    synchronized (second) {
      a.debit(x);
      b.credit(x);
    }
  }
}

L'ordre basé sur le hachage, l'ordre basé sur System.identityHashCode, ou un verrou de départage sont les trois approches habituelles. Le chapitre sur les interblocages les couvre en profondeur.

Qu'en est-il de synchronized sur un primitif ?

Vous ne pouvez pas. synchronized requiert un objet — un long ou un int n'a pas de moniteur. Encapsulez-le (Long/Integer) et vous pouvez syntaxiquement le verrouiller, mais ne faites jamais cela : les primitifs encapsulés dans le cache d'auto-boxing sont partagés. Deux morceaux de code qui verrouillent Integer.valueOf(1) verrouillent le même objet — même s'ils n'ont rien à voir l'un avec l'autre.

synchronized (Integer.valueOf(1)) {                   // never do this
  ...
}

Pour les objets de verrouillage, allouez toujours un Object privé. Tout l'intérêt d'un moniteur est l'identité, pas la valeur.

synchronized et les exceptions

Si le corps d'un bloc synchronisé lève une exception, le moniteur est libéré lors du déroulement de la pile. Vous n'avez pas besoin d'un finally pour le déverrouillage — la JVM s'en charge. C'est l'une des principales raisons pour lesquelles synchronized est difficile à mal utiliser : il n'y a pas de "fuite de verrou" comme c'est le cas avec l'API Lock explicite et ses méthodes lock()/unlock() (où le déverrouillage est un appel de méthode séparé que vous devez penser à placer dans un finally).

La contrepartie : tout état partagé que vous avez partiellement modifié à l'intérieur du bloc est visible pour le prochain acquéreur. Si l'exception laisse vos invariants brisés, le verrou seul ne vous sauve pas — restaurez les invariants dans le catch ou concevez la mutation de sorte qu'elle ne puisse pas se terminer à moitié.

Un exemple concret : les quatre formes côte à côte

Le programme ci-dessous utilise chaque forme sur le même état partagé et se termine par une comparaison côte à côte.

java— editable, runs on the server

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

  • Les trois formes de compteur ont produit le nombre attendu 800 000. Chaque forme a choisi un objet de verrouillage différent (this, un Object privé, la Class), mais chacune a protégé la lecture-modification-écriture de la même manière. synchronized ne se soucie pas de ce qu'est l'objet de verrouillage — seulement que chaque thread concurrent utilise le même.
  • La forme méthode statique (V3) a utilisé le moniteur V3.class comme verrou. Chaque thread, chaque test, chaque autre morceau de code qui se synchronise sur V3.class contendrait pour le même verrou. C'est approprié pour l'état par classe ; l'utiliser pour l'état par instance est un bogue de contention — vous verrouilleriez des travaux sans rapport les uns contre les autres.
  • Les formes méthode statique et méthode d'instance sont pratiques mais se verrouillent sur un objet publiquement accessible (this ou la Class). N'importe qui peut faire synchronized (someObject) et tenir le même moniteur. La forme objet-verrou-privé (V2) est ce qu'utilise le code de production précisément parce que personne en dehors de la classe ne peut atteindre le verrou.
  • La classe V4 (définie mais pas benchmarkée ci-dessus) montre la mauvaise forme : un travail de type E/S à l'intérieur de la section critique. La version correcte suivante déplace le formatage et l'appel bloquant (simulé) en dehors du bloc synchronized afin que la contention ne porte que sur le véritable put. La même exactitude, avec un débit bien plus élevé sous charge.
  • Le bloc à double verrouillage à la fin a acquis deux verrous sans rapport dans l'ordre déterminé par System.identityHashCode. Cette règle d'ordre, appliquée partout dans le programme, est la stratégie de prévention des interblocages la plus simple lorsque vous devez tenir deux verrous à la fois. Nous la reverrons dans le chapitre sur les interblocages.

Prochaine étape

Le chapitre suivant, Communication inter-threads en Java, présente l'autre moitié de l'API du moniteur intrinsèque — wait, notify et notifyAll — la façon dont les threads se signalent mutuellement à l'intérieur d'une section critique.

Pratique

Pratique
Vous écrivez `public synchronized void deposit(int x)` sur une méthode de `class Account`. Quel moniteur la méthode acquiert-elle ?
Vous écrivez `public synchronized void deposit(int x)` sur une méthode de `class Account`. Quel moniteur la méthode acquiert-elle ?
Was this page helpful?