Limites du mot-clé synchronized
Avant Java 5, le mot-clé synchronized était le mécanisme principle pour garantir l'accès synchronisé aux ressources partagées. Bien qu'il soit intégré au langage et facile à utiliser, il présente plusieurs limitations majeures dans les scénarios concurrents complexes :
- Manque de flexibilité en cas de blocage : Si un thread acquiert un verrou
synchronizedpuis se bloque (par exemple, en attendant une opération d'E/S ou viaThread.sleep()), il ne libère pas le verrou. Les autres threads restent alors bloqués indéfiniment, ce qui dégrade fortement les performances. - Impossibilité de séparer les lectures et les écritures : Avec
synchronized, l'accès est exclusivement mutuel. Si plusieurs threads souhaitent uniquement lire une ressource, ils doivent tout de même attendre leur tour, alors que des lectures simultanées ne posent aucun problème de cohérence. - Absence de retour sur l'acquisition : Il est impossible de savoir si un thread a réussi à acquérir le verrou sans bloquer, ou de définir un délai d'attente maximal.
Pour pallier ces défauts, le paquetage java.util.concurrent.locks a été introduit, offrant une approche orientée objet beaucoup plus riche et flexible.
L'interface Lock et ses méthodes fondamentales
L'interface Lock définit le contrat pour les verrous explicites. Contrairement à synchronized, l'acquisition et la libération du verrou doivent être gérées manuellement, ce qui impose une rigueur particulière pour éviter les interblocages (deadlocks).
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
Acquisition standard et libération sécurisée
La méthode lock() bloque le thread jusqu'à ce que le verrou soit disponible. Puisque la libération n'est pas automatique en cas d'exception, il est impératif d'utiliser un bloc finally.
Lock mutex = new ReentrantLock();
mutex.lock();
try {
// Traitement de la section critique
} finally {
mutex.unlock(); // Garantie de libération
}
Tentative d'acquisition sans blocage
La méthode tryLock() tente d'acquérir le verrou et retourne immédiatement un booléen. C'est idéal pour éviter l'attente passive et exécuter des logiques alternatives.
if (mutex.tryLock()) {
try {
// Traitement sécurisé
} finally {
mutex.unlock();
}
} else {
// Exécuter une logique alternative sans attendre
}
Réponse aux interruptions
Contrairement à synchronized, lockInterruptibly() permet à un thread en attente de verrou de répondre à une interruption (Thread.interrupt()), levant ainsi une InterruptedException et permettant au thread de se terminer proprement ou de changer de tâche.
Mise en pratique avec ReentrantLock
ReentrantLock est l'implémentation la plus courante de l'interface Lock. Voici un exemple illustrant la gestion d'un inventaire partagé où l'on tente de réserver un article sans bloquer indéfiniment le thread appelant.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class InventoryManager {
private int availableItems = 10;
private final Lock stockLock = new ReentrantLock();
public boolean reserveItem(String clientId) {
boolean acquired = false;
try {
// Tentative d'acquisition avec un délai maximal de 2 secondes
acquired = stockLock.tryLock(2, java.util.concurrent.TimeUnit.SECONDS);
if (acquired) {
if (availableItems > 0) {
availableItems--;
System.out.println(clientId + " a réservé un article. Stock restant: " + availableItems);
return true;
}
} else {
System.out.println(clientId + " n'a pas pu obtenir le verrou à temps.");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.out.println(clientId + " a été interrompu pendant l'attente.");
} finally {
if (acquired) {
stockLock.unlock();
}
}
return false;
}
}
Optimisation des accès avec ReadWriteLock
L'interface ReadWriteLock introduit une paire de verrous : un pour la lecture et un pour l'écriture. Cela permet à plusieurs threads de lire simultanément, tout en garantissant l'exclusivité lors des écritures. ReentrantReadWriteLock en est l'implémentation standard.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.HashMap;
import java.util.Map;
public class ConfigurationCache {
private final Map<String, String> configData = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public String getConfig(String key) {
rwLock.readLock().lock();
try {
// Simule un temps de lecture
Thread.sleep(10);
return configData.get(key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
rwLock.readLock().unlock();
}
}
public void updateConfig(String key, String value) {
rwLock.writeLock().lock();
try {
// Simule un temps d'écriture plus long
Thread.sleep(50);
configData.put(key, value);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
rwLock.writeLock().unlock();
}
}
}
Dans ce modèle, les appels à getConfig par plusieurs threads s'exécutent en parallèle, augmentant drastiquement le débit de l'application par rapport à un verrou exclusif classique qui sérialiserait toutes les opérations.
Concepts fondamentaux des verrous
Verrouillages réentrants (Reentrant Locks)
Un verrou est dit réentrant s'il peut être acquis plusieurs fois par le même thread sans provoquer d'interblocage. Le compteur d'acquisition est incrémenté à chaque verrouillage et décrémenté à chaque libération. synchronized et ReentrantLock sont tous deux réentrants. Cela est crucial lorsqu'une méthode synchronisée en appelle une autre sur le même objet, évitant ainsi qu'un thread ne se bloque lui-même.
Verrous équitables (Fair Locks) vs non équitables
Un verrou équitable garantit que les threads acquièrent le verrou dans l'ordre exact de leurs requêtes (FIFO), évitant ainsi la famine (starvation). Un verrou non équitable permet à un thread arrivant tardivement de "doubler" la file d'attente si le verrou vient d'être libéré, ce qui améliore le débit global au détriment de l'équité stricte. ReentrantLock permet de choisir ce comportement via son constructeur :
// Verrou équitable
Lock fairLock = new ReentrantLock(true);
// Verrou non équitable (par défaut, plus performant)
Lock unfairLock = new ReentrantLock(false);
Verrous interruptibles
La capacité d'un thread à abandonner l'attente d'un verrou s'il reçoit un signal d'interruption est une caractéristique des verrous interruptibles. L'API Lock supporte cela nativement via lockInterruptibly(), offrant un contrôle bien supérieur à l'attente aveugle et infinie imposée par les monieturs natifs de Java.
Choix entre Lock et synchronized
Le choix dépend entièrement du contexte d'exécution. Pour une synchronisation simple et basique, synchronized reste pertinent car il est géré directement par la JVM et moins sujet aux erreurs humaines (comme l'oubli de la clause unlock()). Cependant, dès que l'application nécessite des délais d'attente, des tentatives non bloquantes, une séparation lecture/écriture ou une équité stricte, l'API Lock devient indispensable. De plus, sous une forte concurrence, les implémentations de Lock offrent généralement de meilleures performances grâce à des algorithmes de gestion de file d'attente plus optimisés et moins de overhead au niveau du système d'exploitation.