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 desynchronized.lockInterruptibly()— bloquer jusqu'à l'acquisition, mais abandonner avecInterruptedExceptionen cas d'interruption. Permet d'annuler un thread qui attend un verrou.tryLock()— essayer une fois, retournertrue/falseimmé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 + Condition | Moniteur 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
synchronizedpar défaut pour la simple exclusion mutuelle. C'est automatique, ne peut pas fuir, et la JVM l'optimise fortement. - Passez à
Locklorsque 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 viainterrupt, plusieursConditions sur le même verrou, ou une distinction lecture-écriture (ReentrantReadWriteLock). - Passez à
Lockquand 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
finallyet 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 ; avecLock, vous pouvez appelerunlock()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,synchronizedpeut être légèrement plus rapide. - Plus de surface pour les mauvaises utilisations.
tryLocketlockInterruptiblydoivent 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.
Ce qu'il faut retenir de l'exécution :
- Le modèle
try/finallyde la section 1 est ce que chaque site d'appelLockdoit avoir. Il n'y a pas de protection syntaxique — si vous supprimez lefinally, 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éfalseaprè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 retournefalseaprès le délai d'expiration quoi qu'il arrive. Avecsynchronized, 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
lockInterruptiblya lancéInterruptedException. Comparez aveclock.lock()ousynchronized— 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 —notFullpour les producteurs,notEmptypour les consommateurs. Quand le producteur a ajouté un élément, il asignalénotEmptyspécifiquement ; seul un consommateur a été réveillé. Avecwait/notifyAllsur un moniteur intrinsèque, chaque thread en attente est réveillé et revérifie ; la paireConditionenvoie le réveil au bon côté de la file et économise le cycle réveil/revérification. - Le
signal()(singulier) plutôt quesignalAll()est sûr ici parce que tous lesawaiteurs 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),signalAllserait 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.