Java Executor Framework
Invia task ai thread pool con Executor ed ExecutorService: gerarchia dei tipi, factory e regole di dimensionamento.
Il capitolo precedente descriveva cos'è un thread pool. Questo capitolo riguarda la gerarchia di tipi che si usa per interagire con esso — le interfacce Executor, ExecutorService e ScheduledExecutorService. Insieme vengono chiamate executor framework, introdotto in Java 5 per disaccoppiare "il lavoro" dai "thread che lo eseguono." Scrivi Callable<Result> e Runnable; li sottoponi; il framework gestisce l'allocazione dei thread, la coda e il trasferimento dei risultati.
La gerarchia a tre livelli
Executor // execute(Runnable)
|
ExecutorService // + submit/invokeAll/invokeAny/shutdown/awaitTermination
|
ScheduledExecutorService // + schedule/scheduleAtFixedRate/scheduleWithFixedDelaySi programma usando l'interfaccia più generale che soddisfa i propri requisiti:
Executor— la base con un solo metodo. Usala quando hai bisogno solo di avviare e dimenticare. Un parametro di metodo tipizzato comeExecutorè il contratto più generale "dammi qualcosa che possa eseguire unRunnable".ExecutorService— il fulcro. Quasi tutto il codice di produzione usa questo tipo. Aggiungesubmit(con un risultatoFuture), operazioni bulk e ciclo di vita.ScheduledExecutorService— quando hai bisogno di esecuzioni ritardate o ripetute.
Executor.execute — avvia e dimentica
public interface Executor {
void execute(Runnable command);
}Questa è l'intera interfaccia. execute riceve un Runnable, lo esegue in futuro, non restituisce nulla. Se il lavoro lancia un'eccezione, non lo saprai — l'eccezione va all'handler per eccezioni non gestite del thread worker.
execute è la chiamata giusta quando:
- Il lavoro non ha un valore di ritorno.
- Non è necessario aspettarlo o ottenerne il risultato.
- Non è necessario annullarlo.
Per qualcosa di più ricco, usa submit.
ExecutorService.submit, la versione avanzata
public interface ExecutorService extends Executor {
<T> Future<T> submit(Callable<T> task);
Future<?> submit(Runnable task);
<T> Future<T> submit(Runnable task, T result);
// ... lifecycle, bulk ops
}submit restituisce un Future, che consente di:
- Attendere il completamento (
get()blocca). - Leggere il risultato (
get()restituisce il valore delCallable). - Annullare il task (
cancel(boolean mayInterrupt)). - Catturare l'eccezione del task (
get()la rilancia).
Future e Callable vengono trattati in dettaglio nel prossimo capitolo; per ora, il contrasto con execute è il punto chiave. execute è unidirezionale; submit apre un canale di ritorno.
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Integer> result = pool.submit(() -> {
// Callable<Integer>; can throw, returns a value
return expensive();
});
Integer value = result.get(); // waits, throws ExecutionException if task failedOperazioni bulk: invokeAll e invokeAny
Quando hai una collezione di task:
List<Callable<Integer>> tasks = makeTasks();
List<Future<Integer>> futures = pool.invokeAll(tasks); // run all, wait for all
Integer first = pool.invokeAny(tasks); // run all, return first success, cancel the restinvokeAll(tasks, timeout, unit) li esegue ma si interrompe dopo un timeout; i task non completati vengono restituiti come Future il cui isDone() è true ma sono stati annullati.
invokeAny è lo strumento giusto per le query ridondanti — chiedi a tre server DNS, prendi quello che risponde per primo, annulla gli altri.
ScheduledExecutorService — ritardi e ripetizioni
Quando hai bisogno di un ritardo o di una pianificazione periodica:
ScheduledExecutorService sched = Executors.newScheduledThreadPool(2);
sched.schedule(() -> log("once, after 5 seconds"), 5, TimeUnit.SECONDS);
sched.scheduleAtFixedRate(this::flush, 0, 1, TimeUnit.SECONDS);
// runs at t=0, t=1, t=2, ... — even if a run takes longer, the next one queues
sched.scheduleWithFixedDelay(this::poll, 0, 1, TimeUnit.SECONDS);
// runs at t=0, then 1 second AFTER the previous finished — back-to-back delay is what's fixedLa differenza tra atFixedRate e withFixedDelay sta nel fatto che il periodo sia tra gli avvii o tra la fine e il successivo avvio. Per "voglio eseguire il flush ogni secondo sull'orologio," usa atFixedRate; per "voglio un intervallo di 1 secondo tra le esecuzioni indipendentemente dalla loro durata," usa withFixedDelay.
Se un task pianificato lancia un'eccezione, le esecuzioni future vengono silenziosamente annullate. Lo scheduler non registra nulla. Avvolgi sempre i task pianificati in un try/catch di primo livello per farli continuare:
sched.scheduleAtFixedRate(() -> {
try { flush(); }
catch (Throwable t) { log.error("flush failed", t); }
}, 0, 1, TimeUnit.SECONDS);Dimenticare questo è il bug dello scheduler più comune in Java di produzione.
Dimensionamento del pool
La giusta dimensione del pool dipende da cosa fanno i task.
Per lavori CPU-bound, la regola empirica è N + 1 thread su una macchina con N core. Ogni thread mantiene occupato un core; il +1 copre il raro momento in cui un thread incontra uno stallo di memoria.
Per lavori I/O-bound, il numero giusto è molto maggiore. La formula approssimativa:
threads = cores * (1 + (wait_time / compute_time))Se i tuoi task sono al 90% in attesa del database, il moltiplicatore è 10x — 80 thread su 8 core. Il numero esatto dipende dallo specifico pattern I/O; profila e regola.
In pratica, usa due pool: uno piccolo per il lavoro CPU e uno grande per I/O. Non mischiarli — una lenta chiamata al database all'interno di un thread del pool CPU blocca un core che dovrebbe calcolare.
I virtual thread di Java 21 cambiano fondamentalmente questa matematica: bloccarsi su I/O non spreca più un thread di piattaforma, quindi puoi usare un executor virtual-thread-per-task e smettere completamente di dimensionare. Lo vediamo alla fine della parte.
Factory di Executors — riferimento rapido
I metodi factory restituiscono tutti ExecutorService (o una sotto-interfaccia). Ciascuno è un ThreadPoolExecutor con valori specifici dei parametri:
| Factory | Configurazione sottostante | Quando usarlo |
|---|---|---|
newFixedThreadPool(n) | core=max=n, LinkedBlockingQueue illimitata | Parallelismo prevedibile; la coda illimitata è la trappola |
newCachedThreadPool | core=0, max=MAX_VALUE, SynchronousQueue, keep-alive 60s | Task brevi e a raffica; il numero illimitato di thread è la trappola |
newSingleThreadExecutor | Uguale a newFixedThreadPool(1), ma il pool non è riconfigurabile | Serializzare un singolo worker ordinato |
newScheduledThreadPool(n) | n thread core, coda pianificata | Task periodici |
newWorkStealingPool | Java 8+: un ForkJoinPool con parallelismo = core | Lavoro CPU-bound, sotto-task ricorsivi |
newVirtualThreadPerTaskExecutor | Java 21+: un virtual thread per task | Lavoro I/O-bound, server web |
Evita newFixedThreadPool e newCachedThreadPool per i percorsi di sovraccarico in produzione — entrambi hanno assi di crescita illimitati. Usa direttamente new ThreadPoolExecutor(...) con una coda limitata.
La sequenza di shutdown standard
Un pool che non viene mai arrestato mantiene vivi i suoi thread worker non-daemon, impedendo l'uscita dalla JVM. Ogni pool che crei necessita dello stesso pattern di pulizia:
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
// ... submit work, gather results ...
} finally {
pool.shutdown();
try {
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
}Oppure, da Java 19, la stessa cosa tramite try-with-resources:
try (var pool = Executors.newFixedThreadPool(4)) {
pool.submit(...);
pool.submit(...);
} // close() runs shutdown + awaitTerminationIl ExecutorService.close() di Java 19 esegue l'arresto educato e poi attende indefinitamente; combinalo con un watchdog se non puoi permetterti un'attesa infinita.
Un esempio completo: il framework dall'inizio alla fine
Il programma seguente usa ciascuna delle tre interfacce — Executor per avviare e dimenticare, ExecutorService per i risultati e ScheduledExecutorService per la periodicità — tutto in uno.
Cosa ricavare dall'esecuzione:
- La sezione 2 ha usato
try (ExecutorService pool = ...)— il pattern di chiusura a fine scope di Java 19. Ilclose()del pool esegueshutdown()e poi attende. Questa è la forma di shutdown più pulita; per codice più vecchio o scadenze più rigide, torna alla sequenzashutdown+awaitTermination+shutdownNow. - La sezione 3 ha eseguito tre task da 50/80/20 ms su 4 worker.
invokeAllha restituito solo dopo che il più lento ha terminato — circa 80 ms. Questo è il contratto "attendi tutti". Lasumsulle future era la somma dei valori restituiti, nell'ordine di sottomissione. - La sezione 4 ha eseguito la stessa struttura con
invokeAny. Il task più veloce (50 ms) ha restituito per primo; gli altri sono stati annullati.invokeAnyè esattamente la forma giusta per i pattern "prima risposta riuscita" — ricerche DNS su più server, download da mirror, gare di latenza. - La sezione 5 ha usato
scheduleAtFixedRatecon un periodo di 60 ms. Ogni tick è scattato su un thread del pool pianificato. Il wrappertry/catchall'interno del corpo è la forma di produzione — se un task pianificato lancia un'eccezione, lo scheduler annulla silenziosamente le esecuzioni future. Avvolgere ogni corpo in un catch di primo livello impedisce che ciò accada. - Il task pianificato è stato esplicitamente
cancel(false)prima che il programma uscisse. Annullare e arrestare lo scheduler è ciò che consente alla JVM di terminare; senza di esso, lo scheduler trattiene thread non-daemon e il programma si blocca. Lo stesso vale per ogni executor che crei.
Cosa viene dopo
Il prossimo capitolo, Java Callable and Future, approfondisce il lato della gestione dei risultati di submit — Callable<V>, Future<V>, annullamento e gli idiomi standard per ottenere un valore da un task asincrono.