1. Unlock/release dimenticato: una trappola per i distratti
Uno degli errori più subdoli nell’uso degli strumenti di sincronizzazione moderni, come ReentrantLock o Semaphore, è dimenticare di chiamare unlock() o release(). Se non rilasci il lock, gli altri thread aspetteranno il suo rilascio... per sempre. L’applicazione si bloccherà e resterai a fissare lo schermo a lungo chiedendoti perché non succede nulla.
Consideriamo un esempio con ReentrantLock:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
// Ops! Abbiamo dimenticato unlock() — ora tutti si bloccheranno!
count++;
}
}
Sembra tutto innocuo, ma se chiami increment() più volte da thread diversi, dopo la prima chiamata gli altri thread attenderanno lo sblocco all'infinito.
Per evitare questa situazione, usa il costrutto try-finally:
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
Ora, anche se nel mezzo del metodo si verifica un'eccezione, il lock verrà comunque rilasciato.
È come se qualcuno occupasse il bagno (chiudendosi dentro), poi dimenticasse di aprire la porta e uscisse dalla finestra. Gli altri aspetteranno che quella persona esca... Non fatelo!
2. Sincronizzazione sull'oggetto sbagliato: «Ops, ho messo il lucchetto nel posto sbagliato!»
In Java la parola chiave synchronized può bloccare l’accesso a un oggetto. Ma se scegli l’oggetto sbagliato da bloccare, la sincronizzazione non funzionerà come ti aspetti.
Errore n. 1: sincronizzazione su una variabile locale
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// Ogni volta un nuovo oggetto — nessuna sincronizzazione!
// I thread non si attendono a vicenda.
// La sezione critica non è protetta!
}
}
Qui ogni thread crea il proprio oggetto lock. Di conseguenza non avviene alcun blocco reale: i thread entrano nella sezione critica contemporaneamente.
Corretto:
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// Ora tutti i thread usano lo stesso oggetto lock
// e si attendono davvero a vicenda.
}
}
Errore n. 2: sincronizzazione su un letterale stringa
public void doSomething() {
synchronized ("lock") {
// I letterali stringa sono internati: parti diverse del programma possono
// sincronizzarsi accidentalmente sulla stessa stringa!
}
}
Conclusione:
Sincronizzati solo su oggetti privati, creati appositamente per questo, che non siano utilizzati altrove.
3. Blocco incrociato (deadlock): «Io aspetto te — tu aspetti me, e restiamo fermi entrambi»
Il deadlock è un classico. Due (o più) thread acquisiscono a turno lock diversi e si aspettano a vicenda finché l’applicazione non si blocca.
Esempio:
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
// Aspettiamo un po' per rendere l'esperimento più evidente
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockB) {
// ...
}
}
}
public void method2() {
synchronized (lockB) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockA) {
// ...
}
}
}
}
Se un thread chiama method1() e un altro — method2(), il primo thread acquisirà lockA e attenderà lockB, mentre il secondo farà il contrario. Di conseguenza entrambi attenderanno l’un l’altro per sempre.
Come evitarlo?
- Acquisisci sempre i lock nello stesso ordine in tutti i thread.
- Riduci al minimo il numero di lock detenuti contemporaneamente.
- Usa strumenti di diagnostica (ad esempio, jstack) se l’applicazione è bloccata.
Analogia:
È come se due persone si incontrassero in un corridoio stretto e ciascuna decidesse di cedere il passo solo se l’altra lo facesse per prima. Alla fine restano entrambe ferme ad aspettare finché qualcuno non cede.
4. Sincronizzazione eccessiva: «Meglio esagerare che rischiare?» — non sempre!
A volte, per paura di errori, gli sviluppatori sincronizzano tutto. Il risultato è che le prestazioni calano e il beneficio è nullo.
Esempio:
public synchronized void add(int value) {
// Qui c'è solo una riga che non richiede sincronizzazione!
System.out.println("Aggiunto: " + value);
}
In questo caso la sincronizzazione non serve: la stampa a video tramite System.out.println è già thread-safe, e il metodo non lavora con risorse condivise.
Dove è critico?
Se sincronizzi metodi che vengono chiamati spesso e non richiedono protezione, riduci drasticamente le prestazioni dell’applicazione. I thread si mettono in coda anche se potrebbero lavorare in parallelo.
Best practice:
Sincronizza solo ciò che è davvero necessario. La sezione critica dovrebbe essere la più piccola possibile.
5. Uso improprio di volatile: «Visibilità sì, atomicità no!»
Il modificatore volatile in Java garantisce che le modifiche alla variabile siano visibili a tutti i thread. Ma non garantisce l’atomicità delle operazioni.
Errore:
private volatile int counter = 0;
public void increment() {
counter++; // Non atomico!
}
L’operazione counter++ consiste nella lettura del valore, nell’incremento e nella scrittura. Se due thread eseguono questo codice contemporaneamente, il valore finale può essere inferiore a quello atteso.
Corretto:
Per operazioni atomiche usa synchronized, AtomicInteger o altre classi thread-safe.
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
Quando usare volatile?
Per flag semplici (ad esempio «terminare l'esecuzione»), quando non è richiesta l’atomicità.
GO TO FULL VERSION