1. ReentrantLock : un verrou flexible
Le mot-clé synchronized convient très bien aux cas de base : on peut protéger rapidement et simplement une méthode ou un bloc de code. Mais parfois on veut davantage :
- Contrôler explicitement le verrou (par exemple, tenter de l’acquérir et, en cas d’échec, ne pas attendre).
- Séparer les droits « lecture » et « écriture » sur une ressource.
- Interrompre l’attente d’un verrou.
- Diagnostiquer qui a acquis ou libéré le verrou et quand.
C’est pour ces besoins que les classes ReentrantLock et ReadWriteLock ont été conçues. Elles offrent plus de contrôle et de possibilités que le bon vieux synchronized.
De quoi s’agit-il ?
ReentrantLock est une classe qui implémente l’interface Lock. Elle fonctionne à peu près comme synchronized, mais avec des fonctionnalités supplémentaires. La différence principale : la gestion du verrou devient explicite — vous appelez vous‑même lock() et unlock().
Point intéressant : le mot reentrant signifie qu’un thread peut acquérir le même verrou plusieurs fois de suite sans provoquer d’interblocage. C’est utile si une méthode s’appelle récursivement ou si l’on travaille avec un verrou partagé dans une chaîne d’appels.
Syntaxe d’utilisation
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int value = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // On acquiert le verrou
try {
value++;
} finally {
lock.unlock(); // Toujours libérer le verrou !
}
}
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
À noter :
Les appels à lock() et unlock() doivent toujours être associés à un bloc try...finally. Si vous oubliez d’appeler unlock(), aucun autre thread ne pourra entrer dans la section protégée — vous obtiendrez un blocage permanent.
Fonctionnalités de ReentrantLock
Tentative d’acquisition :
On peut essayer d’acquérir le verrou sans attendre indéfiniment :
if (lock.tryLock()) {
try {
// Traitement
} finally {
lock.unlock();
}
} else {
// Échec d’acquisition — faire autre chose
}
Attente avec délai (timeout) :
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
// Acquis en 100 ms
}
Vérifier si le verrou est détenu :
if (lock.isLocked()) { ... }
Diagnostic de la file d’attente, « équité » du verrou et autres petits plus.
3. Exemple : incrément d’un compteur avec ReentrantLock
Faisons évoluer notre application console (par exemple, en simulant le traitement de commandes depuis différents threads). Comparons le travail avec synchronized et avec ReentrantLock.
Exemple avec synchronized
public class OrderCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Exemple équivalent avec ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class OrderCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Quels avantages ?
- On peut tenter d’acquérir le verrou sans attendre indéfiniment (tryLock()).
- On peut implémenter une logique complexe : par exemple, acquérir plusieurs verrous dans un ordre précis (utile pour des structures de données complexes).
- On peut « déverrouiller » depuis un autre endroit (mais à manier avec beaucoup de prudence — toujours penser à unlock() !).
4. ReadWriteLock : verrou pour lecture et écriture
Qu’est-ce que c’est ?
ReadWriteLock n’est pas juste un verrou, mais un répartiteur d’accès intelligent. Son implémentation principale est ReentrantReadWriteLock, qui sépare les verrous en deux catégories : lecture et écriture.
Lorsque les threads ne font que lire et que personne ne modifie les données, ils peuvent travailler en parallèle — la lecture ne gêne pas la lecture. Mais dès que quelqu’un veut modifier, tous les autres doivent attendre : l’écriture n’autorise qu’un seul participant et requiert le silence.
Cette approche est particulièrement utile là où il y a beaucoup de lectures et peu de modifications — par exemple, dans un catalogue de produits que les utilisateurs consultent en permanence mais ne mettent à jour qu’occasionnellement.
Syntaxe d’utilisation
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ProductCatalog {
private final Map<String, String> products = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public void addProduct(String id, String name) {
rwLock.writeLock().lock();
try {
products.put(id, name);
} finally {
rwLock.writeLock().unlock();
}
}
public String getProduct(String id) {
rwLock.readLock().lock();
try {
return products.get(id);
} finally {
rwLock.readLock().unlock();
}
}
}
Exemple d’utilisation dans notre application
Supposons que nous ayons une base de commandes que tous les threads lisent (par exemple, pour rechercher une commande), mais que de nouvelles commandes arrivent de temps en temps (opération d’écriture).
import java.util.*;
import java.util.concurrent.locks.*;
public class OrderDatabase {
private final List<String> orders = new ArrayList<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Ajout d’une commande (nécessite writeLock)
public void addOrder(String order) {
rwLock.writeLock().lock();
try {
orders.add(order);
} finally {
rwLock.writeLock().unlock();
}
}
// Récupérer une copie de toutes les commandes (lecture en parallèle possible)
public List<String> getOrders() {
rwLock.readLock().lock();
try {
// Retourner une copie pour éviter les courses
return new ArrayList<>(orders);
} finally {
rwLock.readLock().unlock();
}
}
}
Que se passe-t-il ?
- Tant que personne n’écrit, des milliers de threads peuvent lire la liste des commandes simultanément.
- Dès qu’un thread commence à ajouter une commande, les lectures sont bloquées afin d’éviter des données incohérentes.
5. Comparaison : quand utiliser quoi ?
| Scénario | synchronized | ReentrantLock | ReadWriteLock |
|---|---|---|---|
| Synchronisation simple | ✔ | ✔ | ✖ (superflu) |
| Besoin d’un délai d’attente/tryLock | ✖ | ✔ | ✔ |
| Beaucoup de lectures, peu d’écritures | ✖ | ✖ | ✔ (gain significatif) |
| Diagnostic/statistiques nécessaires | ✖ | ✔ | ✔ |
| Verrouillage récursif | ✔ | ✔ (réentrance) | ✔ |
Conclusion :
- Pour les cas simples — utilisez synchronized.
- Pour plus de flexibilité — ReentrantLock.
- Pour les scénarios « lecture fréquente, écriture rare » — ReadWriteLock.
6. Visualisation : schéma de fonctionnement de ReadWriteLock
flowchart LR
subgraph Lecture
T1[Thread 1] -- Lecture --> Orders
T2[Thread 2] -- Lecture --> Orders
T3[Thread 3] -- Lecture --> Orders
end
subgraph Écriture
T4[Thread 4] -- Écriture (addOrder) --> Orders
end
Orders[Liste des commandes]
style Orders fill:#f9f,stroke:#333,stroke-width:2px
Tant qu’aucun thread n’écrit, tous peuvent lire en parallèle. Dès qu’une écriture apparaît, les autres threads attendent la libération de writeLock.
7. Particularités d’implémentation et nuances
« Équité » (fairness)
Dans ReentrantLock et ReentrantReadWriteLock, on peut activer un mode « équitable » (fair mode) : les threads sont servis dans l’ordre de la file, et non pas au plus rapide. Cela évite l’« affamement » de certains threads, mais peut réduire les performances.
Lock fairLock = new ReentrantLock(true); // true — mode équitable
ReadWriteLock fairRWLock = new ReentrantReadWriteLock(true);
Pièges potentiels
- unlock oublié : si unlock() n’est pas appelé, vous obtenez un blocage permanent. Utilisez toujours try...finally.
- Exceptions à l’intérieur de la section protégée : même si une exception survient dans le bloc, le verrou doit être libéré !
- Utilisation excessive de ReadWriteLock : pour de petites collections, ou si l’on écrit presque tout le temps, ReadWriteLock a peu d’intérêt et complexifie le code.
8. Erreurs typiques
Erreur n° 1 : oubli d’appeler unlock()
L’erreur la plus fréquente et la plus insidieuse consiste à oublier d’appeler unlock() après l’acquisition du verrou. Résultat : blocage permanent, threads bloqués. Utilisez toujours try...finally, même si « rien ne peut casser ici ».
Erreur n° 2 : utiliser ReadWriteLock là où ce n’est pas nécessaire
S’il y a très peu de lectures parallèles et que l’écriture est fréquente, ReadWriteLock ne fera que compliquer le code et réduire les performances. Utilisez-le seulement lorsque vous avez réellement beaucoup de lecteurs simultanés.
Erreur n° 3 : acquisition de plusieurs verrous dans des ordres différents
Si votre code acquiert plusieurs Lock (par exemple, pour plusieurs objets), faites-le toujours dans le même ordre dans tous les threads. Sinon, vous risquez un deadlock — les threads se bloqueront mutuellement indéfiniment.
Erreur n° 4 : tenter de remplacer synchronized par ReentrantLock « juste comme ça »
Il ne faut pas remplacer aveuglément tous les synchronized par des Lock — cela n’accélère pas toujours le programme et peut rendre le code moins lisible.
Erreur n° 5 : oubli de la réentrance
Si un même thread appelle lock() plusieurs fois de suite, c’est normal avec ReentrantLock, mais n’oubliez pas que unlock() doit être appelé autant de fois !
GO TO FULL VERSION