W3docs

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/scheduleWithFixedDelay

Si 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 come Executor è il contratto più generale "dammi qualcosa che possa eseguire un Runnable".
  • ExecutorService — il fulcro. Quasi tutto il codice di produzione usa questo tipo. Aggiunge submit (con un risultato Future), 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 del Callable).
  • 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 failed

Operazioni 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 rest

invokeAll(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 fixed

La 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:

FactoryConfigurazione sottostanteQuando usarlo
newFixedThreadPool(n)core=max=n, LinkedBlockingQueue illimitataParallelismo prevedibile; la coda illimitata è la trappola
newCachedThreadPoolcore=0, max=MAX_VALUE, SynchronousQueue, keep-alive 60sTask brevi e a raffica; il numero illimitato di thread è la trappola
newSingleThreadExecutorUguale a newFixedThreadPool(1), ma il pool non è riconfigurabileSerializzare un singolo worker ordinato
newScheduledThreadPool(n)n thread core, coda pianificataTask periodici
newWorkStealingPoolJava 8+: un ForkJoinPool con parallelismo = coreLavoro CPU-bound, sotto-task ricorsivi
newVirtualThreadPerTaskExecutorJava 21+: un virtual thread per taskLavoro 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 + awaitTermination

Il 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.

java— editable, runs on the server

Cosa ricavare dall'esecuzione:

  • La sezione 2 ha usato try (ExecutorService pool = ...) — il pattern di chiusura a fine scope di Java 19. Il close() del pool esegue shutdown() e poi attende. Questa è la forma di shutdown più pulita; per codice più vecchio o scadenze più rigide, torna alla sequenza shutdown + awaitTermination + shutdownNow.
  • La sezione 3 ha eseguito tre task da 50/80/20 ms su 4 worker. invokeAll ha restituito solo dopo che il più lento ha terminato — circa 80 ms. Questo è il contratto "attendi tutti". La sum sulle 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 scheduleAtFixedRate con un periodo di 60 ms. Ogni tick è scattato su un thread del pool pianificato. Il wrapper try/catch all'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 submitCallable<V>, Future<V>, annullamento e gli idiomi standard per ottenere un valore da un task asincrono.

Esercizio

Pratica
Pianifichi un task con `scheduleAtFixedRate` e alla terza esecuzione lancia una `RuntimeException`. Cosa succede alle esecuzioni successive?
Pianifichi un task con `scheduleAtFixedRate` e alla terza esecuzione lancia una `RuntimeException`. Cosa succede alle esecuzioni successive?
Was this page helpful?