CodeGym /Corsi /JAVA 25 SELF /ReentrantLock e ReadWriteLock: differenze ed esempi

ReentrantLock e ReadWriteLock: differenze ed esempi

JAVA 25 SELF
Livello 52 , Lezione 2
Disponibile

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!

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION