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 — fair vs non-fair, e quale usare come predefinito.
- Reentrancy e
getHoldCount— come viene contato e rilasciato il re-ingresso. - Altri metodi diagnostici — l'introspezione che
synchronizednon può offrire. - Quando
ReentrantLockbattesynchronized— i quattro veri motivi per cambiare. - La coppia
Condition, composizione contryLocke un esempio pratico completo.
Due costruttori
Lock lock = new ReentrantLock(); // non-fair (default) — high throughput
Lock fair = new ReentrantLock(true); // fair — FIFO wait queueNon-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 unlockinggetHoldCount è 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:
- 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;synchronizedno. - Cancellazione. Hai bisogno di interrompere un thread che sta aspettando il lock.
lockInterruptiblyfa questo;synchronizedignora le interruzioni durante l'ingresso nel monitor. - 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. - 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 diReentrantLock.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.
Cosa osservare dall'esecuzione:
- Il lock non-fair ha distribuito il pool condiviso di acquisizioni in modo non uniforme — il valore
spread = max-minriportato 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 statoBLOCKED. Questa è la stessa semantica disynchronized; la differenza è cheReentrantLockespone il contatore all'ispezione. - L'
unlock()extra dopo che l'hold count ha raggiunto zero ha lanciato immediatamenteIllegalMonitorStateException— 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
getQueueLengthpari 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.