CodeGym /Corsi /JAVA 25 SELF /Collezioni thread-safe: ConcurrentHashMap e altre

Collezioni thread-safe: ConcurrentHashMap e altre

JAVA 25 SELF
Livello 53, Lezione 2
Disponibile

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
ConcurrentHashMap
Map, cache, accessi frequenti Prestazioni elevate, nessun lock globale
CopyOnWriteArrayList
List, cambia raramente, letture frequenti Letture veloci, modifiche lente
CopyOnWriteArraySet
Set, cambia raramente, letture frequenti Analogo alla lista Copy-On-Write
ConcurrentLinkedQueue
Coda, FIFO Veloce, non bloccante, code di task
ConcurrentSkipListMap
Map ordinata (NavigableMap) Analogo thread-safe di TreeMap
ConcurrentSkipListSet
Set ordinato Analogo thread-safe di TreeSet
BlockingQueue
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.

1
Compito
JAVA 25 SELF, livello 53, lezione 2
Bloccato
Magazzino di un negozio online: Creare ConcurrentHashMap 📦
Magazzino di un negozio online: Creare ConcurrentHashMap 📦
1
Compito
JAVA 25 SELF, livello 53, lezione 2
Bloccato
Elenco dei giocatori attivi: iterazione su CopyOnWriteArrayList 🎮
Elenco dei giocatori attivi: iterazione su CopyOnWriteArrayList 🎮
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION