W3docs

Java ReentrantLock

Utilisez ReentrantLock pour un verrouillage explicite et flexible en Java — tryLock, lockInterruptibly, politique d'équité et API de diagnostic.

ReentrantLock est l'implémentation standard de l'interface Lock. « Réentrant » signifie que le même thread peut acquérir le verrou plusieurs fois sans se bloquer lui-même — la même propriété que possède synchronized. Tout ce que le chapitre sur Lock a décrit — tryLock, lockInterruptibly, Condition — est fourni par cette seule classe.

Ce chapitre approfondit le sujet : les deux constructeurs, l'option d'équité, les méthodes de diagnostic, l'association Condition pour le producteur/consommateur, et les cas concrets où ReentrantLock justifie son code try/finally supplémentaire par rapport à un simple synchronized.

Ce que couvre cette page

Deux constructeurs

Lock lock = new ReentrantLock();        // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true);    // fair — FIFO wait queue

Non-équitable (par défaut) signifie que lorsque le verrou devient disponible, le premier thread en attente que l'ordonnanceur exécute l'emporte. Les nouveaux threads entrants peuvent aussi « s'intercaler » — acquérir le verrou sans faire la queue s'il se trouve libre au moment de leur appel. C'est rapide : pas de manipulation de file d'attente, pas d'indication à l'ordonnanceur. L'inconvénient est la possibilité de famine — un thread peut rester longtemps dans la file d'attente pendant que d'autres s'intercalent continuellement.

Équitable signifie que le verrou est accordé au thread qui attend depuis le plus longtemps. La file d'attente est un vrai FIFO. Cela élimine la famine. Le coût : un débit notablement plus faible, car chaque acquisition implique une décision de l'ordonnanceur et la JVM ne peut pas prendre de raccourcis sur le chemin rapide.

Le bon défaut est non-équitable. Utilisez le mode équitable uniquement lorsque vous avez identifié un vrai problème de famine (généralement détecté par une requête getWaitQueueLength dont la valeur ne cesse de croître) ou lorsque la correction de l'application dépend de l'ordre de traitement.

La réentrance et getHoldCount

Comme synchronized, un ReentrantLock peut être réacquis par le thread qui le détient déjà :

ReentrantLock lock = new ReentrantLock();
lock.lock();
lock.lock();                          // same thread re-enters — fine
try {
  doStuff();
} finally {
  lock.unlock();
  lock.unlock();                      // must unlock as many times as locked
}

Chaque lock() incrémente un compteur de détention interne ; chaque unlock() le décrémente. Le verrou est réellement libéré — visible par les autres threads — uniquement lorsque le compteur atteint zéro. L'appel de diagnostic :

int n = lock.getHoldCount();          // how many times THIS thread has acquired without unlocking

getHoldCount est utile pour les assertions (« cette méthode doit être appelée avec le verrou détenu ») et pour vérifier les invariants dans les tests.

La règle de unlock correspondant est stricte. Si vous appelez lock deux fois et unlock une seule fois, le verrou reste détenu — fuite silencieuse. Si vous appelez unlock plus de fois que vous avez appelé lock, IllegalMonitorStateException est levée immédiatement. Associez toujours l'acquisition et la libération dans la même méthode si possible ; répartir cela sur plusieurs méthodes rend la gestion des compteurs fragile rapidement.

Autres méthodes de diagnostic

ReentrantLock expose une bonne dose d'introspection que synchronized ne fournit pas :

lock.isLocked();                       // is anybody holding it?
lock.isHeldByCurrentThread();          // do I hold it?
lock.getQueueLength();                 // how many threads are waiting?
lock.hasQueuedThreads();               // are any waiting?
lock.hasQueuedThread(t);               // is thread t waiting?
lock.getHoldCount();                   // how many times have I re-entered?

Ces méthodes servent principalement à la surveillance et aux tests — la logique de production ne devrait pas dépendre de « quelqu'un attend-il ? » car la réponse est sujette à une condition de course au moment où vous la vérifiez. Mais pour les métriques, le « ratio d'acquisitions disputées » est un signal utile indiquant que la granularité de votre verrou est incorrecte.

