W3docs

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:

  1. Lock striping. Chiavi diverse sono protette da lock interni diversi, quindi le scritture su chiavi non correlate non si bloccano a vicenda.
  2. 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.
  3. Aggiornamenti composti atomici. merge, compute, computeIfAbsent e putIfAbsent eseguono il loro check-then-act atomicamente. Senza di loro, il pattern non sincronizzato if (!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 query

Supportata 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 ConcurrentModificationException

Ogni 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 empty

put 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:

ClasseLimitata?Quando usarla
ArrayBlockingQueue(cap)Sì — capacità fissaBuffer di dimensione fissa; back-pressure sul produttore
LinkedBlockingQueue()No (o limitata)Coda general-purpose ad alto throughput
SynchronousQueue0 — handoff direttoOgni put aspetta un take; handoff tra thread
PriorityBlockingQueueNoTask ordinati per priorità (non per inserimento)
DelayQueueNoOgni 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 block

Non 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 ConcurrentModificationException anche 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-valoreConcurrentHashMap (default), ConcurrentSkipListMap (serve ordinamento/intervallo).
  • Lista di listener prevalentemente in letturaCopyOnWriteArrayList.
  • Coda task produttore–consumatoreArrayBlockingQueue (limitata), LinkedBlockingQueue (senza limite), SynchronousQueue (handoff diretto).
  • Coda con priorità tra threadPriorityBlockingQueue.
  • Coda con ritardo per esecuzione differitaDelayQueue.
  • Lock-free non bloccante ad alto throughputConcurrentLinkedQueue / ConcurrentLinkedDeque.
  • SetConcurrentHashMap.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.

java— editable, runs on the server

Cosa ricavare dall'esecuzione:

  • Il ConcurrentHashMap.merge della sezione 1 ha prodotto il conteggio esatto atteso di 40.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 una HashMap normale e put, si otterrebbe una frazione del valore atteso e probabilmente anche uno stato interno corrotto.
  • L'iteratore CopyOnWriteArrayList della sezione 2 ha visto [a, b, c] (lo snapshot al momento della creazione dell'iteratore). Le scritture che hanno aggiunto d e e durante l'iterazione non hanno lanciato ConcurrentModificationException e 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'ArrayBlockingQueue con capacità 4 della sezione 3 ha costretto il produttore a bloccarsi su put ogni 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 ConcurrentLinkedQueue della 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: nessun take() per aspettare su una coda vuota; poll() restituisce null e 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.

Esercizi

Pratica
Hai bisogno di una `Map` thread-safe modificata da molti thread che supporti l'aggiornamento atomico 'incrementa un contatore per una chiave.' Qual è la scelta giusta?
Hai bisogno di una `Map` thread-safe modificata da molti thread che supporti l'aggiornamento atomico 'incrementa un contatore per una chiave.' Qual è la scelta giusta?
Was this page helpful?