W3docs

Java ReentrantLock

Usa ReentrantLock per un locking esplicito e flessibile in Java — tryLock, lockInterruptibly, politica di fairness e API diagnostica.

ReentrantLock è l'implementazione standard dell'interfaccia Lock. "Reentrant" significa che lo stesso thread può acquisire il lock più volte senza bloccarsi su se stesso — la stessa proprietà di synchronized. Tutto ciò che il capitolo su Lock ha descritto — tryLock, lockInterruptibly, Condition — è fornito da questa singola classe.

Questo capitolo è l'approfondimento: i due costruttori, l'opzione di fairness, i metodi diagnostici, l'abbinamento con Condition per il pattern produttore/consumatore e i casi concreti in cui ReentrantLock giustifica il codice boilerplate aggiuntivo con try/finally rispetto al semplice synchronized.

Cosa tratta questa pagina

Due costruttori

Lock lock = new ReentrantLock();        // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true);    // fair — FIFO wait queue

Non-fair (il predefinito) significa che quando il lock diventa disponibile, il thread in attesa che lo scheduler esegue per primo vince. I nuovi thread in arrivo possono anche "sorpassare" — acquisire il lock senza mettersi in coda se risulta libero nel momento della chiamata. Questo è veloce: nessuna manipolazione della coda, nessun hint allo scheduler. Lo svantaggio è la possibilità di starvation — un thread può restare in coda per molto tempo mentre i sorpassatori continuano a prendere il lock.

Fair significa che il lock viene assegnato al thread che ha aspettato più a lungo. La coda di attesa è un vero FIFO. Questo elimina la starvation. Il costo: throughput notevolmente inferiore, perché ogni acquisizione comporta una decisione dello scheduler e la JVM non può prendere scorciatoie sul percorso veloce.

Il predefinito corretto è non-fair. Usa fair solo quando hai identificato un vero problema di starvation (tipicamente rilevato da una query getWaitQueueLength che continua a crescere) o quando la correttezza dell'applicazione dipende dall'ordine di elaborazione.

Reentrancy e getHoldCount

Come synchronized, un ReentrantLock può essere riacquisito dal thread che lo detiene già:

ReentrantLock lock = new ReentrantLock();
lock.lock();
lock.lock();                          // same thread re-enters — fine
try {
  doStuff();
} finally {
  lock.unlock();
  lock.unlock();                      // must unlock as many times as locked
}

Ogni lock() incrementa un contatore di hold interno; ogni unlock() lo decrementa. Il lock viene effettivamente rilasciato — visibile agli altri thread — solo quando il contatore raggiunge zero. La chiamata diagnostica:

int n = lock.getHoldCount();          // how many times THIS thread has acquired without unlocking

getHoldCount è utile per le asserzioni ("questo metodo deve essere chiamato con il lock detenuto") e per verificare gli invarianti nei test.

La regola di abbinamento degli unlock è rigorosa. Se esegui lock due volte e unlock una sola volta, il lock rimane detenuto — perdita silenziosa. Se esegui unlock più volte di quante ne hai bloccate, viene lanciata immediatamente IllegalMonitorStateException. Quando possibile, abbina sempre acquisizione e rilascio nello stesso metodo; distribuirli su più metodi rende la gestione fragile molto rapidamente.

Altri metodi diagnostici

ReentrantLock espone un discreto numero di funzionalità di introspezione che synchronized non offre:

lock.isLocked();                       // is anybody holding it?
lock.isHeldByCurrentThread();          // do I hold it?
lock.getQueueLength();                 // how many threads are waiting?
lock.hasQueuedThreads();               // are any waiting?
lock.hasQueuedThread(t);               // is thread t waiting?
lock.getHoldCount();                   // how many times have I re-entered?

Questi servono principalmente per il monitoraggio e i test — la logica di produzione non dovrebbe dipendere da "c'è qualcuno in attesa" perché la risposta è soggetta a race condition nel momento in cui la controlli. Ma per le metriche, il "rapporto di acquisizioni contese" è un segnale utile che la granularità del lock è sbagliata.