Quand ReentrantLock surpasse synchronized

Les quatre raisons de choisir ReentrantLock :

  1. Acquisition avec délai limite. Vous devez échouer rapidement ou reculer si le verrou n'est pas disponible dans un délai borné. tryLock(timeout) fait cela ; synchronized ne le fait pas.
  2. Annulation. Vous devez interrompre un thread en attente du verrou. lockInterruptibly fait cela ; synchronized ignore les interruptions lors de l'entrée dans le moniteur.
  3. Variables de condition multiples. Vous devez signaler séparément différentes catégories d'attentes. Lock.newCondition() fait cela ; le moniteur intrinsèque n'a qu'un seul ensemble d'attente.
  4. Équité. Vous avez besoin d'un ordre FIFO des attentes. new ReentrantLock(true) est le seul moyen natif.

Tout ce qui sort de ces quatre cas — code simple et bien écrit sans besoins particuliers — devrait rester sur synchronized. Les optimisations de la JVM pour les moniteurs intrinsèques sont réelles, et la discipline try/finally qu'exige Lock est quelque chose qu'on peut oublier. N'utilisez pas Lock « parce que c'est plus moderne. »

La paire Condition, en détail

Le modèle producteur/consommateur avec un ReentrantLock et deux conditions est l'exemple classique. Reproduit ici de manière autonome car c'est aussi la forme qu'utilise ArrayBlockingQueue en interne :

class BoundedBuffer<T> {
  private final ReentrantLock lock = new ReentrantLock();
  private final Condition notFull  = lock.newCondition();
  private final Condition notEmpty = lock.newCondition();
  private final Object[] items;
  private int count, head, tail;

  BoundedBuffer(int cap) { items = new Object[cap]; }

  public void put(T x) throws InterruptedException {
    lock.lock();
    try {
      while (count == items.length) notFull.await();          // release lock, park, re-acquire on wake
      items[tail] = x;
      tail = (tail + 1) % items.length;
      count++;
      notEmpty.signal();                                       // wake exactly one consumer
    } finally { lock.unlock(); }
  }

  @SuppressWarnings("unchecked")
  public T take() throws InterruptedException {
    lock.lock();
    try {
      while (count == 0) notEmpty.await();
      T x = (T) items[head];
      items[head] = null;
      head = (head + 1) % items.length;
      count--;
      notFull.signal();                                        // wake exactly one producer
      return x;
    } finally { lock.unlock(); }
  }
}

L'avantage sur wait/notifyAll : un producteur réveille un consommateur, pas tous les threads en attente. Sous forte contention, c'est la différence entre les tempêtes de notifyAll (chaque attente se réveille, rivalise pour le verrou, tous sauf un se rendorment) et un passage de relais propre.

signal vs signalAll suit la même règle que notify vs notifyAll : préférez signalAll sauf si vous pouvez prouver que tous les attentes sur cette condition sont interchangeables. Dans ce tampon, chaque attente sur notEmpty est un consommateur souhaitant un emplacement — ils sont interchangeables ; signal est sûr.

Le compromis boucle CAS / moniteur

