Variabili Atomiche in Java
Operazioni thread-safe lock-free in Java con le classi java.util.concurrent.atomic — contatori, riferimenti e compare-and-set.
volatile rende sicuro un singolo accesso in lettura o scrittura tra thread. Non può rendere counter++ thread-safe — quella è un'operazione composta da tre passi. Il pacchetto java.util.concurrent.atomic colma questa lacuna. Le sue classi avvolgono un singolo valore ed espongono operazioni come increment-and-get e compare-and-set come istruzioni atomiche singole — nessun lock, nessun blocco synchronized, solo una primitiva CPU (compare-and-swap, o CAS) che la JVM compila direttamente.
Gli atomici sono lo strumento giusto per un numero sorprendentemente elevato di pattern multithread: contatori, numeri di sequenza, stato flag-like, e qualsiasi idioma "pubblica un nuovo snapshot immutabile". Sono più veloci di synchronized sotto contesa e molto più semplici del gestire manualmente un lock attorno a un singolo campo.
La famiglia
Il pacchetto contiene otto classi comunemente usate:
| Classe | Avvolge | Operazioni comuni |
|---|---|---|
AtomicInteger | int | get, set, incrementAndGet, addAndGet, compareAndSet |
AtomicLong | long | stesse di sopra, su long |
AtomicBoolean | boolean | get, set, compareAndSet |
AtomicReference<V> | V (qualsiasi riferimento a oggetto) | get, set, compareAndSet, updateAndGet |
AtomicIntegerArray | int[] | operazioni atomiche per indice |
AtomicLongArray | long[] | operazioni atomiche per indice |
AtomicReferenceArray<V> | V[] | operazioni atomiche per indice |
LongAdder / LongAccumulator | long | contatore ad alta contesa |
Le prime quattro sono quelle che userete nel 99% dei casi.
AtomicInteger — il contatore corretto
Il sostituto di "volatile int più ++":
AtomicInteger counter = new AtomicInteger(); // starts at 0
counter.incrementAndGet(); // ++counter, atomic
counter.getAndIncrement(); // counter++, atomic
counter.addAndGet(5); // counter += 5, atomic
counter.set(42); // counter = 42, atomic
int n = counter.get(); // read
counter.compareAndSet(42, 100); // if (counter == 42) counter = 100; return whether it changedincrementAndGet è ciò che si desidera per un semplice contatore; sotto il cofano è un ciclo CAS che la CPU esegue in un'unica istruzione su x86 moderni (LOCK XADD). L'intera operazione è una singola transazione di memoria a livello di bus — molto meno costosa dell'acquisire anche un lock synchronized non conteso.
compareAndSet(expected, new) è il mattone fondante di quasi tutto il resto. Scrive new atomicamente solo se il valore corrente è expected, e restituisce se la scrittura è avvenuta. Con esso si può costruire qualsiasi aggiornamento atomico su un singolo campo:
AtomicInteger max = new AtomicInteger(Integer.MIN_VALUE);
void recordMax(int v) {
int cur;
do {
cur = max.get();
if (v <= cur) return; // nothing to do
} while (!max.compareAndSet(cur, v)); // retry if someone else updated
}Il ciclo CAS è il pattern standard: leggi, calcola, prova a scrivere, riprova in caso di conflitto. È così che incrementAndGet è implementato; è così che si scrive qualsiasi aggiornamento composto su un singolo campo.
Java 8 ha semplificato il ciclo:
max.updateAndGet(cur -> Math.max(cur, v)); // CAS loop hiddenupdateAndGet, accumulateAndGet, e getAndUpdate accettano una funzione ed eseguono il ciclo CAS per voi. Preferiteli quando si adattano.
AtomicReference<V> — il modo corretto per scambiare un oggetto
Quando lo stato condiviso è più di una primitiva — una mappa di configurazione, uno snapshot in cache, un holder immutabile — AtomicReference permette di scambiare atomicamente l'intero oggetto:
AtomicReference<Config> currentConfig = new AtomicReference<>(initialConfig);
void reload() {
Config c = readConfigFromDisk(); // expensive, lock-free
currentConfig.set(c); // publish atomically
}
Config get() { return currentConfig.get(); }Il trucco: il contenuto di Config deve essere immutabile (o non toccato dopo la pubblicazione). Lo scambio atomico pubblica un valore completato; se altri thread poi mutano gli interni del valore avete perso la sicurezza. Questo è il pattern dello snapshot immutabile, ed è il modo in cui sono costruiti la maggior parte delle cache concorrenti, delle tabelle di routing e degli oggetti "configurazione globale".
updateAndGet su un riferimento è anche estremamente utile:
AtomicReference<List<String>> log = new AtomicReference<>(List.of());
void append(String line) {
log.updateAndGet(old -> {
var copy = new ArrayList<>(old);
copy.add(line);
return List.copyOf(copy); // immutable snapshot
});
}Ogni lettore ottiene una lista immutabile consistente. I writer gareggiano; il ciclo CAS fa riprovare i pochi che perdono. Economico sotto bassa contesa, lento ma corretto sotto alta.
LongAdder — il contatore ad alta contesa
Sotto intensa contesa, AtomicLong.incrementAndGet diventa un collo di bottiglia — ogni thread martella lo stesso indirizzo di memoria, e la CPU deve serializzare le transazioni del bus. LongAdder risolve questo problema mantenendo diversi contatori interni, uno per CPU, e sommandoli in lettura:
LongAdder requestCount = new LongAdder();
void onRequest() { requestCount.increment(); } // append-only, no contention
long snapshot() { return requestCount.sum(); } // sums every cell — not atomic but eventually consistentUsare LongAdder quando:
- Il contatore viene incrementato da molti thread in modo concorrente (ad esempio: metriche per richiesta in un web server).
- Lo si legge raramente (ogni pochi secondi per una dashboard).
Usare AtomicLong quando:
- L'incremento è raro o single-threaded.
- È necessaria una lettura precisa e istantanea.
LongAdder è uno dei contatori concorrenti più veloci in assoluto — ma il compromesso è che sum() non è atomico rispetto agli incrementi concorrenti. Per il caso tipico di reportistica delle metriche, va benissimo.
Cosa gli atomici non sono
Gli atomici si scalano a un singolo campo. Non si compongono su più campi:
AtomicInteger a = new AtomicInteger();
AtomicInteger b = new AtomicInteger();
a.incrementAndGet(); // atomic on its own
b.incrementAndGet(); // atomic on its own
// but the pair is NOT atomic — another thread can see new a, old bSe il vostro invariante si estende su più campi ("a == b + 1 sempre"), avete bisogno di un lock (o di un singolo atomico su un oggetto holder che li contiene entrambi).
Gli atomici non aiutano nemmeno con la visibilità di campi non correlati. Scrivere su un atomico non pubblica altri campi come fa volatile. Rendete quegli altri campi volatile (o final, o scriveteli tramite l'atomico).
compareAndExchange e la nuova API (Java 9+)
Java 9 ha aggiunto compareAndExchange (restituisce il valore corrente, non solo un boolean):
int prev = counter.compareAndExchange(expected, newVal);
if (prev == expected) { // we won
...
} else { // somebody else got there first
// prev is the actual current value
}Java 9 ha anche aggiunto l'API VarHandle che espone CAS debole, accesso ordinato, ecc., per librerie concorrenti di basso livello. Raramente ne avrete bisogno; lo menzioniamo qui perché abbiate visto il nome.
Un esempio pratico: contatore e snapshot
Il programma seguente mette a confronto quattro contatori: non sincronizzato, volatile, AtomicInteger e LongAdder. Tutti e quattro vengono colpiti da 8 thread che eseguono 100.000 incrementi ciascuno.
Cosa trarre dall'esecuzione:
plainevolatilehanno perso entrambi degli aggiornamenti — a volte in modo clamoroso (un conteggio finale ben al di sotto degli800.000attesi).volatilerisolve il problema della visibilità man++è comunque tre operazioni. Questa è la cosa più importante da ricordare suvolatile: non rende atomici gli aggiornamenti composti.AtomicIntegerha prodotto il conteggio esatto atteso, ad ogni esecuzione. Il costo per incremento è di pochi nanosecondi — significativamente più alto din++su unintsemplice (che è uno o due), ma senza acquisizioni di lock e senza blocking dei thread. Sotto contesa è più veloce disynchronizedcon ampio margine.LongAdderè stato il contatore più veloce sotto il carico con 8 thread — distribuisce le scritture su celle separate per CPU così i thread non contendono su un'unica cache line. Il compromesso è chesum()non è atomico rispetto aincrement()(un lettore può vedere un totale leggermente non aggiornato), che è esattamente il giusto compromesso per metriche e contatori dove la precisione istantanea non è importante.- Il max con ciclo CAS ha registrato il valore più grande visto tra tutti i campioni. Il ciclo è il pattern generale: leggi il valore corrente, calcola il nuovo valore desiderato, prova a scriverlo; se qualcun altro ha scritto per primo, il CAS fallisce e si riprova. La maggior parte delle chiamate
updateAndGeteaccumulateAndGetsono questo ciclo con il codice ripetitivo nascosto. - L'
AtomicReference<List<String>>ha prodotto uno snapshot immutabile del log. Ogni writer ha costruito una nuova copia immutabile e ha tentato di pubblicarla; sotto contesa, due writer potrebbero costruire entrambi una copia e il CAS di uno fallisce — quel thread riprova, legge la lista appena aggiornata e unisce. Il pattern è dispendioso sotto alta contesa (molte copie inutili) ma ideale per snapshot "read-heavy, ricostruiti occasionalmente".
Prossimi passi
Il prossimo capitolo, Java Locks, introduce la storia di java.util.concurrent.locks — l'interfaccia Lock, perché esiste accanto a synchronized, e le capacità (tryLock, lockInterruptibly, Condition) che aggiunge rispetto al monitor intrinseco.