W3docs

Interface Lock en Java

L'interface java.util.concurrent.locks.Lock — ce qu'elle offre que `synchronized` ne peut pas, et les règles pour l'utiliser correctement.

synchronized est l'outil petit mais efficace. Il est rapide, automatique, et couvre la plupart des besoins d'exclusion mutuelle. Mais lorsque vous en avez dépassé les limites — lorsque vous avez besoin d'un délai d'attente, d'un moyen d'abandonner, ou de plus d'une variable de condition — Java propose une deuxième API de verrouillage plus riche : l'interface java.util.concurrent.locks.Lock et ses implémentations. Ce chapitre présente l'interface ; les deux chapitres suivants couvrent les deux implémentations (ReentrantLock, ReentrantReadWriteLock) que vous utiliserez réellement.

Ce que l'interface vous offre

public interface Lock {
  void lock();
  void lockInterruptibly() throws InterruptedException;
  boolean tryLock();
  boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
  void unlock();
  Condition newCondition();
}

Six méthodes. Cinq d'entre elles concernent l'acquisition ou la libération du verrou ; une retourne une Condition (la réponse de Lock à wait/notify).

Les quatre façons d'acquérir sont ce que synchronized ne vous offre pas :

  • lock() — bloquer jusqu'à l'acquisition. Le plus proche de synchronized.
  • lockInterruptibly() — bloquer jusqu'à l'acquisition, mais abandonner avec InterruptedException en cas d'interruption. Permet d'annuler un thread qui attend un verrou.
  • tryLock() — essayer une fois, retourner true/false immédiatement. Ne bloque pas.
  • tryLock(time, unit) — essayer jusqu'à un délai d'expiration, puis abandonner. L'outil de prévention des interblocages du chapitre précédent.

synchronized n'a qu'un seul mode d'acquisition — bloquer indéfiniment jusqu'à l'obtention. C'est approprié pour la plupart du code ; ce n'est pas approprié lorsque vous avez besoin d'une échéance ou d'un point d'annulation.

Le modèle obligatoire try/finally

synchronized libère le moniteur automatiquement lorsque le bloc se termine — qu'il s'agisse d'une complétion normale ou d'une exception. Lock ne le fait pas. Si vous oubliez d'appeler unlock, le verrou est maintenu indéfiniment et tout ce qui suit est bloqué.

Le bon modèle, à chaque fois :

lock.lock();
try {
  // critical section
} finally {
  lock.unlock();
}

Le unlock doit être dans un finally pour qu'il s'exécute même si le corps lève une exception. Il n'y a pas de try-with-resources pour Lock directement (ce n'est pas AutoCloseable), mais vous verrez des modèles d'encapsulation qui le simulent. Le modèle standard ci-dessus est ce que presque tout le code en production utilise.

tryLock et délai d'expiration

Les deux surcharges de tryLock permettent à Lock de gérer « que faire si on ne peut pas l'obtenir ? » :

if (lock.tryLock()) {
  try {
    doWork();
  } finally {
    lock.unlock();
  }
} else {
  // didn't get the lock — do something else, maybe retry later
}
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {       // wait up to 500ms
  try {
    doWork();
  } finally {
    lock.unlock();
  }
} else {
  throw new TimeoutException("couldn't acquire " + name);
}

Cette deuxième forme est ce qui rend la récupération après un interblocage possible. Avec synchronized, un thread qui attend un moniteur est bloqué jusqu'à ce que le détenteur le libère — il n'y a pas d'issue à part que la JVM s'arrête. Avec tryLock(timeout), vous abandonnez après une échéance et vous recommencez, faites échouer l'opération, ou prenez un chemin alternatif.

lockInterruptibly — acquisition de verrou annulable

synchronized ne répond pas à Thread.interrupt() pendant l'attente. Un thread BLOCKED sur un moniteur reste bloqué même si vous l'interrompez — la JVM positionne simplement le drapeau et l'oublie.

lock.lockInterruptibly() répond. Si un autre thread appelle interrupt() sur vous pendant que vous attendez le verrou, l'appel lance immédiatement InterruptedException :

try {
  lock.lockInterruptibly();
} catch (InterruptedException e) {
  Thread.currentThread().interrupt();
  return;                                              // gave up on the work
}
try {
  doWork();
} finally {
  lock.unlock();
}

C'est essentiel dans le code serveur : une requête arrive, un thread tente d'acquérir un verrou, la requête est annulée (déconnexion du client, délai d'expiration d'un équilibreur de charge), le superviseur appelle interrupt() sur le worker. Avec synchronized, le worker continue d'attendre ; avec lockInterruptibly, il abandonne.

Condition — le wait/notify adapté à Lock

L'équivalent avec moniteur intrinsèque — wait/notify sur un bloc synchronized — vous donne exactement un ensemble d'attente par objet. Un seul Lock peut avoir plusieurs objets Condition :

Lock lock = new ReentrantLock();
Condition notFull  = lock.newCondition();
Condition notEmpty = lock.newCondition();

