1. Perché i++ non funziona nel multithreading?
Cominciamo con un problema classico: abbiamo una variabile contatore, ad esempio il numero di richieste elaborate o di file scaricati. Vogliamo che più thread incrementino questo contatore. Cosa può andare storto se scriviamo semplicemente i++?
Esempio: race condition sull'incremento
public class Counter {
public int count = 0;
public void increment() {
count++; // Non atomico!
}
}
Supponiamo che due thread chiamino contemporaneamente increment(). Entrambi leggono il vecchio valore, entrambi lo incrementano di 1 ed entrambi scrivono… lo stesso nuovo valore! Di conseguenza un incremento «va perso». Se lo si ripete molte volte, il valore finale sarà inferiore a quello atteso.
Perché succede?
L'operazione i++ in realtà consiste in tre passaggi:
- Leggere il valore della variabile (per esempio, 5).
- Incrementarlo di 1.
- Scrivere il nuovo valore in memoria.
In un ambiente multithread un altro thread può riuscire a modificare la variabile tra questi passaggi. Il risultato è una «race condition».
Che cosa sono le operazioni atomiche?
Una operazione atomica è un'azione che o viene eseguita completamente, o non viene eseguita affatto, e nessun altro thread può «infilarsi» nel mezzo di tale operazione.
In Java c'è una serie di classi che fornisce tali operazioni per primitivi e riferimenti. Si trovano nel package java.util.concurrent.atomic. Le più diffuse:
- AtomicInteger — tipo intero atomico.
- AtomicLong — long atomico.
- AtomicBoolean — boolean atomico.
- AtomicReference<T> — riferimento atomico a un oggetto di qualsiasi tipo.
2. AtomicInteger: contatore thread-safe
Dichiarazione e uso di base
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Incremento atomico
}
public int get() {
return count.get();
}
}
Qui incrementAndGet() esegue «incrementa e restituisci il nuovo valore» come un'operazione indivisibile. Anche se 100 thread chiamano contemporaneamente questo metodo, nessun incremento andrà perso.
Metodi utili:
| Metodo | Descrizione |
|---|---|
|
Ottenere il valore corrente |
|
Impostare il valore |
|
Incrementare di 1 e restituire il nuovo valore |
|
Restituire il valore corrente e incrementarlo di 1 |
|
Aumentare di delta e restituire il nuovo valore |
|
Se il valore corrente è uguale a expect, impostare update (CAS) |
Esempio: contatore multithread
Supponiamo di avere una classe che conteggia il numero di messaggi elaborati in una chat.
public class MessageStatistics {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void onMessageReceived() {
int newCount = messageCount.incrementAndGet();
System.out.println("Messaggi totali: " + newCount);
}
public int getMessageCount() {
return messageCount.get();
}
}
Interno: come funziona AtomicInteger?
Internamente, AtomicInteger utilizza un'istruzione speciale del processore — CAS (Compare-And-Swap, «confronta e sostituisci»). È un'operazione atomica che confronta il valore corrente della variabile con quello atteso e, se coincidono, scrive il nuovo valore. Se un altro thread è riuscito a modificare la variabile, l'operazione non viene eseguita e si ripete il tentativo.
Schema di funzionamento:
1. Leggiamo il valore corrente (per esempio, 5)
2. Confrontiamo con quello atteso (5)
3. Se coincide — scriviamo il nuovo valore (6)
4. Se non coincide — riproviamo
Tutto ciò avviene molto velocemente e senza blocchi (lock‑free). Per questo le classi atomiche sono spesso più veloci di synchronized, soprattutto con un numero elevato di thread.
3. AtomicReference: riferimento atomico a un oggetto
AtomicReference<T> è un contenitore atomico generico per qualsiasi oggetto. Consente di modificare in modo sicuro il riferimento a un oggetto da thread diversi.
Esempio: aggiornamento thread-safe del riferimento
import java.util.concurrent.atomic.AtomicReference;
public class AtomicReferenceExample {
private final AtomicReference<String> latestMessage = new AtomicReference<>("");
public void updateMessage(String message) {
latestMessage.set(message);
}
public String getLatestMessage() {
return latestMessage.get();
}
}
Uso di compareAndSet
L'operazione più interessante è compareAndSet(expected, newValue). Consente di aggiornare il valore solo se non è cambiato dall'ultima lettura.
public void safeUpdate(String oldValue, String newValue) {
boolean success = latestMessage.compareAndSet(oldValue, newValue);
if (success) {
System.out.println("Aggiornamento riuscito!");
} else {
System.out.println("Qualcuno ha già modificato il valore, riprova.");
}
}
Questa è la base degli algoritmi non bloccanti: da code e stack fino alle cache, dove è importante evitare blocchi superflui.
4. Esempi d'uso in un'applicazione
Esempio 1: contatore di messaggi multithread
public class ChatRoom {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void receiveMessage(String message) {
// ... elaborazione del messaggio ...
int count = messageCount.incrementAndGet();
System.out.println("Nuovo messaggio: " + message + ". Messaggi totali: " + count);
}
}
Esempio 2: aggiornamento sicuro del riferimento all'ultimo messaggio
public class ChatRoom {
private final AtomicReference<String> lastMessage = new AtomicReference<>("");
public void receiveMessage(String message) {
lastMessage.set(message);
// ... elaborazione ...
}
public String getLastMessage() {
return lastMessage.get();
}
}
Se occorre aggiornare il riferimento solo se l'ultimo messaggio non è cambiato (per evitare «perdite» con aggiornamenti simultanei), usa compareAndSet.
5. Limitazioni e insidie
Quando le classi atomiche non sono la panacea?
Le variabili atomiche sono perfette per operazioni semplici: incremento, impostazione del valore, verifica e sostituzione. Ma se è necessario aggiornare più variabili contemporaneamente, l'atomicità non è più garantita. Per esempio, se hai due contatori e vuoi incrementare entrambi come un'unica operazione — qui serve synchronized o un altro meccanismo di sincronizzazione.
Esempio di uso errato
// NON atomico!
if (ref.get() == null) {
ref.set("Hello");
}
Tra get() e set(...) un altro thread può modificare il valore e la condizione non sarà più vera. Per tali casi usa compareAndSet.
Classi atomiche ≠ oggetti thread-safe
Se l'oggetto a cui punta AtomicReference non è thread-safe, la sostituzione del riferimento sarà atomica, ma la modifica dei campi dell'oggetto — no. Per esempio, se memorizzi in AtomicReference<List<String>> un normale ArrayList, la lista in sé non diventa thread‑safe.
6. Classi atomiche avanzate
Nel package java.util.concurrent.atomic ci sono anche altre classi utili:
- AtomicLong, AtomicBoolean — per long e boolean.
- AtomicIntegerArray, AtomicReferenceArray — operazioni atomiche sugli array.
- LongAdder, LongAccumulator — per contatori ad alto carico.
LongAdder e LongAccumulator
Se hai moltissimi thread e il normale AtomicInteger diventa un «collo di bottiglia» (tutti i thread competono per una sola variabile), usa LongAdder. Suddivide il contatore in più celle interne e le somma alla richiesta del valore, offrendo vantaggio in caso di alta contesa.
import java.util.concurrent.atomic.LongAdder;
public class FastCounter {
private final LongAdder adder = new LongAdder();
public void increment() {
adder.increment();
}
public long getCount() {
return adder.sum();
}
}
7. Errori tipici nell'uso delle variabili atomiche
Errore n. 1: aspettarsi l'atomicità di operazioni complesse.
Se devi eseguire più azioni su un valore, le classi atomiche non ti salveranno — tra i passaggi un altro thread può modificare i dati. Per operazioni composte usa compareAndSet o la sincronizzazione.
Errore n. 2: ignorare la thread‑safety degli oggetti annidati.
Se in AtomicReference c'è un oggetto normale, i suoi metodi e campi non diventano thread-safe. È atomica solo la sostituzione del riferimento.
Errore n. 3: usare le classi atomiche senza necessità.
Nel codice single-thread i tipi atomici sono ridondanti e leggermente più lenti delle variabili normali a causa di controlli aggiuntivi.
Errore n. 4: ottimizzazione prematura.
A volte è più semplice e affidabile usare synchronized, soprattutto quando la logica è complessa e coinvolge più variabili contemporaneamente. Non sempre vale la pena costruire soluzioni lock‑free.
Errore n. 5: dimenticare il problema ABA.
Caso raro ma importante: il valore cambia da A a B e di nuovo ad A — compareAndSet «pensa» che nulla sia cambiato. Per tali scenari usa classi speciali come AtomicStampedReference (o AtomicMarkableReference).
GO TO FULL VERSION