1. Classe ReentrantLock: lock flessibile
La parola chiave synchronized è ottima per i casi di base: consente di proteggere rapidamente e facilmente un metodo o un blocco di codice. Ma a volte serve di più:
- Gestire esplicitamente il lock (per esempio, provare ad acquisirlo e, se non riesce, non restare in attesa).
- Separare i diritti di “lettura” e “scrittura” su una risorsa.
- Interrompere l’attesa del lock.
- Diagnosticare chi e quando ha acquisito o rilasciato il lock.
Per questi casi esistono le classi ReentrantLock e ReadWriteLock. Offrono più controllo e funzionalità rispetto al caro vecchio synchronized.
Che cos’è?
ReentrantLock è una classe che implementa l’interfaccia Lock. Funziona in modo simile a synchronized, ma con funzionalità aggiuntive. La differenza principale è che la gestione del lock diventa esplicita: sei tu a chiamare i metodi lock() e unlock().
Un aspetto interessante: il termine reentrant significa che un thread può acquisire lo stesso lock più volte di seguito senza causare un blocco reciproco. È utile se un metodo si richiama ricorsivamente o utilizza un lock condiviso in una catena di chiamate.
Sintassi d’uso
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int value = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Acquisiamo il lock
try {
value++;
} finally {
lock.unlock(); // Rilasciare sempre il lock!
}
}
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
Attenzione:
Le chiamate lock() e unlock() devono sempre essere racchiuse in un blocco try...finally. Se dimentichi di chiamare unlock(), nessun altro thread potrà entrare nel blocco protetto — otterrai un blocco permanente.
Funzionalità di ReentrantLock
Tentativo di acquisizione:
Puoi provare ad acquisire il lock senza attendere all’infinito:
if (lock.tryLock()) {
try {
// Lavoriamo
} finally {
lock.unlock();
}
} else {
// Acquisizione non riuscita — facciamo qualcos’altro
}
Attesa con timeout:
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
// Acquisito entro 100 ms
}
Verificare se il lock è acquisito:
if (lock.isLocked()) { ... }
Diagnostica della coda di attesa, “fairness” del lock e altri extra.
3. Esempio: incremento di un contatore con ReentrantLock
Sviluppiamo la nostra applicazione console (ad esempio, simuliamo l’elaborazione di ordini da thread diversi). Confrontiamo come cambia l’implementazione con synchronized e con ReentrantLock.
Esempio con synchronized
public class OrderCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Esempio equivalente con ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class OrderCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Qual è il vantaggio?
- Puoi provare ad acquisire il lock senza aspettare all’infinito (tryLock()).
- Puoi implementare logiche complesse: ad esempio, acquisire più lock in un ordine specifico (utile per strutture dati complesse).
- Puoi “sbloccare” da un punto diverso del codice (ma fallo con molta attenzione — ricorda sempre unlock()!).
4. ReadWriteLock: lock per lettura e scrittura
Che cos’è?
ReadWriteLock non è un semplice lucchetto, ma un gestore intelligente degli accessi. La sua implementazione principale è ReentrantReadWriteLock, che suddivide i lock in due categorie: di lettura e di scrittura.
Quando i thread leggono soltanto i dati e nessuno li modifica, possono lavorare in parallelo — la lettura non ostacola la lettura. Ma non appena qualcuno deve apportare una modifica, gli altri devono attendere: la scrittura consente un solo partecipante e richiede esclusività.
Questo approccio è particolarmente utile quando le letture sono molte e le modifiche poche — ad esempio, in un catalogo prodotti che gli utenti consultano spesso ma aggiornano di rado.
Sintassi d’uso
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ProductCatalog {
private final Map<String, String> products = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public void addProduct(String id, String name) {
rwLock.writeLock().lock();
try {
products.put(id, name);
} finally {
rwLock.writeLock().unlock();
}
}
public String getProduct(String id) {
rwLock.readLock().lock();
try {
return products.get(id);
} finally {
rwLock.readLock().unlock();
}
}
}
Esempio d’uso nella nostra applicazione
Supponiamo di avere un archivio ordini che tutti i thread leggono (ad esempio per cercare un ordine), ma di tanto in tanto arrivano nuovi ordini (operazione di scrittura).
import java.util.*;
import java.util.concurrent.locks.*;
public class OrderDatabase {
private final List<String> orders = new ArrayList<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Aggiunta di un ordine (richiede writeLock)
public void addOrder(String order) {
rwLock.writeLock().lock();
try {
orders.add(order);
} finally {
rwLock.writeLock().unlock();
}
}
// Ottenere una copia di tutti gli ordini (si può leggere in parallelo)
public List<String> getOrders() {
rwLock.readLock().lock();
try {
// Restituiamo una copia per evitare race condition
return new ArrayList<>(orders);
} finally {
rwLock.readLock().unlock();
}
}
}
Che cosa succede?
- Finché nessuno scrive, anche mille thread possono leggere simultaneamente l’elenco degli ordini.
- Non appena un thread inizia ad aggiungere un ordine — le letture vengono bloccate per evitare dati incoerenti.
5. Confronto: quando usare cosa?
| Scenario | synchronized | ReentrantLock | ReadWriteLock |
|---|---|---|---|
| Sincronizzazione semplice | ✔ | ✔ | ✖ (eccessivo) |
| Serve timeout/tentativo di acquisizione | ✖ | ✔ | ✔ |
| Molte letture, poche scritture | ✖ | ✖ | ✔ (incremento significativo) |
| Servono diagnostica/statistiche | ✖ | ✔ | ✔ |
| Lock ricorsivo | ✔ | ✔ (ri-entrabilità) | ✔ |
Conclusioni:
- Per i casi semplici — usa synchronized.
- Per maggiore flessibilità — ReentrantLock.
- Per scenari “leggiamo spesso, scriviamo di rado” — ReadWriteLock.
6. Visualizzazione: schema di funzionamento di ReadWriteLock
flowchart LR
subgraph Lettura
T1[Thread 1] -- Lettura --> Orders
T2[Thread 2] -- Lettura --> Orders
T3[Thread 3] -- Lettura --> Orders
end
subgraph Scrittura
T4[Thread 4] -- Scrittura (addOrder) --> Orders
end
Orders[Elenco degli ordini]
style Orders fill:#f9f,stroke:#333,stroke-width:2px
Finché nessun thread scrive, tutti possono leggere contemporaneamente. Quando compare una scrittura, gli altri thread attendono il rilascio di writeLock.
7. Dettagli di implementazione e sfumature
“Fairness” (equità)
In ReentrantLock e ReentrantReadWriteLock è possibile abilitare la modalità “fair” (fair mode): i thread vengono serviti in ordine di coda, non secondo il principio “chi prima arriva, meglio alloggia”. Questo previene la starvation dei thread, ma può ridurre le prestazioni.
Lock fairLock = new ReentrantLock(true); // true — modalità fair
ReadWriteLock fairRWLock = new ReentrantReadWriteLock(true);
Insidie potenziali
- unlock dimenticato: Se non chiami unlock(), otterrai un blocco permanente. Usa sempre try...finally.
- Eccezioni all’interno del lock: Anche se nel blocco di codice si verifica un’eccezione, il lock deve essere rilasciato!
- Uso eccessivo di ReadWriteLock: Per collezioni piccole o se quasi sempre si scrive, ReadWriteLock serve a poco e complica il codice.
8. Errori tipici
Errore n. 1: hai dimenticato di chiamare unlock()
L’errore più comune e subdolo è dimenticare di chiamare unlock() dopo aver acquisito il lock. Il risultato è un blocco permanente: i thread restano “appesi”. Usa sempre try...finally, anche se ti sembra che “qui non possa succedere nulla”.
Errore n. 2: usare ReadWriteLock dove non serve
Se quasi non hai letture in parallelo e la scrittura è frequente, ReadWriteLock complica soltanto il codice e riduce le prestazioni. Usalo solo quando ci sono davvero molti lettori contemporanei.
Errore n. 3: acquisire più lock in ordini diversi
Se il tuo codice acquisisce più Lock (per esempio, per oggetti diversi), fallo sempre nello stesso ordine in tutti i thread. Altrimenti puoi incorrere in un deadlock — i thread si aspetteranno a vicenda all’infinito.
Errore n. 4: sostituire synchronized con ReentrantLock “tanto per”
Non ha senso sostituire alla cieca tutti i synchronized con Lock — non sempre velocizza il programma e può rendere il codice meno leggibile.
Errore n. 5: dimenticarsi della reentrancy
Se lo stesso thread chiama lock() più volte di seguito — è normale per ReentrantLock, ma ricorda che unlock() va chiamato lo stesso numero di volte!
GO TO FULL VERSION