Lock est un mécanisme de synchronisation qui fournit un accès exclusif aux sections critiques de code dans les applications multithread. Selon Oracle, 2024, l’interface Lock offre un contrôle de synchronisation plus flexible par rapport aux blocs synchronized traditionnels, incluant des tentatives d’acquisition avec délai d’attente et la prise en charge de plusieurs files d’attente.
Points clés
Lock est une interface du paquet java.util.concurrent.locks qui fournit des opérations explicites de verrouillage et de déverrouillage pour synchroniser l’accès aux données. Contrairement à synchronized, Lock donne au développeur un contrôle total sur le mécanisme de verrouillage.
L’interface Lock a été introduite dans Java 5 comme alternative au mécanisme synchronized intégré. Les principales méthodes sont lock, unlock, tryLock et lockInterruptibly. Les verrous permettent d’organiser un accès sécurisé aux données dans un environnement multithread, évitant les conditions de concurrence et la corruption des données.
Le principal avantage de Lock par rapport à synchronized est la flexibilité. Le développeur peut tenter d’acquérir un verrou avec un délai d’attente, vérifier sa disponibilité sans bloquer ou organiser plusieurs files d’attente avec différentes priorités.
Avant l’apparition de l’interface Lock dans Java 5, la seule méthode de synchronisation était synchronized, qui souffrait de limitations : pas de délais d’attente, pas d’attente interruptible et une file d’attente unique. Doug Lea a conçu le paquet java.util.concurrent, incluant Lock comme bloc fondamental.
Un verrou gère l’accès via un indicateur d’état interne et une file d’attente. Lorsqu’un thread appelle lock(), le mécanisme vérifie si le verrou est libre et soit l’acquiert, soit place le thread dans la file d’attente jusqu’à sa libération.
Au cœur de tout verrou se trouve une opération atomique de comparaison et d’échange (CAS). Lors de l’appel à lock(), le thread tente de définir atomiquement l’indicateur d’occupation. Si l’indicateur est déjà défini, le thread se bloque. Lors de unlock(), l’indicateur est effacé et un thread en attente est réveillé.
import java.util.concurrent.locks.ReentrantLock
val lock = ReentrantLock()
fun performTask() {
lock.lock()
try {
// section critique
println("Le thread ${Thread.currentThread().name} travaille")
} finally {
lock.unlock()
}
}
ReentrantLock utilise en interne une liste doublement chaînée (file d’attente CLH) où chaque thread en attente est représenté par un nœud. Lorsque le verrou est libéré, le nœud de tête de la file est réveillé. Le mode équitable (fair) garantit l’ordre FIFO, tandis que le mode non équitable permet à un nouveau thread d’acquérir le verrou avant ceux qui attendent pour améliorer le débit.
Dans l’écosystème Java moderne, il existe plusieurs implémentations de verrous, chacune optimisée pour des scénarios spécifiques. Le choix du bon verrou impacte directement les performances et la fiabilité d’une application multithread.
ReentrantLock est l’implémentation de base la plus utilisée de Lock. Elle prend en charge la réacquisition par le même thread : si un thread possède déjà le verrou, un nouvel appel à lock() ne le bloque pas. Cela évite les deadlocks dans les appels récursifs.
ReadWriteLock sépare les verrous en deux modes : lecture et écriture. Plusieurs threads peuvent détenir le verrou de lecture simultanément, mais l’écriture nécessite un accès exclusif. Cela améliore considérablement les performances en cas de lectures fréquentes et d’écritures rares.
StampedLock est l’implémentation la plus récente, introduite dans Java 8. Elle prend en charge trois modes : écriture, lecture et lecture optimiste. La lecture optimiste ne bloque pas les autres threads et valide les données après la lecture, offrant un gain de performance de 10 à 20 % par rapport à ReadWriteLock.
| Verrou | Version Java | Modes | Performances |
|---|---|---|---|
| ReentrantLock | Java 5 | exclusif | élevées |
| ReadWriteLock | Java 5 | lecture + écriture | moyennes |
| StampedLock | Java 8 | lecture + écriture + optimiste | très élevées |
ReentrantLock est l’implémentation de Lock la plus populaire, offrant plusieurs fonctionnalités indisponibles dans synchronized. Comprendre ses caractéristiques est essentiel pour un travail efficace avec le multithreading.
Le constructeur de ReentrantLock accepte un paramètre fair. Lorsqu’il est true, le verrou garantit l’ordre FIFO ; lorsqu’il est false, un nouveau thread peut acquérir le verrou avant ceux qui attendent. Le mode équitable évite la famine mais réduit le débit de 10 à 20 % en raison de la surcharge de maintenance de la file d’attente.
Contrairement à synchronized, ReentrantLock prend en charge tryLock avec un délai d’attente. Si le verrou ne peut pas être acquis dans le délai spécifié, le thread continue son exécution au lieu de se bloquer indéfiniment. La méthode lockInterruptibly permet d’interrompre un thread en attente via Thread.interrupt().
val lock = ReentrantLock()
fun tryTask() {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
println("Verrou acquis")
} finally {
lock.unlock()
}
} else {
println("Échec de l’acquisition du verrou")
}
}
ReentrantLock prend en charge plusieurs variables de condition via la méthode newCondition(). Chaque Condition possède sa propre file d’attente, permettant des scénarios de réveil complexes. Les méthodes await() et signal() ont remplacé wait() et notify() des blocs synchronized, mais avec la prise en charge de plusieurs files d’attente.
ReadWriteLock et StampedLock abordent l’optimisation de l’accès lorsque les lectures prédominent sur les écritures. Ils sont considérablement plus efficaces que ReentrantLock dans les scénarios où la lecture est plus fréquente que l’écriture.
L’interface ReadWriteLock contient deux méthodes : readLock() et writeLock(). Le verrou de lecture peut être détenu par plusieurs threads simultanément, tandis que le verrou d’écriture est exclusif. Un exemple typique est un cache thread-safe : de nombreux threads lisent des données tandis qu’un seul les met à jour périodiquement.
class SafeCache<K, V> {
private val map = mutableMapOf<K, V>()
private val rwLock = ReentrantReadWriteLock()
fun get(key: K): V? {
rwLock.readLock().lock()
return try { map[key] } finally { rwLock.readLock().unlock() }
}
fun put(key: K, value: V) {
rwLock.writeLock().lock()
return try { map[key] = value } finally { rwLock.writeLock().unlock() }
}
}
StampedLock ajoute un troisième mode — tryOptimisticRead. Ce mode ne bloque pas les autres threads, mais enregistre simplement un tampon (stamp) d’état. Après la lecture, le développeur appelle validate(stamp) pour vérifier si les données ont changé pendant la lecture. Si les données ont changé, l’opération doit être répétée.
Dans les applications mobiles, les verrous sont utilisés pour coordonner l’accès aux données partagées entre les threads. Cependant, leur utilisation nécessite une prudence particulière en raison des ressources limitées de l’appareil et de la nécessité de maintenir la réactivité de l’interface.
Sur Android, ReentrantLock est utile lorsqu’on travaille avec Room, les caches et les fichiers. Il est important de se rappeler : n’acquérez jamais un verrou sur le thread principal. Pour le code asynchrone, les coroutines et Mutex de kotlinx.coroutines sont préférables, car ils suspendent la coroutine au lieu de bloquer le thread.
Sous iOS, le NSLock standard est moins utilisé — les développeurs préfèrent DispatchQueue avec des indicateurs barrier ou os_unfair_lock. Swift 5.7+ fournit des mécanismes de synchronisation modernes via les acteurs, qui protègent automatiquement l’état.
import Foundation
actor DataStore {
private var items: [String] = []
func add(_ item: String) {
items.append(item)
}
func getAll() -> [String] {
items
}
}
Pour éviter les deadlocks, suivez un ordre d’acquisition cohérent dans l’ensemble du projet. Utilisez tryLock avec un délai d’attente au lieu de lock() partout où un blocage prolongé est possible. Envisagez d’utiliser des algorithmes Lock-Free (AtomicReference, ConcurrentHashMap) au lieu des verrous traditionnels.
L’utilisation de Lock nécessite de la discipline et le respect de plusieurs règles qui préviennent les deadlocks et la dégradation des performances. Ces pratiques ont été développées par la communauté Java au cours de 20 ans d’utilisation du paquet java.util.concurrent.
Le modèle le plus important est lock dans finally. Que la section critique se termine avec succès ou lève une exception, le verrou doit être libéré. Cela garantit que les autres threads ne sont pas bloqués indéfiniment à cause d’une seule erreur. En Kotlin, ce modèle est élégamment résolu grâce à l’extension withLock.
La section critique doit être aussi courte que possible. N’effectuez jamais d’E/S, de requêtes réseau ou de calculs longs à l’intérieur d’un verrou. Si vous devez lire des données depuis un serveur, récupérez-les d’abord, puis acquérez le verrou uniquement pour mettre à jour l’état partagé. Cela réduit la contention et améliore le débit du système.
Pour prévenir les deadlocks lors du travail avec plusieurs verrous, établissez un ordre d’acquisition global dans l’ensemble du projet. Si lockA est acquis en premier, puis lockB — toute séquence inverse doit être interdite par les règles de révision de code. Utilisez des analyseurs statiques tels que SpotBugs et IntelliJ Inspections pour une vérification automatique.
Foire aux questions
Lock est une interface explicite avec prise en charge du délai d’attente et de l’attente interruptible. synchronized acquiert et libère automatiquement le moniteur, mais ne permet pas d’utiliser tryLock, lockInterruptibly ou plusieurs Conditions. Lock est plus flexible mais nécessite une libération manuelle dans finally.
Un verrou équitable garantit l’ordre FIFO : le thread qui a attendu le plus longtemps reçoit le verrou en premier. Un verrou non équitable peut accorder l’accès à un nouveau thread avant ceux qui attendent, ce qui augmente le débit mais peut provoquer la famine des threads en attente.
Suivez un ordre fixe pour acquérir tous les verrous, utilisez tryLock avec un délai d’attente au lieu de lock() inconditionnel, et minimisez le nombre de verrous détenus simultanément. L’utilisation de structures de données Lock-Free réduit également le risque de deadlock.
Condition est l’analogue de wait/notify pour Lock, permettant plusieurs files d’attente indépendantes. Chaque appel à newCondition() crée une file d’attente séparée, offrant un contrôle plus précis sur le réveil des threads par rapport à la file d’attente unique de synchronized.
Pour Android avec des coroutines, utilisez Mutex de kotlinx.coroutines — il suspend la coroutine au lieu de bloquer le thread. Pour iOS avec Swift 5.7+, les acteurs sont préférables car ils synchronisent automatiquement l’accès à l’état. Réservez ReentrantLock pour le code legacy et les scénarios de bas niveau.
Résumé
Nous développerons une application mobile clé en main
IT Sectr crée des applications iOS et Android pour les startups et les entreprises depuis 2017. Nous vous conseillerons et vous proposerons la meilleure solution.
Lisez aussi