Thread Pool in Java
Riutilizza i thread per eseguire molti task in modo efficiente con i thread pool Java e i parametri di configurazione di ThreadPoolExecutor.
Creare un thread è costoso. Ogni new Thread() alloca circa 1 MB di stack nativo, chiede al sistema operativo di pianificare un nuovo thread kernel e aggiunge carico al GC. Un programma che assegna un thread per ogni task va bene per dieci task; crolla a diecimila. La soluzione è un thread pool — un piccolo insieme di thread worker longevi che prelevano task da una coda. Il pool gestisce i thread; tu gestisci i task.
Questo capitolo è quello concettuale — cosa sia un pool, i parametri che lo configurano e le modalità di fallimento. Il capitolo successivo, Executor framework, introduce i tipi Executor/ExecutorService che si usano per interagire con un pool. I due sono intrecciati; questo capitolo si concentra sul cosa e sul perché, il successivo sul come.
Perché usare un pool?
Tre problemi che un pool risolve:
- Costo di creazione dei thread. Allocare uno stack nativo e richiedere un nuovo thread al sistema operativo richiede dell'ordine dei millisecondi. Riutilizzare thread esistenti richiede microsecondi. Su larga scala, la differenza è quella tra un server che regge il carico e uno che cede.
- Limiti di risorse. Un thread di piattaforma su una JVM a 64 bit occupa circa 1 MB di stack —
64 GBdi RAM corrispondono a~64.000thread, e il sistema operativo ha un proprio overhead per thread. Una creazione illimitata di thread equivale a un consumo illimitato di RAM. Un pool pone un limite al numero. - Parallelismo prevedibile. Un pool con
Nworker esegue esattamenteNtask in parallelo. Questo si adatta molto meglio a "usa tutti i 16 core" rispetto a "crea un thread per ogni richiesta e spera."
Il costo del pooling: devi dimensionarlo. Troppo piccolo → i task si accodano e la latenza cresce. Troppo grande → il context switching domina e il throughput cala. Il capitolo sul dimensionamento (executor framework) copre le regole empiriche; questo capitolo riguarda cosa sia il pool.
L'anatomia di un pool
Un thread pool è essenzialmente tre cose:
- Un insieme limitato di thread worker. I worker eseguono un ciclo: prelevano un task dalla coda, lo eseguono, prendono il successivo, ripetono. Vivono per tutta la durata del pool (o finché non rimangono inattivi troppo a lungo, a seconda della policy).
- Una coda di task. Quando sottometti lavoro e nessun worker è libero, il task va qui. Il tipo di coda —
LinkedBlockingQueue,ArrayBlockingQueue,SynchronousQueue— determina come il pool cresce sotto carico. - Un'API di sottomissione.
execute(Runnable),submit(Callable),invokeAll(...)— i modi per inserire lavoro nel pool.
In Java, tutto ciò è racchiuso in java.util.concurrent.ThreadPoolExecutor, che è la classe sottostante di quasi ogni pool che incontrerai.
I sette parametri di ThreadPoolExecutor
Costruzione diretta (che raramente si fa, ma i parametri sono quelli che ogni factory passa internamente):
new ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
);| Parametro | Cosa controlla |
|---|---|
corePoolSize | Numero minimo di worker mantenuti attivi anche quando inattivi. I thread fino a questo numero non vengono terminati. |
maximumPoolSize | Limite massimo sul totale dei worker. Il pool cresce oltre core solo quando la coda è piena. |
keepAliveTime | Per quanto tempo un worker inattivo oltre la dimensione core attende prima di terminare. |
workQueue | Dove vivono i task in attesa. LinkedBlockingQueue (illimitata) vs ArrayBlockingQueue (limitata) vs SynchronousQueue (senza buffer) determina completamente il comportamento del pool. |
threadFactory | Come vengono costruiti i thread worker. Usalo per impostare nomi, stato daemon, priorità, handler per eccezioni non gestite. |
handler | Cosa accade quando sia i worker sia la coda sono saturi. Default: AbortPolicy. |
L'interazione non ovvia: il pool preferisce riempire la coda prima di creare nuovi thread oltre core. Quindi una coda illimitata significa che il pool non cresce mai oltre core — accumula semplicemente task all'infinito. Una coda limitata (o SynchronousQueue) è ciò che rende significativo il parametro max.
Le quattro policy di rifiuto
Quando submit non può accettare un task (coda piena, tutti i worker al massimo occupati), il RejectedExecutionHandler decide cosa accade:
| Policy | Comportamento |
|---|---|
AbortPolicy (default) | Lancia RejectedExecutionException. Il chiamante sa che il task è stato scartato. |
CallerRunsPolicy | Il thread chiamante esegue il task stesso. Rallenta il chiamante, fornendo back-pressure. |
DiscardPolicy | Scarta silenziosamente il task. Da usare solo per lavori di telemetria "best-effort". |
DiscardOldestPolicy | Scarta il task più vecchio in coda e sottomette il nuovo. Utile quando "conta solo il più recente". |
Il lancio dell'eccezione di default è di solito la scelta sicura. CallerRunsPolicy è un elegante meccanismo di back-pressure — quando il pool è sovraccarico, il sottomettitore viene rallentato per adeguarsi, limitando naturalmente la frequenza della sorgente.
I metodi factory di Executors — e perché dovresti evitarli per lo più
java.util.concurrent.Executors offre factory di convenienza:
Executors.newFixedThreadPool(n); // core = max = n, unbounded LinkedBlockingQueue
Executors.newCachedThreadPool(); // core = 0, max = Integer.MAX_VALUE, SynchronousQueue, 60s keep-alive
Executors.newSingleThreadExecutor(); // fixed pool with one thread
Executors.newScheduledThreadPool(n); // for delay/repeat scheduling
Executors.newVirtualThreadPerTaskExecutor(); // Java 21+: one virtual thread per taskDue di questi hanno trappole ben note:
newFixedThreadPoolusa unaLinkedBlockingQueueillimitata. Sotto un carico sostenuto, la coda cresce senza limite — alla fine OOM. La dimensione del pool è fissa; il lavoro che si accumula dietro di esso non lo è.newCachedThreadPoolhamaximum = Integer.MAX_VALUE. Sotto un picco sostenuto di lavoro, crea thread senza limite — alla fine esaurisce il limite di thread per processo del sistema operativo e manda in crash la JVM.
Questi vanno bene per piccoli lavori, demo e script occasionali. Per il codice di produzione, costruisci un ThreadPoolExecutor direttamente con una coda limitata, un max ragionevole e una policy di rifiuto esplicita.
L'eccezione: newVirtualThreadPerTaskExecutor (Java 21+) distribuisce thread virtuali, abbastanza economici da rendere "uno per task" effettivamente funzionante. Trattiamo questo nel capitolo sui thread virtuali.
Ciclo di vita: shutdown vs shutdownNow
Un pool continua a funzionare finché non gli dici di fermarsi. Le due modalità di arresto:
pool.shutdown(); // stop accepting new work; let queued tasks finish
pool.shutdownNow(); // stop accepting; interrupt running threads; return queued tasks
boolean terminated = pool.awaitTermination(10, TimeUnit.SECONDS);shutdown è la versione educata: non vengono accettate nuove sottomissioni, il lavoro esistente viene completato, poi il pool termina. shutdownNow è la versione brusca: interrompe i worker, restituisce la coda pendente. Usa shutdown per un'uscita pulita; usa shutdownNow dopo un shutdown + scadenza di awaitTermination se il lavoro non è terminato.
Il pattern di shutdown combinato dalla documentazione JDK:
pool.shutdown();
try {
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}Vorrai quasi sempre esattamente questa forma in qualsiasi codice che possiede un pool. Senza shutdown, la JVM mantiene i worker attivi (non-daemon per default) e non termina.
Nominare i worker con ThreadFactory
Il Executors.defaultThreadFactory() di default nomina i thread pool-1-thread-1, pool-1-thread-2, ecc. È un piccolo passo avanti rispetto a Thread-7, ma non è ancora ottimale. Il codice di produzione usa una factory con nome:
ThreadFactory factory = r -> {
Thread t = new Thread(r, "image-worker-" + COUNTER.incrementAndGet());
t.setDaemon(false);
t.setUncaughtExceptionHandler((thr, ex) -> log.error("uncaught in " + thr.getName(), ex));
return t;
};
ExecutorService pool = new ThreadPoolExecutor(
4, 4, 0, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
factory,
new ThreadPoolExecutor.CallerRunsPolicy());La factory è la tua occasione per impostare ogni proprietà per thread: nome, flag daemon, priorità, handler per eccezioni non gestite, thread-group. In un heap dump con 200 thread, un thread chiamato image-worker-7 è un thread che puoi trovare.
Un esempio pratico: costruire un pool limitato con back-pressure
Il programma seguente costruisce un ThreadPoolExecutor con 4 worker, una coda limitata di 8 e il rejection handler CallerRunsPolicy — così il sottomettitore viene rallentato quando il pool è sovraccarico invece di lanciare un'eccezione.
Cosa ricavare dall'esecuzione:
- Il pool aveva un limite rigoroso di 4 thread worker. Con 40 task da 50 ms ciascuno, il tempo idealizzato seriale-per-pool è
40 * 50 / 4 = 500 ms. Il wall-clock effettivo era vicino a quello — meno il costo del rallentamento del sottomettitore da parte diCallerRunsPolicyogni volta che la coda si riempiva. - Alcuni task riportavano il nome del thread
main. ÈCallerRunsPolicyin azione: quando la coda era piena e tutti i worker erano occupati,pool.executeeseguiva il task sul thread chiamante invece di accodarlo o lanciare un'eccezione. Il sottomettitore rallentava; il sistema rimaneva limitato. È il back-pressure fatto bene. pool.getLargestPoolSize()era 4 — il massimo rimaneva uguale al core. Il pool non è cresciuto oltrecorenemmeno sotto carico sostenuto perché la coda limitata aveva spazio per i picchi brevi. Con una coda illimitata (il default diExecutors.newFixedThreadPool), la coda avrebbe accettato ogni task elargestPoolSizesarebbe rimasto a 4 — ma la memoria sarebbe aumentata mentre i task si accumulavano.- La sequenza di shutdown è il pattern di produzione.
shutdown()ha detto al pool di smettere di accettare nuove sottomissioni;awaitTermination(5, SECONDS)ha atteso fino a 5 secondi per il lavoro in corso; se il lavoro non fosse terminato,shutdownNow()avrebbe interrotto i worker rimanenti. Senza queste chiamate, la JVM non termina — i worker non-daemon la mantengono in vita. - La thread factory ha dato a ogni worker un nome significativo (
worker-1...worker-4) e un handler per eccezioni non gestite. In un thread dump di produzione o in un profiler, quei nomi sono la differenza tra "so quale sottosistema è questo" e "non ho idea." Impostali su ogni pool che crei.
Cosa c'è dopo
Il capitolo successivo, Java Executor Framework, introduce la gerarchia di tipi che si usa per interagire con i thread pool — Executor, ExecutorService, ScheduledExecutorService — e come dimensionare un pool per workload CPU-bound e I/O-bound.