1. ExecutorService : gérer les threads comme des pros
Pourquoi ne pas simplement créer des threads avec new Thread
Aux débuts de la concurrence, tout paraît simple :
Thread t = new Thread(() -> {
// on fait quelque chose
});
t.start();
Cette approche fonctionne, mais devient vite lourde quand les tâches se multiplient. Chaque appel à new Thread() crée un nouveau thread, et des dizaines ou centaines de threads finissent par surcharger le système. De plus, leur gestion est peu pratique : il faut surveiller quand ils se terminent, quoi faire en cas d’erreurs, comment les arrêter et les réutiliser.
C’est là qu’entre en scène ExecutorService — un ordonnanceur de threads intelligent. Vous lui confiez simplement des tâches, et il décide lui-même quel thread les exécutera et quand. Résultat : tout fonctionne plus vite, plus stablement et sans prise de tête.
Comment fonctionne ExecutorService
ExecutorService fonctionne selon un principe simple mais efficace.
- Il contient un pool de threads — un ensemble de threads de travail précréés (fixe ou dynamique).
- Les tâches vont dans une file d’attente et sont prises en charge par les threads libres.
- Le service gère le cycle de vie : vous pouvez attendre la fin, arrêter correctement le pool et libérer les ressources.
Création d’un ExecutorService
La manière la plus courante est d’utiliser les méthodes factory de la classe Executors :
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(4); // 4 threads
- newFixedThreadPool(N) — un pool de N threads (convient à la plupart des cas).
- newCachedThreadPool() — pool dynamique, crée des threads au besoin (attention : en cas d’avalanche de tâches, vous pouvez épuiser la mémoire).
- newSingleThreadExecutor() — un seul thread (exécution séquentielle).
Exemple : lancer Runnable via ExecutorService
executor.submit(() -> {
System.out.println("Bonjour depuis le pool de threads !");
});
Une fois que vous avez fini d’utiliser ExecutorService, il faut le terminer correctement :
executor.shutdown(); // Interdit d'ajouter de nouvelles tâches, attend la fin des tâches en cours
Important : si vous n’appelez pas shutdown(), le programme peut ne pas se terminer — les threads du pool attendront de nouvelles tâches.
2. Runnable vs Callable : toutes les tâches ne se ressemblent pas
Avant Java 5, si vous vouliez exécuter quelque chose dans un thread, vous écriviez une implémentation de l’interface Runnable. C’est une tâche qui ne renvoie rien et ne lance pas d’exceptions vérifiées.
Runnable task = () -> {
System.out.println("Je travaille simplement, je ne renvoie rien !");
};
executor.submit(task);
Callable : une tâche avec un résultat (et des exceptions)
Parfois, on veut qu’une tâche ne se contente pas de « faire quelque chose », mais qu’elle renvoie un résultat — par exemple une somme, le résultat d’un calcul, des données depuis un serveur. Pour cela, on a conçu l’interface Callable<T>.
import java.util.concurrent.Callable;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
- La méthode call() renvoie un résultat de type T.
- La méthode call() peut lancer une exception vérifiée.
Analogie : Runnable — « va faire la vaisselle » (le résultat n’a pas d’importance), Callable — « va chercher du thé et dis-moi à quelle température il est » (le résultat est important).
Lancement d’un Callable : pour obtenir le résultat, utilisez executor.submit(...). Il renverra un objet Future<T>.
3. Future : une promesse de résultat
Future est une « promesse » de fournir un résultat plus tard. Lorsque vous envoyez une tâche à ExecutorService, vous obtenez un Future à partir duquel vous pourrez ensuite récupérer le résultat, savoir si la tâche est terminée ou l’annuler.
Méthodes principales de Future
- T get() — obtenir le résultat (attend la fin de la tâche).
- boolean isDone() — savoir si la tâche est terminée.
- boolean cancel(boolean mayInterruptIfRunning) — tenter d’annuler la tâche.
- boolean isCancelled() — savoir si la tâche a été annulée.
Exemple : lancer un Callable et récupérer le résultat
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("Tâche lancée, vous pouvez faire autre chose...");
// On obtient le résultat (la méthode bloque le thread si la tâche n'est pas terminée)
Integer result = future.get();
System.out.println("Résultat des calculs: " + result);
executor.shutdown();
}
}
- La tâche est envoyée au pool de threads.
- Pendant son exécution, le thread principal peut faire autre chose.
- Quand le résultat est nécessaire, on appelle future.get() — le thread patientera si la tâche est encore en cours.
- Dès que la tâche se termine, le résultat est renvoyé.
4. Pratique : plusieurs tâches, attente de la fin
Il faut souvent lancer plusieurs tâches à la fois et attendre qu’elles se terminent toutes. Par exemple, vous traitez un tableau de données, le découpez en morceaux et calculez la somme de chaque morceau dans une tâche séparée.
Exemple : somme des éléments d’un tableau par morceaux
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); // On remplit avec des nombres de 1 à 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("Somme de " + from + " à " + (to - 1) + " = " + sum);
return sum;
};
futures.add(executor.submit(sumTask));
}
int totalSum = 0;
for (Future<Integer> f : futures) {
totalSum += f.get(); // On attend chaque tâche à son tour
}
System.out.println("Somme totale: " + totalSum);
executor.shutdown();
}
}
Ici, le tableau est divisé en 4 parties. Pour chaque partie, on crée une tâche (Callable) qui calcule la somme. Toutes les tâches sont envoyées à ExecutorService, et des Future sont renvoyés. À la fin, nous récupérons les résultats de toutes les tâches et les additionnons.
Dans des cas réels, il est pratique d’utiliser invokeAll pour attendre l’exécution de toutes les tâches en une fois.
5. Gestion des erreurs avec Future
Lorsque vous appelez future.get(), si la tâche s’est terminée par une exception, celle-ci sera propagée sous la forme d’ExecutionException. C’est important : si quelque chose s’est mal passé dans la tâche, vous ne le saurez qu’à l’appel de get().
Exemple : gestion des exceptions
Callable<Integer> errorTask = () -> {
throw new IllegalArgumentException("Quelque chose a mal tourné !");
};
Future<Integer> badFuture = executor.submit(errorTask);
try {
badFuture.get();
} catch (ExecutionException e) {
System.out.println("La tâche s'est terminée avec une erreur: " + e.getCause());
}
- Une exception est levée à l’intérieur de la tâche.
- Lors de l’appel à get(), elle est « enveloppée » dans une ExecutionException.
- La vraie cause peut être obtenue via getCause().
6. Nuances utiles
Comment annuler une tâche
Future<?> f = executor.submit(() -> {
while (true) {
// Travail sans fin
if (Thread.currentThread().isInterrupted()) {
System.out.println("On m'a demandé de m'arrêter !");
break;
}
}
});
Thread.sleep(100); // On attend un petit peu
f.cancel(true); // Essayons d'annuler la tâche
- cancel(true) essaie d’interrompre la tâche si elle n’est pas encore terminée.
- À l’intérieur de la tâche, il est recommandé de vérifier Thread.currentThread().isInterrupted() et de se terminer proprement.
shutdown vs shutdownNow
shutdown() — arrêt en douceur : interdit d’ajouter de nouvelles tâches et laisse les tâches en cours se terminer tranquillement. Utilisé le plus souvent.
shutdownNow() — arrêt brutal : essaie d’interrompre les threads actifs et renvoie la liste des tâches qui n’ont pas pu démarrer. À utiliser avec prudence.
invokeAll et invokeAny
invokeAll(Collection<Callable<T>> tasks) lance toutes les tâches fournies et attend qu’elles se terminent toutes. Renvoie une liste de Future.
invokeAny(Collection<Callable<T>> tasks) n’attend que la première tâche terminée avec succès, renvoie son résultat et annule les autres. Pratique lorsque seule la première réponse réussie importe.
7. Erreurs courantes avec ExecutorService, Callable et Future
Erreur n° 1 : ne pas fermer ExecutorService. Si vous oubliez d’appeler shutdown(), le programme peut « rester bloqué » après la fin de main, car les threads du pool attendent de nouvelles tâches.
Erreur n° 2 : attendre le résultat juste après avoir soumis la tâche. Si vous appelez get() immédiatement après submit(), vous ne profitez pas de l’asynchronisme — le thread attendra quand même. Faites du travail utile en parallèle et ne demandez le résultat que lorsqu’il est réellement nécessaire.
Erreur n° 3 : ignorer les exceptions dans les tâches. Si vous ne traitez pas ExecutionException lors de l’appel à get(), vous risquez de passer à côté d’erreurs importantes survenues dans la tâche.
Erreur n° 4 : utiliser des variables partagées et mutables sans synchronisation. Si plusieurs tâches travaillent sur les mêmes données — il faut de la synchronisation ou des collections thread-safe.
Erreur n° 5 : créer trop de threads. Évitez de configurer un pool avec un nombre de threads largement supérieur au nombre de cœurs du processeur — cela peut même ralentir l’exécution.
Erreur n° 6 : oublier d’annuler les tâches. Si une tâche n’est plus nécessaire, annulez-la via cancel() pour ne pas gaspiller de ressources.
GO TO FULL VERSION