Lock è un meccanismo di sincronizzazione che fornisce accesso esclusivo a sezioni critiche di codice nelle applicazioni multithread. Secondo Oracle, 2024, l’interfaccia Lock offre un controllo di sincronizzazione più flessibile rispetto ai tradizionali blocchi synchronized, inclusi tentativi di acquisizione con timeout e supporto per più code di attesa.
Punti Chiave
Lock è un’interfaccia del pacchetto java.util.concurrent.locks che fornisce operazioni esplicite di blocco e sblocco per sincronizzare l’accesso ai dati. A differenza di synchronized, Lock dà allo sviluppatore il controllo completo sul meccanismo di blocco.
L’interfaccia Lock è stata introdotta in Java 5 come alternativa al meccanismo synchronized integrato. I metodi principali sono lock, unlock, tryLock e lockInterruptibly. I blocchi aiutano a organizzare l’accesso sicuro ai dati in un ambiente multithread, prevenendo condizioni di competizione e corruzione dei dati.
Il principale vantaggio di Lock rispetto a synchronized è la flessibilità. Lo sviluppatore può tentare di acquisire un blocco con timeout, verificarne la disponibilità senza bloccarsi o organizzare più code di attesa con diverse priorità.
Prima dell’introduzione dell’interfaccia Lock in Java 5, l’unico metodo di sincronizzazione era synchronized, che soffriva di limitazioni: nessun timeout, nessuna attesa interruptibile e una coda singola. Doug Lea ha progettato il pacchetto java.util.concurrent, includendo Lock come blocco fondamentale.
Un blocco gestisce l’accesso attraverso un flag di stato interno e una coda di attesa. Quando un thread chiama lock(), il meccanismo verifica se il blocco è libero e lo acquisisce o mette il thread in coda fino al rilascio.
Al centro di qualsiasi blocco c’è un’operazione atomica di confronto e scambio (CAS). Quando viene chiamato lock(), il thread tenta di impostare atomicamente il flag di occupato. Se il flag è già impostato, il thread si blocca. Con unlock(), il flag viene cancellato e un thread in attesa viene risvegliato.
import java.util.concurrent.locks.ReentrantLock
val lock = ReentrantLock()
fun performTask() {
lock.lock()
try {
// sezione critica
println("Il thread ${Thread.currentThread().name} sta lavorando")
} finally {
lock.unlock()
}
}
ReentrantLock utilizza internamente una lista doppiamente collegata (coda di blocco CLH) in cui ogni thread in attesa è rappresentato da un nodo. Quando il blocco viene rilasciato, il nodo di testa della coda viene risvegliato. La modalità equa (fair) garantisce l’ordine FIFO, mentre la modalità non equa consente a un nuovo thread di acquisire il blocco prima di quelli in attesa per migliorare il throughput.
Nell’ecosistema Java moderno, esistono diverse implementazioni di blocchi, ciascuna ottimizzata per scenari specifici. Scegliere il blocco giusto influisce direttamente sulle prestazioni e l’affidabilità di un’applicazione multithread.
ReentrantLock è l’implementazione di base più utilizzata di Lock. Supporta la riacquisizione dallo stesso thread: se un thread possiede già il blocco, chiamare lock() di nuovo non lo blocca. Questo previene deadlock nelle chiamate ricorsive.
ReadWriteLock separa i blocchi in due modalità: lettura e scrittura. Più thread possono mantenere il blocco di lettura contemporaneamente, ma la scrittura richiede accesso esclusivo. Ciò migliora significativamente le prestazioni in caso di letture frequenti e scritture rare.
StampedLock è l’implementazione più recente, introdotta in Java 8. Supporta tre modalità: scrittura, lettura e lettura ottimistica. La lettura ottimistica non blocca altri thread e valida i dati dopo la lettura, offrendo un miglioramento delle prestazioni del 10-20% rispetto a ReadWriteLock.
| Blocco | Versione Java | Modalità | Prestazioni |
|---|---|---|---|
| ReentrantLock | Java 5 | esclusivo | alte |
| ReadWriteLock | Java 5 | lettura + scrittura | medie |
| StampedLock | Java 8 | lettura + scrittura + ottimistica | molto alte |
ReentrantLock è l’implementazione di Lock più popolare, offrendo diverse funzionalità non disponibili in synchronized. Comprendere le sue caratteristiche è essenziale per un lavoro efficace con il multithreading.
Il costruttore di ReentrantLock accetta un parametro fair. Quando è true, il blocco garantisce ordine FIFO; quando è false, un nuovo thread può acquisire il blocco prima di quelli in attesa. La modalità equa previene la fame ma riduce il throughput del 10-20% a causa del sovraccarico di gestione della coda.
A differenza di synchronized, ReentrantLock supporta tryLock con timeout. Se il blocco non può essere acquisito entro il tempo specificato, il thread continua l’esecuzione invece di bloccarsi indefinitamente. Il metodo lockInterruptibly consente di interrompere un thread in attesa tramite Thread.interrupt().
val lock = ReentrantLock()
fun tryTask() {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
println("Blocco acquisito")
} finally {
lock.unlock()
}
} else {
println("Impossibile acquisire il blocco")
}
}
ReentrantLock supporta più variabili di condizione tramite il metodo newCondition(). Ogni Condition ha la propria coda di attesa, consentendo scenari di risveglio complessi. I metodi await() e signal() hanno sostituito wait() e notify() dei blocchi synchronized, ma con supporto per più code.
ReadWriteLock e StampedLock affrontano l’ottimizzazione dell’accesso quando le letture predominano sulle scritture. Sono significativamente più efficienti di ReentrantLock negli scenari in cui la lettura avviene più frequentemente della scrittura.
L’interfaccia ReadWriteLock contiene due metodi: readLock() e writeLock(). Il blocco di lettura può essere mantenuto da più thread contemporaneamente, mentre il blocco di scrittura è esclusivo. Un esempio tipico è una cache thread-safe: molti thread leggono dati mentre solo uno li aggiorna periodicamente.
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 aggiunge una terza modalità — tryOptimisticRead. Questa modalità non blocca altri thread, ma registra solo un timbro (stamp) dello stato. Dopo la lettura, lo sviluppatore chiama validate(stamp) per verificare se i dati sono cambiati durante la lettura. Se i dati sono cambiati, l’operazione deve essere ripetuta.
Nelle applicazioni mobili, i blocchi vengono utilizzati per coordinare l’accesso ai dati condivisi tra i thread. Tuttavia, il loro uso richiede particolare cautela a causa delle risorse limitate del dispositivo e della necessità di mantenere la reattività dell’interfaccia.
Su Android, ReentrantLock è utile quando si lavora con Room, cache e file. È importante ricordare: non acquisire mai un blocco sul thread principale. Per il codice asincrono, sono preferibili le coroutine e Mutex di kotlinx.coroutines, che sospendono la coroutine invece di bloccare il thread.
In iOS, il NSLock standard è meno utilizzato — gli sviluppatori preferiscono DispatchQueue con flag barrier o os_unfair_lock. Swift 5.7+ fornisce meccanismi di sincronizzazione moderni tramite attori, che proteggono automaticamente lo stato.
import Foundation
actor DataStore {
private var items: [String] = []
func add(_ item: String) {
items.append(item)
}
func getAll() -> [String] {
items
}
}
Per evitare deadlock, seguire un ordine di acquisizione coerente in tutto il progetto. Utilizzare tryLock con timeout invece di lock() ovunque sia possibile un blocco prolungato. Considerare l’uso di algoritmi Lock-Free (AtomicReference, ConcurrentHashMap) invece dei blocchi tradizionali.
L’uso di Lock richiede disciplina e il rispetto di diverse regole che prevengono deadlock e degrado delle prestazioni. Queste pratiche sono state sviluppate dalla comunità Java in 20 anni di utilizzo del pacchetto java.util.concurrent.
Il pattern più importante è lock in finally. Indipendentemente dal fatto che la sezione critica venga completata con successo o generi un’eccezione, il blocco deve essere rilasciato. Ciò garantisce che altri thread non rimangano bloccati per sempre a causa di un singolo errore. In Kotlin, questo pattern è elegantemente risolto tramite l’estensione withLock.
La sezione critica dovrebbe essere il più breve possibile. Non eseguire mai I/O, richieste di rete o calcoli lunghi all’interno di un blocco. Se è necessario leggere dati da un server, recuperarli prima, quindi acquisire il blocco solo per aggiornare lo stato condiviso. Ciò riduce la contesa e migliora il throughput del sistema.
Per prevenire deadlock quando si lavora con più blocchi, stabilire un ordine di acquisizione globale in tutto il progetto. Se lockA viene acquisito per primo, poi lockB — qualsiasi sequenza inversa deve essere vietata dalle regole di revisione del codice. Utilizzare analizzatori statici come SpotBugs e IntelliJ Inspections per la verifica automatica.
Domande frequenti
Lock è un’interfaccia esplicita con supporto per timeout e attesa interruptibile. synchronized acquisisce e rilascia automaticamente il monitor, ma non consente di utilizzare tryLock, lockInterruptibly o più Conditions. Lock è più flessibile ma richiede rilascio manuale in finally.
Un blocco equo garantisce ordine FIFO: il thread che ha aspettato più a lungo riceve il blocco per primo. Un blocco non equo può concedere l’accesso a un nuovo thread prima di quelli in attesa, il che aumenta il throughput ma può causare fame dei thread in attesa.
Seguire un ordine fisso per acquisire tutti i blocchi, utilizzare tryLock con timeout invece di lock() incondizionato e minimizzare il numero di blocchi mantenuti contemporaneamente. L’uso di strutture dati Lock-Free riduce anche il rischio di deadlock.
Condition è l’analogo di wait/notify per Lock, che consente più code di attesa indipendenti. Ogni chiamata newCondition() crea una coda separata, fornendo un controllo più preciso sul risveglio dei thread rispetto alla coda singola di synchronized.
Per Android con coroutine, utilizzare Mutex di kotlinx.coroutines — sospende la coroutine invece di bloccare il thread. Per iOS con Swift 5.7+, sono preferibili gli attori, che sincronizzano automaticamente l’accesso allo stato. Riservare ReentrantLock per codice legacy e scenari di basso livello.
Riepilogo
Svilupperemo un'applicazione mobile chiavi in mano
IT Sectr crea applicazioni iOS e Android per startup e aziende dal 2017. Ti consulteremo e ti proporremo la soluzione migliore.
Leggi anche