Interfaccia Lock di Java
L'interfaccia java.util.concurrent.locks.Lock — cosa offre in più rispetto a `synchronized` e le regole per usarla in modo sicuro.
synchronized è lo strumento piccolo e preciso. È veloce, automatico, e copre la maggior parte delle esigenze di mutua esclusione. Ma quando le sue capacità non bastano più — quando hai bisogno di un timeout, di un modo per interrompere l'attesa, o di più di una variabile di condizione — Java offre una seconda API di locking più ricca: l'interfaccia java.util.concurrent.locks.Lock e le sue implementazioni. Questo capitolo introduce l'interfaccia; i due capitoli successivi trattano le due implementazioni (ReentrantLock, ReentrantReadWriteLock) che userai nella pratica.
Cosa offre l'interfaccia
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}Sei metodi. Cinque riguardano l'acquisizione o il rilascio del lock; uno restituisce una Condition (la risposta di Lock a wait/notify).
I quattro modi per acquisire sono ciò che synchronized non ti dà:
lock()— blocca fino all'acquisizione. Il più simile asynchronized.lockInterruptibly()— blocca fino all'acquisizione, ma lanciaInterruptedExceptionse il thread viene interrotto. Permette di cancellare un thread in attesa di un lock.tryLock()— tenta una volta, restituiscetrue/falseimmediatamente. Non blocca.tryLock(time, unit)— tenta fino a un timeout, poi rinuncia. Lo strumento di prevenzione dei deadlock introdotto due capitoli fa.
synchronized ha un solo modo di acquisizione — blocca per sempre finché non ottieni il lock. Questo va bene per la maggior parte del codice; non va bene quando hai bisogno di una scadenza o di un punto di cancellazione.
Il pattern obbligatorio try/finally
synchronized rilascia il monitor automaticamente all'uscita del blocco — sia in caso di completamento normale che di eccezione. Lock no. Se dimentichi di chiamare unlock, il lock rimane acquisito per sempre e tutto ciò che segue si blocca.
Il pattern corretto, sempre:
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}unlock deve trovarsi in un finally affinché venga eseguito anche se il corpo lancia un'eccezione. Non esiste try-with-resources per Lock direttamente (non è AutoCloseable), ma esistono pattern wrapper che lo simulano. Il pattern standard sopra è quello usato dalla quasi totalità del codice in produzione.
tryLock e timeout
I due overload di tryLock sono il modo in cui Lock ti permette di gestire "e se non riusciamo ad acquisirlo?":
if (lock.tryLock()) {
try {
doWork();
} finally {
lock.unlock();
}
} else {
// didn't get the lock — do something else, maybe retry later
}if (lock.tryLock(500, TimeUnit.MILLISECONDS)) { // wait up to 500ms
try {
doWork();
} finally {
lock.unlock();
}
} else {
throw new TimeoutException("couldn't acquire " + name);
}La seconda forma è ciò che rende possibile il recupero dai deadlock. Con synchronized, un thread in attesa di un monitor è bloccato finché il detentore non rilascia — non c'è via d'uscita se non la terminazione della JVM. Con tryLock(timeout), si rinuncia dopo una scadenza e si può riprovare, segnalare un fallimento dell'operazione, oppure seguire un percorso alternativo.
lockInterruptibly — acquisizione del lock cancellabile
synchronized non risponde a Thread.interrupt() durante l'attesa. Un thread BLOCKED su un monitor rimane bloccato anche se viene interrotto — la JVM imposta soltanto il flag e lo ignora.
lock.lockInterruptibly() risponde invece. Se un altro thread chiama interrupt() su di te mentre sei in attesa del lock, la chiamata lancia immediatamente InterruptedException:
try {
lock.lockInterruptibly();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return; // gave up on the work
}
try {
doWork();
} finally {
lock.unlock();
}Questo è essenziale nel codice server: arriva una richiesta, un thread tenta di acquisire un lock, la richiesta viene cancellata (disconnessione del client, timeout da un load balancer), il supervisore chiama interrupt() sul worker. Con synchronized il worker continua ad aspettare; con lockInterruptibly, rinuncia.
Condition — l'equivalente wait/notify per Lock
L'equivalente del monitor intrinseco — wait/notify su un blocco synchronized — ti dà esattamente un wait set per oggetto. Un singolo Lock può avere più oggetti Condition:
Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();Detieni il lock, chiami await() su una condizione (che rilascia il lock e ti mette in pausa), e un altro thread chiama signal() sulla condizione (che ti sposta nello stato BLOCKED in attesa del lock). La corrispondenza con wait/notify:
Lock + Condition | Monitor intrinseco |
|---|---|
lock.lock() | entra in synchronized (obj) |
condition.await() | obj.wait() |
condition.signal() | obj.notify() |
condition.signalAll() | obj.notifyAll() |
lock.unlock() | esce da synchronized |
Il vantaggio su wait/notify: più condizioni per lock. Un buffer limitato può avere una condizione per "non pieno" e una per "non vuoto" — i produttori chiamano signal(notEmpty) dopo aver inserito un elemento; i consumatori chiamano signal(notFull) dopo averlo prelevato. Viene svegliato solo il lato giusto. L'approccio con notifyAll su un singolo monitor deve svegliare tutti e sperare.
Vedremo la riscrittura del buffer limitato nel capitolo su ReentrantLock.
Quando usare Lock, quando restare con synchronized
Una regola decisionale pragmatica:
- Prediligi
synchronizedper la semplice mutua esclusione. È automatico, non può andare in leak, e la JVM lo ottimizza pesantemente. - Usa
Lockquando hai bisogno di: un timeout sull'acquisizione, la possibilità di cancellare un thread in attesa tramiteinterrupt, piùConditionsullo stesso lock, o distinzione lettura-scrittura (ReentrantReadWriteLock). - Usa
Lockquando la contention è elevata e hai bisogno di un ordinamento equo (new ReentrantLock(true)è la versione fair; i monitor intrinseci sono unfair). L'ordinamento fair scambia throughput per prevedibilità.
Non dovresti "promuovere" synchronized a Lock senza motivo. I due sono equivalenti nel caso base; il resto del capitolo riguarda quando le capacità aggiuntive sono utili.
Cosa perdi
Lock ha dei costi che synchronized non ha:
- Nessun rilascio automatico. Dimentica
finallye il lock va in leak. La JVM non può salvarti. - Nessuna verifica strutturale dell'annidamento. Con
synchronizedil compilatore impone l'accoppiamento lock/unlock; conLock, puoi chiamareunlock()da un metodo o percorso diverso e il compilatore non se ne accorge. - Nessuna ottimizzazione nativa del runtime. La JVM dispone di ottimizzazioni speciali per i monitor intrinseci (biased locking, lock coarsening, lock elision in alcuni casi) che non si applicano a
Lock. Per codice a contention molto bassa,synchronizedpuò essere leggermente più veloce. - Più superficie per gli errori.
tryLockelockInterruptiblydevono entrambi essere abbinati a un controllo; omettere il controllo produce un bug silenzioso "lock non acquisito".
Usa Lock per le capacità aggiuntive, non per la sintassi.
Un esempio pratico: Lock fa ciò che synchronized non può
Il programma seguente usa ReentrantLock (l'implementazione standard di Lock) per dimostrare le tre cose che synchronized non offre: tryLock con timeout, lockInterruptibly, e una Condition personalizzata.
Cosa osservare dall'esecuzione:
- Il pattern
try/finallydella sezione 1 è ciò di cui ogni punto di chiamataLockha bisogno. Non c'è protezione sintattica — se elimini ilfinally, il codice compila, e il lock va in leak alla prima eccezione lanciata dal corpo. Memorizza la forma:lock(),try { ... } finally { unlock(); }. - Il
tryLock(100, MS)della sezione 2 ha restituitofalsedopo circa 100 ms perché il thread holder era ancora nel suo sleep di 500 ms. Questo è il contratto della scadenza — la chiamata restituiscefalseallo scadere del timeout indipendentemente da tutto. Consynchronizedquesto thread avrebbe aspettato finché il holder non rilasciava, senza via d'uscita. - Il waiter della sezione 3 è stato interrotto mentre aspettava il lock, e
lockInterruptiblyha lanciatoInterruptedException. Confronta conlock.lock()osynchronized— nessuno dei due risponde ainterrupt()durante l'attesa. Questa è la differenza tra un server che può cancellare le richieste scadute e uno che accumula semplicemente thread bloccati. - La sezione 4 ha usato due
Conditionsu un lock —notFullper i produttori,notEmptyper i consumatori. Quando il produttore ha aggiunto un elemento, ha chiamatosignalsunotEmptyin modo specifico; è stato svegliato solo un consumatore. Conwait/notifyAllsu un monitor intrinseco, vengono svegliati tutti i thread in attesa che devono poi riverificare; la coppia diConditioninvia il wakeup al lato corretto della coda, risparmiando il ciclo wakeup/ricontrollo. - Il
signal()(singolare) anzichésignalAll()è sicuro qui perché tutti gliawaiter su ciascuna condizione sono intercambiabili — qualsiasi produttore può riempire lo slot appena liberato. Se i waiter non fossero intercambiabili (ad esempio, stessero aspettando chiavi specifiche diverse),signalAllrimarrebbe comunque l'opzione più sicura di default.
Cosa viene dopo
Il capitolo successivo, Java ReentrantLock, approfondisce l'implementazione standard di Lock — la sua rientranza, la politica di fairness, e l'API diagnostica getHoldCount/isHeldByCurrentThread.