Quando ReentrantLock batte synchronized

I quattro motivi per scegliere ReentrantLock:

  1. Acquisizione con scadenza. Hai bisogno di fallire rapidamente o di fare un back-off se il lock non è disponibile entro un tempo limitato. tryLock(timeout) fa questo; synchronized no.
  2. Cancellazione. Hai bisogno di interrompere un thread che sta aspettando il lock. lockInterruptibly fa questo; synchronized ignora le interruzioni durante l'ingresso nel monitor.
  3. Variabili di condizione multiple. Hai bisogno di segnalare separatamente diverse categorie di thread in attesa. Lock.newCondition() fa questo; il monitor intrinseco ha esattamente un unico wait set.
  4. Fairness. Hai bisogno dell'ordinamento FIFO dei thread in attesa. new ReentrantLock(true) è l'unico modo nativo.

Qualsiasi cosa al di fuori di questi quattro casi — codice ben scritto senza esigenze particolari — dovrebbe restare su synchronized. Le ottimizzazioni della JVM per i monitor intrinseci sono reali, e la disciplina try/finally richiesta da Lock è qualcosa che puoi dimenticare. Non usare Lock "perché è più moderno."

La coppia Condition, in dettaglio

Il pattern produttore/consumatore con un ReentrantLock e due condizioni è l'esempio classico. Lo riportiamo qui in forma autonoma perché è anche la struttura che ArrayBlockingQueue utilizza internamente:

class BoundedBuffer<T> {
  private final ReentrantLock lock = new ReentrantLock();
  private final Condition notFull  = lock.newCondition();
  private final Condition notEmpty = lock.newCondition();
  private final Object[] items;
  private int count, head, tail;

  BoundedBuffer(int cap) { items = new Object[cap]; }

  public void put(T x) throws InterruptedException {
    lock.lock();
    try {
      while (count == items.length) notFull.await();          // release lock, park, re-acquire on wake
      items[tail] = x;
      tail = (tail + 1) % items.length;
      count++;
      notEmpty.signal();                                       // wake exactly one consumer
    } finally { lock.unlock(); }
  }

  @SuppressWarnings("unchecked")
  public T take() throws InterruptedException {
    lock.lock();
    try {
      while (count == 0) notEmpty.await();
      T x = (T) items[head];
      items[head] = null;
      head = (head + 1) % items.length;
      count--;
      notFull.signal();                                        // wake exactly one producer
      return x;
    } finally { lock.unlock(); }
  }
}

Il vantaggio rispetto a wait/notifyAll: un produttore sveglia un consumatore, non tutti i thread in attesa. In caso di forte contesa, questa è la differenza tra le "tempeste di notifyAll" (ogni thread in attesa si sveglia, gareggia per il lock, tutti tranne uno tornano a dormire) e un passaggio di consegne pulito.

signal vs signalAll segue la stessa regola di notify vs notifyAll: preferisci signalAll a meno che tu non possa dimostrare che tutti i thread in attesa su questa condizione siano intercambiabili. In questo buffer, ogni thread in attesa su notEmpty è un consumatore che vuole uno slot — sono intercambiabili; signal è sicuro.

Il trade-off CAS-loop / monitor

Una domanda comune: quando una variabile atomica come AtomicInteger batte ReentrantLock? In linea di massima:

  • Per un singolo campo con un aggiornamento semplice, gli atomici vincono — sono istruzioni CAS, nessuna chiamata al kernel, nessun parking. AtomicInteger.incrementAndGet è più veloce di ReentrantLock.lock + int++ + unlock.
  • Per più campi che devono essere aggiornati insieme o per semantiche di blocking (aspettare che una coda non sia vuota), il lock vince — puoi raggruppare il lavoro e segnalare attraverso di esso.

Un controllo in sola lettura come "la cache è valida?" è volatile; un incremento è atomico; uno "scambio di un elemento con un altro in una coda" è un lock. Usa lo strumento più leggero che il lavoro richiede.

tryLock per la composizione senza deadlock

Il pattern più semplice per combinare due lock senza ordinamento:

