W3docs

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:

  1. 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.
  2. Limiti di risorse. Un thread di piattaforma su una JVM a 64 bit occupa circa 1 MB di stack — 64 GB di RAM corrispondono a ~64.000 thread, 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.
  3. Parallelismo prevedibile. Un pool con N worker esegue esattamente N task 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:

  1. 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).
  2. 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.
  3. 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
);
ParametroCosa controlla
corePoolSizeNumero minimo di worker mantenuti attivi anche quando inattivi. I thread fino a questo numero non vengono terminati.
maximumPoolSizeLimite massimo sul totale dei worker. Il pool cresce oltre core solo quando la coda è piena.
keepAliveTimePer quanto tempo un worker inattivo oltre la dimensione core attende prima di terminare.
workQueueDove vivono i task in attesa. LinkedBlockingQueue (illimitata) vs ArrayBlockingQueue (limitata) vs SynchronousQueue (senza buffer) determina completamente il comportamento del pool.
threadFactoryCome vengono costruiti i thread worker. Usalo per impostare nomi, stato daemon, priorità, handler per eccezioni non gestite.
handlerCosa 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:

PolicyComportamento
AbortPolicy (default)Lancia RejectedExecutionException. Il chiamante sa che il task è stato scartato.
CallerRunsPolicyIl thread chiamante esegue il task stesso. Rallenta il chiamante, fornendo back-pressure.
DiscardPolicyScarta silenziosamente il task. Da usare solo per lavori di telemetria "best-effort".
DiscardOldestPolicyScarta 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 task

Due di questi hanno trappole ben note:

  • newFixedThreadPool usa una LinkedBlockingQueue illimitata. 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 è.
  • newCachedThreadPool ha maximum = 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.

java— editable, runs on the server

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 di CallerRunsPolicy ogni volta che la coda si riempiva.
  • Alcuni task riportavano il nome del thread main. È CallerRunsPolicy in azione: quando la coda era piena e tutti i worker erano occupati, pool.execute eseguiva 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 oltre core nemmeno sotto carico sostenuto perché la coda limitata aveva spazio per i picchi brevi. Con una coda illimitata (il default di Executors.newFixedThreadPool), la coda avrebbe accettato ogni task e largestPoolSize sarebbe 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.

Esercitati

Pratica
Chiami `Executors.newFixedThreadPool(8)` e sottometti task più velocemente di quanto il pool riesca a elaborarli. Il pool ha 8 thread. Qual è la modalità di fallimento patologica sotto carico sostenuto?
Chiami `Executors.newFixedThreadPool(8)` e sottometti task più velocemente di quanto il pool riesca a elaborarli. Il pool ha 8 thread. Qual è la modalità di fallimento patologica sotto carico sostenuto?
Was this page helpful?