Vous détenez le verrou, vous await() sur une condition (ce qui libère le verrou et vous met en attente), et un autre thread signal()e la condition (ce qui vous déplace vers l'état BLOCKED en attente du verrou). La correspondance avec wait/notify :

Lock + ConditionMoniteur intrinsèque
lock.lock()entrer dans synchronized (obj)
condition.await()obj.wait()
condition.signal()obj.notify()
condition.signalAll()obj.notifyAll()
lock.unlock()sortir de synchronized

L'avantage sur wait/notify : plusieurs conditions par verrou. Un tampon borné peut avoir une condition pour « non plein » et une pour « non vide » — les producteurs appellent signal(notEmpty) après avoir ajouté un élément ; les consommateurs appellent signal(notFull) après en avoir pris un. Seul le bon côté est réveillé. L'approche notifyAll avec moniteur unique doit réveiller tout le monde et espérer.

Nous verrons la réécriture du tampon borné dans le chapitre sur ReentrantLock.

Quand utiliser Lock, quand rester avec synchronized

Une règle de décision pragmatique :

  • Utilisez synchronized par défaut pour la simple exclusion mutuelle. C'est automatique, ne peut pas fuir, et la JVM l'optimise fortement.
  • Passez à Lock lorsque vous avez besoin de l'un des éléments suivants : un délai d'expiration lors de l'acquisition, la capacité d'annuler un attendeur via interrupt, plusieurs Conditions sur le même verrou, ou une distinction lecture-écriture (ReentrantReadWriteLock).
  • Passez à Lock quand la contention est forte et que vous avez besoin d'une option d'ordonnancement équitable (new ReentrantLock(true) est la version équitable ; les moniteurs intrinsèques ne sont pas équitables). L'ordonnancement équitable échange le débit contre la prévisibilité.

Vous ne devez pas « améliorer » synchronized en Lock sans raison. Les deux sont équivalents dans le cas de base ; le reste du chapitre porte sur quand les capacités supplémentaires sont utiles.

Ce que vous perdez

Lock a des coûts que synchronized n'a pas :

  • Pas de libération automatique. Oubliez finally et le verrou fuit. La JVM ne peut pas vous sauver.
  • Pas de vérification d'imbrication structurée. Avec synchronized, le compilateur impose le couplage verrou/déverrouillage ; avec Lock, vous pouvez appeler unlock() depuis une méthode ou un chemin différent et le compilateur ne s'en aperçoit pas.
  • Pas d'optimisations natives d'exécution. La JVM dispose d'optimisations spéciales pour les moniteurs intrinsèques (verrouillage biaisé, élargissement de verrou, élision de verrou dans certains cas) qui ne s'appliquent pas à Lock. Pour du code à très faible contention, synchronized peut être légèrement plus rapide.
  • Plus de surface pour les mauvaises utilisations. tryLock et lockInterruptibly doivent tous deux être couplés à une vérification ; manquer la vérification produit un bug silencieux « verrou non acquis ».

Utilisez Lock pour les capacités, pas pour la syntaxe.

Un exemple concret : Lock faisant ce que synchronized ne peut pas

Le programme ci-dessous utilise ReentrantLock (l'implémentation standard de Lock) pour démontrer les trois choses que synchronized n'offre pas : tryLock avec délai d'expiration, lockInterruptibly, et une Condition personnalisée.

java— editable, runs on the server

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

  • Le modèle try/finally de la section 1 est ce que chaque site d'appel Lock doit avoir. Il n'y a pas de protection syntaxique — si vous supprimez le finally, le code compile, et le verrou fuit dès la première exception dans le corps. Mémorisez la forme : lock(), try { ... } finally { unlock(); }.
  • Le tryLock(100, MS) de la section 2 a retourné false après environ 100 ms parce que le thread détenteur était toujours dans son attente de 500 ms. C'est le contrat d'échéance — l'appel retourne false après le délai d'expiration quoi qu'il arrive. Avec synchronized, ce thread aurait bloqué jusqu'à la libération par le détenteur, sans porte de sortie.
  • L'attendeur de la section 3 a été interrompu pendant qu'il attendait le verrou, et lockInterruptibly a lancé InterruptedException. Comparez avec lock.lock() ou synchronized — ni l'un ni l'autre ne répond à interrupt() pendant l'attente. C'est la différence entre un serveur qui peut annuler les requêtes ayant dépassé leur délai et un qui accumule simplement des threads bloqués.
  • La section 4 a utilisé deux Conditions sur un seul verrou — notFull pour les producteurs, notEmpty pour les consommateurs. Quand le producteur a ajouté un élément, il a signalé notEmpty spécifiquement ; seul un consommateur a été réveillé. Avec wait/notifyAll sur un moniteur intrinsèque, chaque thread en attente est réveillé et revérifie ; la paire Condition envoie le réveil au bon côté de la file et économise le cycle réveil/revérification.
  • Le signal() (singulier) plutôt que signalAll() est sûr ici parce que tous les awaiteurs sur chaque condition sont interchangeables — n'importe quel producteur peut remplir le slot que nous venons d'ouvrir. Si les attendeurs n'étaient pas interchangeables (par exemple, ils attendaient des clés spécifiques différentes), signalAll serait toujours la valeur par défaut la plus sûre.

Et ensuite

Le chapitre suivant, Java ReentrantLock, entre dans les détails de l'implémentation standard de Lock — sa réentrance, sa politique d'équité, et l'API de diagnostic getHoldCount/isHeldByCurrentThread.

Pratique

Pratique
Vous écrivez du code qui doit acquérir un verrou avec une échéance — abandonner après 200 ms si le verrou n'est pas disponible, et effectuer une action alternative à la place. Quelle approche est la bonne ?
Vous écrivez du code qui doit acquérir un verrou avec une échéance — abandonner après 200 ms si le verrou n'est pas disponible, et effectuer une action alternative à la place. Quelle approche est la bonne ?
Was this page helpful?