Une question courante : quand une variable atomique comme AtomicInteger l'emporte-t-elle sur un ReentrantLock ? En gros :

  • Pour un champ unique avec une mise à jour simple, les atomiques gagnent — ce sont des instructions CAS, sans appel noyau, sans mise en pause. AtomicInteger.incrementAndGet est plus rapide que ReentrantLock.lock + int++ + unlock.
  • Pour plusieurs champs qui doivent être mis à jour ensemble ou pour des sémantiques de blocage (attendre qu'une file soit non vide), le verrou gagne — vous pouvez regrouper le travail et émettre des signaux à travers lui.

Une vérification en lecture seule comme « le cache est-il valide ? » est volatile ; un incrément est atomique ; « échanger un élément contre un autre dans une file » est un verrou. Utilisez l'outil le plus léger que le travail requiert.

tryLock pour une composition sans interblocage

Le modèle le plus simple pour combiner deux verrous sans ordonnancement :

boolean done = false;
while (!done) {
  lockA.lock();
  try {
    if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {           // bounded wait for the second lock
      try {
        doCriticalWork();
        done = true;
      } finally { lockB.unlock(); }
    }
    // else: couldn't get lockB in time — fall through, release lockA, retry
  } finally {
    lockA.unlock();                                           // always release lockA before looping
  }
}

Si tryLock sur lockB expire, le finally libère lockA et la boucle recommence depuis le début. Comme aucun thread ne détient jamais un verrou tout en bloquant indéfiniment sur un autre, la condition classique d'interblocage « détenir et attendre » est rompue.

Cela évite le piège de l'interblocage par ordre d'acquisition sans nécessiter de règle globale d'ordre de verrou. Le compromis est un code plus long, un potentiel d'interblocage actif sous forte contention, et un comportement de cache moins bon. Utilisez-le pour les verrous transversaux (verrous sur des objets que vous n'avez pas écrits) ; préférez un ordre fixe d'acquisition des verrous lorsque vous contrôlez les deux côtés.

Un exemple complet : contention, équité et réentrance

Le programme ci-dessous compare un ReentrantLock équitable vs non-équitable sous forte contention, puis démontre la réentrance et la comptabilisation du nombre de détentions.

java— editable, runs on the server

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

  • Le verrou non-équitable a réparti le pool partagé d'acquisitions de manière inégale — la valeur spread = max-min rapportée était grande (souvent plusieurs milliers sur 50 000 par thread). C'est le chemin rapide en action : la JVM n'impose pas d'ordre, donc un thread qui vient de libérer le verrou peut immédiatement s'intercaler et gagner à nouveau avant qu'un thread en file soit ordonnancé.
  • Le verrou équitable a réparti les acquisitions presque uniformément — son écart était une fraction minime de l'écart non-équitable, car la file d'attente FIFO donne à chaque thread son tour. La durée totale d'horloge murale était notablement plus longue. L'ordonnancement équitable échange le débit contre une progression prévisible. Ne payez pas ce coût à moins d'avoir mesuré un problème de famine. (Les chiffres exacts varient selon l'exécution et la machine ; ce qui est stable, c'est que l'écart non-équitable est bien plus grand que l'écart équitable.)
  • La section réentrance a montré le compteur de détentions augmenter et diminuer avec chaque lock/unlock. Le verrou est réellement libéré uniquement quand le compteur tombe à zéro ; jusqu'alors les autres threads en attente restent BLOCKED. La sémantique est identique à celle de synchronized ; la différence est que ReentrantLock expose le compteur à l'inspection.
  • Le unlock() supplémentaire après que le compteur de détentions a atteint zéro a levé IllegalMonitorStateException immédiatement — il n'y a pas de « double déverrouillage silencieux » qui réussit. C'est la JVM qui fait respecter l'invariant du verrou : seul le détenteur peut le libérer, et seulement autant de fois qu'il l'a acquis.
  • La lecture de getQueueLength à 3 a confirmé que les trois threads en attente étaient véritablement en file derrière nous. En production, cette méthode est utile pour alerter sur « la contention augmente-t-elle ? » — une longueur de file qui croît avec le temps est le signe que le travail à l'intérieur du verrou est trop lent.

Et ensuite

Le chapitre suivant, Java ReadWriteLock, couvre ReentrantReadWriteLock — le verrou qui divise l'acquisition en « plusieurs lecteurs OU un seul écrivain », pour les charges de travail à lecture intensive où les verrous exclusifs sont excessifs.

Pratique

Pratique
Vous appelez `lock.lock()` deux fois depuis le même thread sur un `ReentrantLock`, puis appelez `lock.unlock()` une seule fois. Le verrou est-il libéré pour que d'autres threads puissent l'acquérir ?
Vous appelez `lock.lock()` deux fois depuis le même thread sur un `ReentrantLock`, puis appelez `lock.unlock()` une seule fois. Le verrou est-il libéré pour que d'autres threads puissent l'acquérir ?
Was this page helpful?