Collezioni Concorrenti in Java
Collezioni thread-safe in java.util.concurrent — ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue — e quando usarle.
HashMap, ArrayList, ArrayDeque — queste sono le collezioni di uso quotidiano, e nessuna di esse è thread-safe. Usarle da più thread senza sincronizzazione esterna produce aggiornamenti persi, invarianti rotte e la temuta ConcurrentModificationException durante l'iterazione. La risposta tradizionale era Collections.synchronizedMap(...), che avvolge una mappa normale in un unico grande lock. Funziona, ma serializza ogni operazione.
Il pacchetto java.util.concurrent ha sostituito l'approccio "wrap-with-lock" con collezioni progettate per l'accesso concorrente fin dalle fondamenta: varianti con lock-striping, copy-on-write e lock-free, ottimizzate per diversi rapporti lettura/scrittura. Questo capitolo è la panoramica — cosa sa fare meglio ogni classe e le modalità di errore da conoscere.
ConcurrentHashMap — il cavallo di battaglia
La collezione concorrente più usata in Java. Una mappa con la forma di HashMap che puoi usare da più thread senza sincronizzazione esterna:
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.put("hits", 1);
counts.merge("hits", 1, Integer::sum); // atomic add-or-increment
counts.computeIfAbsent("misses", k -> 0);
counts.computeIfPresent("hits", (k, v) -> v + 1);Tre fattori la rendono veloce sotto contesa:
- Lock striping. Chiavi diverse sono protette da lock interni diversi, quindi le scritture su chiavi non correlate non si bloccano a vicenda.
- Letture lock-free. Le letture non acquisiscono alcun lock (nello stato stabile). Un reader può essere in gara con un writer; il risultato è il vecchio valore o il nuovo, mai uno corrotto.
- Aggiornamenti composti atomici.
merge,compute,computeIfAbsenteputIfAbsenteseguono il loro check-then-act atomicamente. Senza di loro, il pattern non sincronizzatoif (!map.containsKey(k)) map.put(k, v)ha una finestra di race tra le due chiamate; i metodi atomici la chiudono.
Usa ConcurrentHashMap ogni volta che una HashMap viene toccata da più di un thread. È il default corretto — più veloce di Hashtable, più veloce di synchronizedMap, e supporta aggiornamenti composti atomici che gli altri non hanno.
Una regola: le chiavi null e i valori null non sono ammessi. containsKey(k) è affidabile; map.get(k) == null è ambiguo (chiave assente vs. valore null). Vietare i null rimuove l'ambiguità.
ConcurrentSkipListMap — mappa concorrente ordinata
Quando hai bisogno di una mappa con la forma di TreeMap (ordinata per chiave) e di accesso concorrente:
ConcurrentSkipListMap<Long, Event> byTimestamp = new ConcurrentSkipListMap<>();
byTimestamp.put(1700000000000L, e1);
byTimestamp.put(1700000005000L, e2);
byTimestamp.firstEntry(); // earliest
byTimestamp.lastEntry(); // latest
byTimestamp.subMap(start, end); // range querySupportata da una skip list (un'alternativa probabilistica a un albero bilanciato, più semplice da rendere lock-free). Supporta l'intera API NavigableMap. Più lenta di ConcurrentHashMap per la semplice ricerca per chiave; la scelta giusta quando hai bisogno di iterazione ordinata o query per intervallo.
CopyOnWriteArrayList — lista piccola con molte letture
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(myListener);
for (Listener l : listeners) l.onEvent(e); // never throws ConcurrentModificationExceptionOgni scrittura copia l'array sottostante. Le letture sono wait-free — nessun lock, nessuna sincronizzazione, nessuna CME. Il compromesso è evidente: ogni add/remove/set è O(n) perché copia l'intero array.
È un compromesso pessimo per i workload con molte scritture. È un compromesso perfetto per il workload per cui è stata progettata:
- Una lista piccola (decine, forse centinaia, di elementi).
- Le letture superano di gran lunga le scritture.
- L'iterazione è frequente; non deve mai lanciare
CME.
Il caso d'uso classico: una lista di event listener, voci di configurazione o sottoscrittori registrati. Le letture avvengono ad ogni evento; le scritture avvengono all'avvio o quando un componente si registra.
Non usare CopyOnWriteArrayList per "qualsiasi cosa che potrei mettere in un ArrayList." Per collezioni condivise mutabili che non sono piccole e prevalentemente in lettura, usa Collections.synchronizedList attorno a un ArrayList, oppure ripensa la struttura dati.
BlockingQueue — la coda produttore/consumatore
L'astrazione più utile in java.util.concurrent:
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1024);
queue.put(task); // blocks if full
queue.offer(task, 100, TimeUnit.MILLISECONDS); // blocks up to deadline
queue.add(task); // throws if full
Task t = queue.take(); // blocks if empty
Task t2 = queue.poll(100, TimeUnit.MILLISECONDS); // blocks up to deadline
Task t3 = queue.poll(); // returns null if emptyput e take sono le operazioni bloccanti: aspettano finché la coda non è piena / non è vuota. Questo è l'intero pilastro del framework executor — ogni ThreadPoolExecutor internamente detiene una BlockingQueue di task in attesa; i worker la consumano con take; il metodo pubblico execute vi inserisce con put.
Implementazioni comuni:
| Classe | Limitata? | Quando usarla |
|---|---|---|
ArrayBlockingQueue(cap) | Sì — capacità fissa | Buffer di dimensione fissa; back-pressure sul produttore |
LinkedBlockingQueue() | No (o limitata) | Coda general-purpose ad alto throughput |
SynchronousQueue | 0 — handoff diretto | Ogni put aspetta un take; handoff tra thread |
PriorityBlockingQueue | No | Task ordinati per priorità (non per inserimento) |
DelayQueue | No | Ogni elemento ha un ritardo; viene prelevato solo alla scadenza |
ArrayBlockingQueue è il default in produzione — limita il lavoro in volo, essenziale per il back-pressure. LinkedBlockingQueue senza limite è la trappola dietro Executors.newFixedThreadPool (coda illimitata → memoria illimitata).
ConcurrentLinkedQueue e ConcurrentLinkedDeque — le varianti lock-free illimitate
ConcurrentLinkedQueue<Event> events = new ConcurrentLinkedQueue<>();
events.add(e);
Event e = events.poll(); // null if empty; doesn't blockNon bloccanti, lock-free, illimitate. poll restituisce null invece di bloccarsi; non esiste take. Ideali quando:
- Vuoi alto throughput.
- Puoi tollerare che "coda vuota" sia un ritorno rapido piuttosto che un'attesa.
- Non hai bisogno di back-pressure.
Queste non sono BlockingQueue — sceglile quando non vuoi davvero la semantica bloccante.
Iterazione: consistenza debole
Un iteratore di HashMap lancia ConcurrentModificationException se la mappa cambia durante l'iterazione. Le collezioni concorrenti si comportano diversamente: i loro iteratori sono weakly consistent. Questo significa:
- Non lanciano
ConcurrentModificationExceptionanche se altri thread modificano la collezione. - Hanno la garanzia di vedere ogni elemento presente al momento della creazione dell'iteratore.
- Possono o meno riflettere le modifiche effettuate dopo la creazione dell'iteratore.
Va bene per la maggior parte degli usi — un iteratore snapshot è esattamente ciò che il codice concorrente vuole. Il compromesso: anche size() è "weakly consistent" — per ConcurrentHashMap è un conteggio approssimativo, non un valore snapshot garantito. Se tratti size() come autorevole, stai usando l'API in modo errato.
Quando scegliere cosa
Un albero decisionale approssimativo:
- Mappa chiave-valore →
ConcurrentHashMap(default),ConcurrentSkipListMap(serve ordinamento/intervallo). - Lista di listener prevalentemente in lettura →
CopyOnWriteArrayList. - Coda task produttore–consumatore →
ArrayBlockingQueue(limitata),LinkedBlockingQueue(senza limite),SynchronousQueue(handoff diretto). - Coda con priorità tra thread →
PriorityBlockingQueue. - Coda con ritardo per esecuzione differita →
DelayQueue. - Lock-free non bloccante ad alto throughput →
ConcurrentLinkedQueue/ConcurrentLinkedDeque. - Set →
ConcurrentHashMap.newKeySet(),CopyOnWriteArraySet,ConcurrentSkipListSet.
Ogni volta che una collezione normale viene toccata da più di un thread, scegli una collezione concorrente o avvolgila con Collections.synchronizedX — non sperare semplicemente che funzioni.
Esempio pratico: ogni collezione al suo posto
Il programma seguente dimostra quattro collezioni concorrenti sotto un workload condiviso — una ConcurrentHashMap che conta gli eventi, una CopyOnWriteArrayList di listener, un ArrayBlockingQueue per produttore/consumatore e una ConcurrentLinkedQueue per l'append lock-free.
Cosa ricavare dall'esecuzione:
- Il
ConcurrentHashMap.mergedella sezione 1 ha prodotto il conteggio esatto atteso di40.000. La funzione merge (Integer::sum) è stata eseguita atomicamente per chiave, quindi due thread che incrementano la stessa chiave non hanno mai perso un aggiornamento — l'aggiornamento composto atomico è il punto centrale. Con unaHashMapnormale eput, si otterrebbe una frazione del valore atteso e probabilmente anche uno stato interno corrotto. - L'iteratore
CopyOnWriteArrayListdella sezione 2 ha visto[a, b, c](lo snapshot al momento della creazione dell'iteratore). Le scritture che hanno aggiuntodeedurante l'iterazione non hanno lanciatoConcurrentModificationExceptione non sono state viste dall'iteratore in corso. La lista finale conteneva tutti e cinque — le scritture sono avvenute, erano solo invisibili all'iteratore già avviato. - L'
ArrayBlockingQueuecon capacità 4 della sezione 3 ha costretto il produttore a bloccarsi suputogni volta che la coda era piena. L'output mostrava la coda che si riempiva fino a 4, poi il produttore in pausa mentre il consumatore svuotava, poi il produttore che riprendeva. Questo è il back-pressure realizzato dalla struttura dati: il produttore non può andare più veloce del consumatore, senza alcun codice di coordinamento. - La
ConcurrentLinkedQueuedella sezione 4 ha accettato scritture da quattro thread senza blocchi e senza contesa di lock. Il conteggio finale drenato corrispondeva esattamente al conteggio inserito — ogni elemento scritto è stato letto con successo. Il costo: nessuntake()per aspettare su una coda vuota;poll()restituiscenulle devi gestirlo tu stesso. - Per tutto il tempo, le collezioni concorrenti non hanno mai lanciato
ConcurrentModificationException. Quella eccezione è una caratteristica delle collezioni non concorrenti — è il modo della JVM di dire "hai rotto questo." Le collezioni concorrenti sono progettate per essere modificate da più thread, quindi non hanno bisogno di quel segnale.
Cosa c'è dopo
Il prossimo capitolo, Java Virtual Threads, tratta la funzionalità di Java 21 che cambia il modo in cui pensi al numero di thread — thread leggeri schedulati dalla JVM che rendono nuovamente economico l'I/O bloccante.