Java ReadWriteLock
Autorisez plusieurs lecteurs simultanés et des écrivains exclusifs en Java avec ReadWriteLock — et quand StampedLock est un meilleur choix.
Un ReentrantLock (ou un bloc synchronized) accorde à un seul thread un accès exclusif — lecteurs et écrivains partagent le même verrou. Pour les charges de travail à lecture intensive, c'est peu efficace : si cent threads veulent lire une valeur et un seul veut l'écrire, il n'y a pas de vrai conflit entre les lecteurs, seulement entre lecteurs et écrivain. L'interface ReadWriteLock et son implémentation standard ReentrantReadWriteLock divisent le verrou en deux parties — un verrou de lecture que plusieurs threads peuvent détenir simultanément, et un verrou d'écriture exclusif vis-à-vis de tout le reste. Utilisé correctement, il réduit considérablement la contention. Utilisé incorrectement, il est plus lent qu'un verrou simple.
L'interface
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}Deux Locks, solidairement liés par leur parent : le verrou de lecture et le verrou d'écriture obéissent à la règle selon laquelle soit le verrou d'écriture est détenu par exactement un thread soit le verrou de lecture est détenu par zéro ou plusieurs threads. Jamais les deux à la fois, jamais un de chaque.
L'implémentation standard :
ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
Lock r = rw.readLock();
Lock w = rw.writeLock();Les deux verrous disposent de l'API Lock standard — lock, tryLock, lockInterruptibly, unlock. Le contrat pour la paire rw est :
- Plusieurs threads peuvent détenir
rsimultanément. Aucun ne bloque l'autre. - Un seul thread peut détenir
wà la fois. - Un thread qui tente d'acquérir
wattend que tous les lecteurs actuels relâchent le verrou. - Les threads tentant d'acquérir
rattendent siwest détenu ou (selon la politique) si un écrivain est déjà en file d'attente.
Le dernier point correspond à la politique de prévention de la famine des écrivains — abordée ci-dessous.
Le cas d'utilisation du cache
L'exemple classique. Un cache lu en permanence et rafraîchi occasionnellement :
class ConfigCache {
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
private final Lock r = rw.readLock();
private final Lock w = rw.writeLock();
private Map<String, String> data = new HashMap<>();
public String get(String k) {
r.lock();
try {
return data.get(k); // many readers, no contention
} finally { r.unlock(); }
}
public void reload(Map<String, String> fresh) {
w.lock();
try {
data = new HashMap<>(fresh); // exclusive: blocks readers and other writers
} finally { w.unlock(); }
}
}Sous une charge de travail typique à 99 % de lectures, ce code passe à l'échelle bien mieux qu'un unique ReentrantLock. Les lecteurs ne se bloquent pas mutuellement ; le rare écrivain interrompt brièvement tout le monde, puis chacun continue.
La même discipline try/finally qu'avec Lock s'applique — chaque lock() doit être associé à un unlock() dans un bloc finally. Le verrou de lecture n'est pas plus indulgent que le verrou d'écriture concernant les fuites.
Équité et famine des écrivains
ReentrantReadWriteLock propose deux politiques :
new ReentrantReadWriteLock(); // non-fair (default)
new ReentrantReadWriteLock(true); // fair (FIFO)La politique non-équitable par défaut laisse les nouveaux lecteurs acquérir le verrou même si un écrivain attend déjà — débit élevé, mais les écrivains peuvent mourir de faim sous une charge de lecture continue. La politique équitable met chaque requérant en file d'attente FIFO : un écrivain en attente bloque les lecteurs suivants, et ces lecteurs attendent leur tour.
La valeur par défaut reste la politique non-équitable. Si vous observez en production des écrivains bloqués en file indéfiniment (l'une des choses qu'expose getQueueLength), passez à la politique équitable.
Il existe également une protection plus subtile. Même en mode non-équitable, si un écrivain est « le suivant en ligne » (en tête de file), les nouveaux lecteurs sont bloqués. Cela prévient la forme la plus grave de famine ; les nouveaux lecteurs peuvent encore passer devant si aucun écrivain n'est en file.
Déclassement de verrou : écriture → lecture
Une astuce utile : vous pouvez détenir le verrou d'écriture, acquérir également le verrou de lecture, puis relâcher le verrou d'écriture — sans jamais laisser entrer un autre écrivain. C'est ce qu'on appelle le déclassement :
w.lock();
try {
data = recompute(); // exclusive write
r.lock(); // before releasing w
} finally { w.unlock(); }
// now holding only r — readers can join, but no writer can sneak in until I release r
try {
process(data); // read-only work, multiple threads can do it
} finally { r.unlock(); }L'intérêt du déclassement : effectuer la mutation réelle sous le verrou d'écriture, puis continuer à lire le résultat sans bloquer les autres. L'étape intermédiaire « acquérir la lecture tout en détenant l'écriture » fonctionne parce que le verrou l'autorise — vous faites passer la réservation du verrou d'écriture de « exclusif » à « partagé avec vous spécifiquement toujours autorisé ».
L'inverse — promotion lecture → écriture — ne fonctionne pas. Tenter d'acquérir w pendant que vous détenez r crée un interblocage : le verrou d'écriture attend que tous les lecteurs relâchent, et vous en êtes un. Le verrou vous bloquera indéfiniment en attendant vous-même.
r.lock();
try {
if (needsRefresh()) {
w.lock(); // DEADLOCK on the same thread
...
}
} finally { r.unlock(); }Pour passer de lecture → écriture, vous devez d'abord relâcher le verrou de lecture, puis acquérir le verrou d'écriture et vérifier à nouveau la condition (quelqu'un d'autre a peut-être rafraîchi pendant que vous étiez déverrouillé).
Quand ReadWriteLock surpasse ReentrantLock
Une règle empirique approximative. ReentrantReadWriteLock gagne quand :
- Les lectures dépassent largement les écritures (disons, 100:1 ou plus).
- La section protégée en lecture est non triviale — assez longue pour que laisser plusieurs threads l'exécuter simultanément soit un gain significatif.
- L'écriture est également assez longue pour que bloquer brièvement les lecteurs soit acceptable.
Il perd (ou fait jeu égal) quand :
- Les lectures sont extrêmement courtes (une seule recherche dans une map). La surcharge d'acquisition du verrou est comparable au travail effectué ; vous seriez mieux avec un
ReentrantLocksimple ou un instantané immuable viaAtomicReference. - Le ratio lecteurs/écrivains n'est pas extrême.
- Vous avez de nombreux threads. La comptabilité interne effectuée par le verrou lecture/écriture pour compter les lecteurs devient plus coûteuse à l'échelle. Pour les structures de données à lecture très intensive sur de nombreux cœurs,
StampedLockou la copie à l'écriture est généralement un meilleur choix.
StampedLock — l'alternative moderne
Java 8 a ajouté java.util.concurrent.locks.StampedLock avec trois modes — écriture, lecture et lecture optimiste. Le mode optimiste permet à un lecteur de continuer sans acquérir de verrou ; après la lecture, il vérifie que la valeur n'a pas changé via un tampon. Si c'est le cas, le lecteur revient à une acquisition de verrou de lecture classique.
StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();
String val = data.get(k); // read without locking
if (!sl.validate(stamp)) { // somebody wrote during our read
stamp = sl.readLock();
try {
val = data.get(k); // re-read under proper lock
} finally { sl.unlockRead(stamp); }
}Pour les charges de travail dominées par les lectures, StampedLock est généralement plus rapide que ReentrantReadWriteLock. Le coût : il n'est pas réentrant, ne prend pas en charge Condition, et l'API est bien plus facile à mal utiliser. Ayez-y recours quand un profileur vous signale un goulot d'étranglement sur un ReadWriteLock ; utilisez ReentrantReadWriteLock par défaut pour sa facilité d'emploi.
Un exemple pratique : cache à lecture intensive, trois approches
Le programme ci-dessous compare trois implémentations du même cache à lecture intensive sous 16 lecteurs et 2 écrivains : une map synchronisée, une map protégée par ReentrantLock, et une map protégée par ReentrantReadWriteLock.
Ce que l'on retient de l'exécution — et le résultat n'est probablement pas celui que vous attendez :
- La section critique ici est un simple
HashMap.get— une poignée de nanosecondes. À cette taille,ReentrantReadWriteLockperd en fait face à unReentrantLocksimple (et face àsynchronized). Sur une exécution typique, le rwlock effectue moins de lectures, pas plus, car le travail à l'intérieur du verrou est écrasé par le coût de son acquisition. C'est la chose la plus importante à retenir : un verrou lecture-écriture n'est pas une mise à niveau gratuite. - Pourquoi il perd ici :
r.lock()doit incrémenter de manière atomique un compteur de lecteurs partagé, et ce compteur en contention devient le nouveau goulot d'étranglement. Seize cœurs martelant tous un même champ de styleAtomicIntegerproduisent autant de rebondissements de cache que sur un mutex unique — parfois pire, car la comptabilité du rwlock est plus lourde que celle d'un verrou simple. Le gain théorique « les lecteurs ne se bloquent pas mutuellement » ne se matérialise jamais quand la lecture est trop courte pour se chevaucher significativement. - Le rwlock ne prend l'avantage que quand chaque lecture détient le verrou assez longtemps pour qu'un vrai chevauchement soit rentable — pensez à une recherche, une désérialisation, ou tout ce qui prend de la microseconde en plus, pas une simple recherche dans une map. Remplacez le corps de
get()par un travail en lecture seule vraiment coûteux et relancez : maintenant les lecteurs concurrents gagnent nettement. Profilez votre charge de travail réelle avant d'utiliser unReadWriteLock. - Le coût de
ReadWriteLockest davantage d'état à maintenir (un compteur de lecteurs, un indicateur d'attente d'écrivain, la politique d'équité) — chaquer.lock()est plus coûteux qu'unReentrantLock.lock(). Pour une faible contention ou des sections critiques très courtes, le verrou plus simple est plus rapide. Pour une charge extrêmement orientée lecture sur de nombreux cœurs,StampedLock(les lectures optimistes n'acquièrent rien) ou un instantané immuable derrière unAtomicReferencesurpasse généralement les deux. - Quel que soit le verrou choisi, les écrivains obtiennent toujours un accès exclusif via le chemin d'écriture et ne corrompent jamais la map — l'exactitude est identique pour les trois. La différence est purement en termes de débit, et le débit dépend entièrement de ce qui se trouve à l'intérieur du verrou.
- Le déclassement (
w→r→ relâcherw) est ce qu'utilise le code de production quand la reconstruction doit se faire sous un verrou d'écriture mais que le reste de la requête peut rester sous un verrou de lecture. La promotion dans l'autre sens provoque un interblocage ; relâchez d'abordr, prenezw, vérifiez à nouveau l'état, puis continuez.
La suite
Le prochain chapitre, Java Thread Pools, introduit le framework d'exécuteurs de haut niveau — l'idée que vous cessez de créer des threads manuellement et soumettez à la place du travail à un pool qui gère les threads pour vous.