1. ExecutorService: gestiamo i thread da professionisti
Perché non conviene creare semplicemente i thread con new Thread
All’inizio del multithreading tutto sembra semplice:
Thread t = new Thread(() -> {
// facciamo qualcosa
});
t.start();
Questo approccio funziona, ma diventa presto un peso quando le attività aumentano. Ogni chiamata a new Thread() crea un nuovo thread, e decine o centinaia di thread iniziano a sovraccaricare il sistema. Inoltre è scomodo gestirli: bisogna controllare quando finiscono, cosa fare in caso di errori, come fermarli e riutilizzarli.
Qui entra in scena ExecutorService — un dispatcher di thread intelligente. Voi gli passate le attività e lui decide con quale thread e quando eseguirle. Il risultato è tutto più veloce, stabile e senza grattacapi.
Come funziona ExecutorService
ExecutorService funziona secondo un principio semplice ma efficace.
- All’interno ha un pool di thread — un insieme di thread di lavoro già creati (fisso o dinamico).
- Le attività finiscono in una coda e vengono prelevate dai thread liberi.
- Il servizio gestisce il ciclo di vita: potete attendere il completamento, arrestare correttamente il pool e liberare le risorse.
Creazione di ExecutorService
Il modo più comune è usare i metodi factory della classe Executors:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(4); // 4 thread
- newFixedThreadPool(N) — pool di N thread (adatto alla maggior parte dei casi).
- newCachedThreadPool() — pool dinamico, crea thread al bisogno (attenzione: con una valanga di attività si può saturare la memoria).
- newSingleThreadExecutor() — un solo thread (esecuzione sequenziale).
Esempio: esecuzione Runnable tramite ExecutorService
executor.submit(() -> {
System.out.println("Ciao dal pool di thread!");
});
Dopo aver finito di usare ExecutorService, è necessario terminarlo correttamente:
executor.shutdown(); // Proibisce di aggiungere nuove attività, attende il completamento di quelle correnti
Importante: Se non chiamate shutdown(), il programma potrebbe non terminare — i thread del pool resteranno in attesa di nuove attività.
2. Runnable vs Callable: non tutte le attività sono uguali
Prima di Java 5, se volevate eseguire qualcosa in un thread, scrivevate un’implementazione dell’interfaccia Runnable. È un’attività che non restituisce nulla e non lancia eccezioni verificate.
Runnable task = () -> {
System.out.println("Sto solo lavorando, non restituisco nulla!");
};
executor.submit(task);
Callable: attività con risultato (e con eccezioni)
A volte si vuole che l’attività non solo “faccia qualcosa”, ma restituisca un risultato — per esempio la somma di numeri, l’esito di un calcolo, dati da un server. Per questo esiste l’interfaccia Callable<T>.
import java.util.concurrent.Callable;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
- Il metodo call() restituisce un risultato di tipo T.
- Il metodo call() può lanciare un’eccezione verificata.
Analogia: Runnable — «vai a lavare i piatti» (il risultato non è importante), Callable — «vai a prendere il tè e dimmi a che temperatura è» (il risultato è importante).
Esecuzione di Callable: per ottenere il risultato, usa executor.submit(...). Restituirà un oggetto Future<T>.
3. Future: una promessa di risultato
Future è una “promessa” di restituire un risultato in futuro. Quando inviate un’attività a ExecutorService, ottenete un Future, dal quale potrete in seguito ottenere il risultato, sapere se l’attività è terminata o annullarla.
Metodi principali di Future
- T get() — ottenere il risultato (attende finché l’attività non termina).
- boolean isDone() — se l’attività è terminata.
- boolean cancel(boolean mayInterruptIfRunning) — provare ad annullare l’attività.
- boolean isCancelled() — se l’attività è stata annullata.
Esempio: esecuzione di Callable e ottenimento del risultato
import java.util.concurrent.*;
public class ParallelSumApp {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
Future<Integer> future = executor.submit(sumTask);
System.out.println("Attività avviata, puoi fare qualcos'altro...");
// Otteniamo il risultato (il metodo blocca il thread se l'attività non è ancora terminata)
Integer result = future.get();
System.out.println("Risultato dei calcoli: " + result);
executor.shutdown();
}
}
- L’attività viene inviata al pool di thread.
- Finché l’attività è in esecuzione, il thread principale può fare altro.
- Quando serve il risultato, chiamiamo future.get() — il thread attenderà se l’attività è ancora in corso.
- Non appena l’attività termina, viene restituito il risultato.
4. Pratica: più attività e attesa del completamento
Spesso è necessario avviare più attività contemporaneamente e attendere che finiscano tutte. Per esempio, si elabora un array di dati, lo si suddivide in parti e si calcola la somma di ciascuna parte in un’attività separata.
Esempio: somma degli elementi dell’array a blocchi
import java.util.*;
import java.util.concurrent.*;
public class ParallelArraySum {
public static void main(String[] args) throws Exception {
int[] array = new int[1000];
Arrays.setAll(array, i -> i + 1); // Riempiamo con i numeri da 1 a 1000
ExecutorService executor = Executors.newFixedThreadPool(4);
int chunkSize = array.length / 4;
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int from = i * chunkSize;
int to = (i == 3) ? array.length : (i + 1) * chunkSize;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int j = from; j < to; j++) sum += array[j];
System.out.println("Somma da " + from + " a " + (to - 1) + " = " + sum);
return sum;
};
futures.add(executor.submit(sumTask));
}
int totalSum = 0;
for (Future<Integer> f : futures) {
totalSum += f.get(); // Attendiamo ogni attività a turno
}
System.out.println("Somma totale: " + totalSum);
executor.shutdown();
}
}
Qui l’array è suddiviso in 4 parti. Per ciascuna parte si crea un’attività (Callable) che calcola la somma. Tutte le attività vengono inviate a ExecutorService e vengono restituiti i Future. Infine raccogliamo i risultati di tutte le attività e li sommiamo.
Nelle attività reali è comodo usare invokeAll per attendere il completamento di tutte le attività in una volta sola.
5. Gestione degli errori con Future
Quando chiamate future.get(), se l’attività è terminata con un’eccezione, questa verrà rilanciata come ExecutionException. È importante: se qualcosa va storto nell’attività, lo scoprirete solo al momento della chiamata a get().
Esempio: gestione delle eccezioni
Callable<Integer> errorTask = () -> {
throw new IllegalArgumentException("Qualcosa è andato storto!");
};
Future<Integer> badFuture = executor.submit(errorTask);
try {
badFuture.get();
} catch (ExecutionException e) {
System.out.println("L'attività è terminata con un errore: " + e.getCause());
}
- All’interno dell’attività viene lanciata un’eccezione.
- Alla chiamata di get() viene “incapsulata” in una ExecutionException.
- La causa reale si può ottenere tramite getCause().
6. Dettagli utili
Come annullare un’attività
Future<?> f = executor.submit(() -> {
while (true) {
// Lavoro infinito
if (Thread.currentThread().isInterrupted()) {
System.out.println("Mi hanno chiesto di terminare!");
break;
}
}
});
Thread.sleep(100); // Aspettiamo un po'
f.cancel(true); // Proviamo ad annullare l'attività
- cancel(true) prova a interrompere l’attività, se non è ancora terminata.
- All’interno dell’attività è consigliabile controllare Thread.currentThread().isInterrupted() e terminare correttamente.
shutdown vs shutdownNow
shutdown() — arresto morbido: vieta di aggiungere nuove attività e consente a quelle correnti di terminare tranquillamente. È l’opzione più usata.
shutdownNow() — arresto forzato: prova a interrompere i thread attivi e restituisce l’elenco delle attività che non sono riuscite a partire. Usatelo con cautela.
invokeAll e invokeAny
invokeAll(Collection<Callable<T>> tasks) avvia tutte le attività fornite e attende che finiscano. Restituisce un elenco di Future.
invokeAny(Collection<Callable<T>> tasks) attende solo la prima attività completata con successo, restituisce il suo risultato e annulla le altre. Utile quando conta la prima risposta valida.
7. Errori tipici nell’uso di ExecutorService, Callable e Future
Errore n. 1: non chiudere ExecutorService. Se vi dimenticate di chiamare shutdown(), il programma può “restare appeso” dopo la fine di main, perché i thread del pool aspettano nuove attività.
Errore n. 2: attendere il risultato subito dopo l’invio dell’attività. Se subito dopo submit() chiamate get(), non avrete i vantaggi dell’asincronia — il thread aspetterà comunque. Svolgete lavoro utile in parallelo e richiedete il risultato quando vi serve davvero.
Errore n. 3: ignorare le eccezioni nelle attività. Se non gestite ExecutionException quando chiamate get(), potete perdere errori importanti che sono avvenuti nell’attività.
Errore n. 4: uso di variabili condivise e mutabili senza sincronizzazione. Se più attività operano sugli stessi dati, serve sincronizzazione o collezioni thread-safe.
Errore n. 5: creare troppi thread. Non è il caso di creare un pool con un numero di thread molto superiore ai core della CPU — può perfino rallentare l’esecuzione.
Errore n. 6: dimenticarsi di annullare le attività. Se un’attività non serve più, annullatela tramite cancel() per non sprecare risorse.
GO TO FULL VERSION