boolean done = false;
while (!done) {
  lockA.lock();
  try {
    if (lockB.tryLock(50, TimeUnit.MILLISECONDS)) {           // bounded wait for the second lock
      try {
        doCriticalWork();
        done = true;
      } finally { lockB.unlock(); }
    }
    // else: couldn't get lockB in time — fall through, release lockA, retry
  } finally {
    lockA.unlock();                                           // always release lockA before looping
  }
}

Se tryLock su lockB va in timeout, il finally rilascia lockA e il ciclo riprova dall'inizio. Poiché nessun thread detiene mai un lock mentre si blocca indefinitamente su un altro, la classica condizione di deadlock hold-and-wait viene eliminata.

Questo evita la trappola del deadlock per ordinamento senza richiedere una regola globale di ordinamento dei lock. Il trade-off è più codice, potenziale livelock sotto forte contesa e comportamento peggiore della cache. Usalo per lock trasversali (lock su oggetti che non hai scritto tu); preferisci un ordine fisso di acquisizione dei lock quando controlli entrambi i lati.

Un esempio pratico: contesa, fairness e reentrancy

Il programma qui sotto mette a confronto un ReentrantLock fair vs non-fair sotto alta contesa, quindi dimostra la reentrancy e la contabilità dell'hold count.

java— editable, runs on the server

Cosa osservare dall'esecuzione:

  • Il lock non-fair ha distribuito il pool condiviso di acquisizioni in modo non uniforme — il valore spread = max-min riportato era grande (comunemente diverse migliaia su 50.000 per thread). Questo è il percorso veloce in azione: la JVM non impone l'ordinamento, quindi un thread che ha appena rilasciato il lock può immediatamente rientrare e vincere di nuovo prima che un thread in coda venga schedulato.
  • Il lock fair ha distribuito le acquisizioni quasi uniformemente — il suo spread era una piccola frazione di quello non-fair, perché la coda di attesa FIFO dà a ciascun thread il suo turno. Il tempo totale di esecuzione era notevolmente più lungo. L'ordinamento fair scambia throughput con progressione prevedibile. Non pagare quel costo a meno che tu non abbia misurato un problema di starvation. (I numeri esatti variano per ogni esecuzione e per ogni macchina; ciò che è stabile è che lo spread non-fair è molto più grande di quello fair.)
  • La sezione sulla reentrancy ha mostrato l'hold count salire e scendere con ogni lock/unlock. Il lock viene effettivamente rilasciato solo quando il contatore scende a zero; fino ad allora gli altri thread in attesa rimangono in stato BLOCKED. Questa è la stessa semantica di synchronized; la differenza è che ReentrantLock espone il contatore all'ispezione.
  • L'unlock() extra dopo che l'hold count ha raggiunto zero ha lanciato immediatamente IllegalMonitorStateException — non esiste un "doppio unlock" silenzioso che abbia successo. È la JVM che fa rispettare l'invariante del lock: solo il detentore può rilasciarlo, e solo tante volte quante lo ha acquisito.
  • La lettura di getQueueLength pari a 3 ha confermato che i tre thread in attesa erano genuinamente in coda dietro di noi. In produzione questo metodo è utile per gli alert "la contesa sta aumentando?" — una lunghezza della coda che cresce nel tempo è un segnale che il lavoro all'interno del lock è troppo lento.

Cosa c'è dopo

Il prossimo capitolo, Java ReadWriteLock, tratta ReentrantReadWriteLock — il lock che divide l'acquisizione in "molti lettori O un solo scrittore", per i carichi di lavoro a forte predominanza di letture dove i lock esclusivi sono eccessivi.

Esercitazione

Pratica
Chiami `lock.lock()` due volte dallo stesso thread su un `ReentrantLock` e poi chiami `lock.unlock()` una sola volta. Il lock viene rilasciato in modo che altri thread possano acquisirlo?
Chiami `lock.lock()` due volte dallo stesso thread su un `ReentrantLock` e poi chiami `lock.unlock()` una sola volta. Il lock viene rilasciato in modo che altri thread possano acquisirlo?
Was this page helpful?