1. Perché le collezioni normali non sono adatte al multithreading
Ricordiamo come lavoravamo con le collezioni nella nostra applicazione principale (ad esempio, una chat room):
List<String> messages = new ArrayList<>();
messages.add("Ciao!");
messages.add("Come va?");
In un programma monothread va tutto bene. Ma se più thread aggiungono, rimuovono o leggono elementi dalla stessa collezione contemporaneamente — benvenuti nel mondo delle race condition (race conditions), degli stati inconsistenti e dei bug misteriosi.
Per esempio, un thread aggiunge un elemento, un altro lo rimuove, un terzo itera — e all’improvviso otteniamo ConcurrentModificationException, a volte persino ArrayIndexOutOfBoundsException o semplicemente una collezione «danneggiata».
Classico:
List<String> list = new ArrayList<>();
Runnable writer = () -> {
for (int i = 0; i < 1000; i++) {
list.add("msg-" + i);
}
};
Runnable reader = () -> {
for (String msg : list) {
// ...
}
};
// Avviamo writer e reader in thread diversi — otterrete bug!
Conclusione: Le collezioni normali (ArrayList, HashMap, HashSet e altre) NON sono thread-safe. Non si possono usare da più thread senza sincronizzazione aggiuntiva (synchronized, lock, ecc.).
2. Quali collezioni thread-safe esistono in Java
Java non vi lascia allo sbaraglio. Per i compiti multithread nel pacchetto java.util.concurrent c’è un intero insieme di collezioni (perdonate il gioco di parole) che si possono usare in sicurezza da più thread.
Principali collezioni thread-safe:
| Collezione | Dove usarla | Caratteristiche |
|---|---|---|
|
Map, cache, accessi frequenti | Prestazioni elevate, nessun lock globale |
|
List, cambia raramente, letture frequenti | Letture veloci, modifiche lente |
|
Set, cambia raramente, letture frequenti | Analogo alla lista Copy-On-Write |
|
Coda, FIFO | Veloce, non bloccante, code di task |
|
Map ordinata (NavigableMap) | Analogo thread-safe di TreeMap |
|
Set ordinato | Analogo thread-safe di TreeSet |
|
Code con blocco (thread pool) | Interfaccia, molte implementazioni |
Importante! Il buon vecchio Collections.synchronizedList(list) e simili non sono esattamente la stessa cosa delle moderne collezioni di java.util.concurrent. Dettagli — più avanti.
3. ConcurrentHashMap: il tuo amico nel mondo del multithreading
ConcurrentHashMap<K, V> è, essenzialmente, lo stesso HashMap, ma potenziato per il multithreading. Consente a più thread di leggere e scrivere in sicurezza in parallelo senza bloccare l’intera mappa.
In un HashMap normale, se si vuole rendere l’accesso thread-safe, bisogna mettere un lock sull’intera struttura — e si trasforma subito in un «collo di bottiglia»: finché un thread lavora, gli altri aspettano.
ConcurrentHashMap risolve il problema in modo più intelligente. Nelle versioni precedenti la mappa era suddivisa in segmenti con lock separati; nelle implementazioni più recenti si usano operazioni atomiche leggere (CAS) a livello dei singoli bucket. Grazie a ciò i thread possono lavorare in parallelo finché non toccano gli stessi dati.
Esempio di utilizzo di ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
public class ChatStats {
private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();
public void increment(String user) {
// Incrementiamo il valore in modo atomico
userMessageCount.merge(user, 1, Integer::sum);
}
public int getCount(String user) {
return userMessageCount.getOrDefault(user, 0);
}
}
Cosa è importante qui:
- È possibile chiamare i metodi da thread diversi: tutto funzionerà correttamente.
- Il metodo merge è atomico: se più thread incrementano il contatore contemporaneamente, il risultato sarà corretto.
- Per la lettura non serve sincronizzazione aggiuntiva.
In cosa ConcurrentHashMap è migliore di synchronizedMap?
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
Quando si usa synchronizedMap, qualunque operazione — lettura, scrittura o rimozione — blocca l’intera mappa. Mentre un thread lavora sui dati, gli altri sono costretti ad aspettare il proprio turno.
ConcurrentHashMap è progettata in modo molto più elegante: permette a più thread di leggere e persino modificare i dati contemporaneamente, a patto che non accedano alle stesse aree della mappa (bucket). Di conseguenza, in sistemi realmente multithread mostra prestazioni decisamente migliori — talvolta la differenza arriva a decine di volte.
4. CopyOnWriteArrayList e CopyOnWriteArraySet
CopyOnWriteArrayList e CopyOnWriteArraySet sono collezioni speciali che, a ogni modifica (per esempio, alla chiamata di add() o remove()), creano una nuova copia dell’intero array. In compenso, la lettura avviene senza alcuna sincronizzazione ed è completamente sicura per i thread.
Immaginate di avere un elenco di ospiti a una festa. Ogni volta che qualcuno arriva o se ne va, riscrivete l’elenco da capo e distribuite a tutti copie aggiornate. Un po’ dispendioso, ma così nessuno si confonde su chi è presente.
Quando è davvero comodo
- Le letture sono frequenti e le modifiche rare.
- Il caso classico è la lista dei listener di eventi: gli handler vengono aggiunti di rado, ma gli eventi arrivano continuamente.
Esempio: listener della chat
import java.util.concurrent.CopyOnWriteArrayList;
public class ChatRoom {
private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();
public void addListener(ChatListener listener) {
listeners.add(listener);
}
public void removeListener(ChatListener listener) {
listeners.remove(listener);
}
public void sendMessage(String message) {
// Sicuro per il multithreading anche se qualcuno si iscrive/si disiscrive proprio ora
for (ChatListener listener : listeners) {
listener.onMessage(message);
}
}
}
Caratteristiche importanti:
- L’iterazione su CopyOnWriteArrayList non genererà mai ConcurrentModificationException.
- Le modifiche (add/remove) sono costose in tempo e memoria (viene copiato l’intero array!).
- Sconsigliato per collezioni grandi con modifiche frequenti.
5. Altre collezioni thread-safe
ConcurrentLinkedQueue
ConcurrentLinkedQueue è una coda non bloccante che segue il principio FIFO. Consente a più thread di aggiungere e prelevare elementi in sicurezza senza usare lock espliciti. Spesso è usata per passare task tra thread — velocemente e senza «colli di bottiglia».
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // restituisce null se la coda è vuota
ConcurrentSkipListMap e ConcurrentSkipListSet
- Analoghi thread-safe di TreeMap e TreeSet.
- Gli elementi sono sempre ordinati.
- Si usano quando è importante mantenere l’ordine delle chiavi.
import java.util.concurrent.ConcurrentSkipListMap;
ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b
BlockingQueue e le sue implementazioni
- Interfaccia di coda che supporta operazioni di blocco (attendere finché compare/spazio disponibile).
- Implementazioni: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue e altre.
- Usate nei thread pool, per il pattern «producer-consumer».
import java.util.concurrent.ArrayBlockingQueue;
ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // Blocca se la coda è piena
String t = blockingQueue.take(); // Blocca se la coda è vuota
6. Esempi: operazioni sicure con le collezioni
Esempio 1: Map thread-safe per contare i messaggi
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();
// Thread 1
messageCount.put("Anna", 1);
// Thread 2
messageCount.put("Anna", messageCount.getOrDefault("Anna", 0) + 1); // Non atomico!
// Corretto (atomico):
messageCount.merge("Anna", 1, Integer::sum);
Esempio 2: Iterazione su CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArrayList;
CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Anton");
users.add("Maria");
for (String user : users) {
System.out.println(user);
users.remove(user); // Non genererà ConcurrentModificationException!
}
System.out.println(users); // []
Esempio 3: Coda di task tra thread
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
// Thread produttore
queue.add("task-1");
// Thread consumatore
String task = queue.poll(); // null se vuota
7. Dettagli utili
Quando (e perché) usare le collezioni thread-safe
L’uso di collezioni thread-safe è giustificato se:
- La stessa collezione è condivisa tra più thread.
- Non si vuole sincronizzare manualmente ogni operazione.
- È importante evitare race condition ed errori di consistenza.
Casi tipici:
- Cache in un sistema multithread (per esempio, ConcurrentHashMap per memorizzare le sessioni utente).
- Code di task tra thread (ConcurrentLinkedQueue, BlockingQueue).
- Liste di listener di eventi (CopyOnWriteArrayList).
- Elaborazione dati multithread (ad es., stile MapReduce).
Limitazioni e insidie
- Le operazioni su più elementi non sono atomiche. La costruzione if (!map.containsKey(k)) map.put(k, v) non è atomica. Usate putIfAbsent, computeIfAbsent, merge.
- CopyOnWriteArrayList è inefficiente con modifiche frequenti. Con grandi dimensioni e frequenti add/remove gli overhead crescono rapidamente.
- L’iterazione su ConcurrentHashMap è «debole». L’iterazione fornisce uno snapshot debolmente consistente: alcune modifiche parallele possono non essere visibili.
- Le collezioni thread-safe non risolvono tutti i problemi di sincronizzazione. Se la logica coinvolge più collezioni/variabili insieme, servirà sincronizzazione esterna (synchronized, lock, classi atomiche).
8. Errori comuni nell’uso delle collezioni thread-safe
Errore n. 1: Aspettarsi magia dalle collezioni thread-safe. «Se una collezione è thread-safe, posso fare qualsiasi cosa senza pensare alla sincronizzazione». Purtroppo, sequenze di più operazioni (controllo + inserimento) non sono atomiche. Usate i metodi specializzati: putIfAbsent, compute, merge.
Errore n. 2: Usare CopyOnWriteArrayList per collezioni grandi e spesso modificate. Va bene per le liste di listener, ma con 10 000+ elementi e modifiche frequenti avrete costi elevati di memoria e tempo.
Errore n. 3: ConcurrentModificationException usando collezioni normali. State iterando su ArrayList o HashMap mentre un altro thread modifica la collezione — beccatevi una ConcurrentModificationException. Usate collezioni specializzate o bloccate l’accesso manualmente.
Errore n. 4: Dimenticare l’atomicità delle operazioni complesse. Se serve modificare più collezioni contemporaneamente o eseguire una serie di azioni correlate, le collezioni thread-safe non bastano. Applicate sincronizzazione esterna o logiche transazionali.
Errore n. 5: Errori durante l’iterazione su ConcurrentHashMap. L’iterazione è debolmente consistente: non si può usare l’iterator come «snapshot» consistente dello stato della mappa. Per uno snapshot consistente, copiate i dati in una struttura separata.
GO TO FULL VERSION