W3docs

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 a synchronized.
  • lockInterruptibly() — blocca fino all'acquisizione, ma lancia InterruptedException se il thread viene interrotto. Permette di cancellare un thread in attesa di un lock.
  • tryLock() — tenta una volta, restituisce true/false immediatamente. 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 + ConditionMonitor 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 synchronized per la semplice mutua esclusione. È automatico, non può andare in leak, e la JVM lo ottimizza pesantemente.
  • Usa Lock quando hai bisogno di: un timeout sull'acquisizione, la possibilità di cancellare un thread in attesa tramite interrupt, più Condition sullo stesso lock, o distinzione lettura-scrittura (ReentrantReadWriteLock).
  • Usa Lock quando 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 finally e il lock va in leak. La JVM non può salvarti.
  • Nessuna verifica strutturale dell'annidamento. Con synchronized il compilatore impone l'accoppiamento lock/unlock; con Lock, puoi chiamare unlock() 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, synchronized può essere leggermente più veloce.
  • Più superficie per gli errori. tryLock e lockInterruptibly devono 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.

java— editable, runs on the server

Cosa osservare dall'esecuzione:

  • Il pattern try/finally della sezione 1 è ciò di cui ogni punto di chiamata Lock ha bisogno. Non c'è protezione sintattica — se elimini il finally, 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 restituito false dopo circa 100 ms perché il thread holder era ancora nel suo sleep di 500 ms. Questo è il contratto della scadenza — la chiamata restituisce false allo scadere del timeout indipendentemente da tutto. Con synchronized questo thread avrebbe aspettato finché il holder non rilasciava, senza via d'uscita.
  • Il waiter della sezione 3 è stato interrotto mentre aspettava il lock, e lockInterruptibly ha lanciato InterruptedException. Confronta con lock.lock() o synchronized — nessuno dei due risponde a interrupt() 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 Condition su un lock — notFull per i produttori, notEmpty per i consumatori. Quando il produttore ha aggiunto un elemento, ha chiamato signal su notEmpty in modo specifico; è stato svegliato solo un consumatore. Con wait/notifyAll su un monitor intrinseco, vengono svegliati tutti i thread in attesa che devono poi riverificare; la coppia di Condition invia il wakeup al lato corretto della coda, risparmiando il ciclo wakeup/ricontrollo.
  • Il signal() (singolare) anziché signalAll() è sicuro qui perché tutti gli awaiter 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), signalAll rimarrebbe 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.

Esercitazione

Pratica
Stai scrivendo codice che deve acquisire un lock con una scadenza — rinunciare dopo 200 ms se il lock non è disponibile ed eseguire un'azione alternativa. Qual è l'approccio corretto?
Stai scrivendo codice che deve acquisire un lock con una scadenza — rinunciare dopo 200 ms se il lock non è disponibile ed eseguire un'azione alternativa. Qual è l'approccio corretto?
Was this page helpful?