Optimisation des threads
Est-ce que l'utilisation des threads qu'on a vu à date est optimisée?
Non, car :
1. Création de thread coûteuse
- Créer un thread en Java n'est pas gratuit : ça consomme temps et mémoire.
- Chaque thread nécessite une pile mémoire dédiée (par défaut, plusieurs centaines de Ko).
- Si tu crées 10 000 threads, tu vas rapidement épuiser la mémoire, même si chaque tâche est très simple.
2. Temps de création élevé
- L'initialisation d'un thread prend plus de temps qu'une tâche légère.
- Si ta tâche dure 5 ms, mais que le thread met 2 ms à démarrer, tu perds déjà presque 40 % du temps en overhead.
3. Limite du système d'exploitation
- Le nombre de threads simultanés est limité par le système.
- Si tu en lances trop, tu risques une erreur OutOfMemoryError ou un ralentissement massif du système (même hors de ta JVM).
4. Pas d'optimisation de la gestion des ressources
- Le CPU ne peut exécuter qu'un nombre limité de threads en parallèle (dépend du nombre de cœurs).
- Si tu lances 1000 threads sur un CPU 8 cœurs, 992 threads seront en attente (et le CPU devra switcher entre eux, ce qui est coûteux).
5. Pas de réutilisation
- Si tu crées un thread pour une tâche, il vit une fois puis meurt.
- Cela empêche la réutilisation des ressources, contrairement à un thread pool où les threads sont recyclés.
Analogie simple :
C'est comme si tu engageais un ouvrier pour chaque clou à planter :
- Tu payes pour qu'il vienne, plante un clou, reparte.
- Résultat : plus de gestion de transport que de travail.
Alors qu'avec un pool, tu engages 5 ouvriers, tu leur donnes tous les clous, et ils bossent en continu, efficacement.
6. Executors
Depuis Java 5, la classe ExecutorService facilite la gestion des threads :
ExecutorService pool = Executors.newFixedThreadPool(4);
pool.submit(() -> System.out.println("Tâche exécutée"));
pool.shutdown();
Avantages :
- Réutilisation des threads
- Moins de gestion manuelle
- Plus de contrôle
Types soumis à l'ExecutorService
En Java, l'ExecutorService peut exécuter tout ce qui est un Runnable ou un Callable.
1. Runnable (pas de retour)
Runnable task = () -> System.out.println("Tâche Runnable exécutée");
executor.submit(task);
2. Callable<V> (avec retour)
Callable<V> est une interface fonctionnelle du package java.util.concurrent. Elle ressemble à Runnable, mais sa méthode call() retourne une valeur et peut lever une exception.
@FunctionalInterface
public interface Callable<V> {
V call() throws Exception;
}
Runnable | Callable<V> | |
|---|---|---|
| Méthode | void run() | V call() throws Exception |
| Retourne une valeur | Non | Oui |
| Peut lever une exception vérifiée | Non | Oui |
Puisque c'est une interface fonctionnelle, on peut l'écrire avec une lambda :
// Forme complète
Callable<String> task = new Callable<String>() {
@Override
public String call() {
return "Résultat de la tâche";
}
};
// Forme lambda (équivalent)
Callable<String> task = () -> "Résultat de la tâche";
Future<String> result = executor.submit(task);
Future
Future<T> est un objet représentant le résultat d'une tâche asynchrone — un engagement que tu recevras un objet de type T plus tard. Quand tu soumets un Callable à un ExecutorService, il te rend un Future que tu peux :
- consulter pour savoir si la tâche est terminée,
- attendre pour récupérer le résultat,
- ou même annuler la tâche.
Exemple simple
import java.util.concurrent.*;
public class ExempleFuture {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newSingleThreadExecutor();
Callable<String> task = () -> {
Thread.sleep(1000);
return "Résultat prêt !";
};
Future<String> future = executor.submit(task);
System.out.println("La tâche est lancée...");
String result = future.get(); // bloque jusqu'à ce que le résultat soit prêt
System.out.println("Résultat obtenu : " + result);
executor.shutdown();
}
}
Méthodes utiles de Future
| Méthode | Description |
|---|---|
get() | Bloque jusqu'à ce que le résultat soit prêt. |
get(long timeout, TimeUnit unit) | Attend un maximum de temps, puis lève une exception si pas prêt. |
cancel(boolean mayInterruptIfRunning) | Annule la tâche (si elle n'est pas encore terminée). |
isDone() | Retourne true si la tâche est terminée (résultat ou erreur). |
isCancelled() | Retourne true si la tâche a été annulée. |
Résumé rapide des méthodes d'ExecutorService
| Méthode | Description | Bloquant |
|---|---|---|
execute(Runnable) | Exécute une tâche, ne retourne rien. | Non-bloquant |
submit(Runnable) | Exécute une tâche, retourne un Future<?>. | Non-bloquant |
submit(Callable<V>) | Exécute une tâche, retourne un Future<V>. | Non-bloquant |
invokeAll(Collection<Callable>) | Exécute toutes les tâches, attend qu'elles soient toutes terminées. | Bloquant |
invokeAny(Collection<Callable>) | Exécute toutes les tâches, retourne la première qui réussit. | Bloquant |
invokeAll / invokeAny
List<Callable<String>> tasks = List.of(
() -> "Tâche 1",
() -> "Tâche 2",
() -> "Tâche 3"
);
List<Future<String>> futures = executor.invokeAll(tasks); // attend toutes
String firstResult = executor.invokeAny(tasks); // retourne la première qui réussit
Exemple complet : benchmark
La tâche doit être CPU-intensive pour voir la différence. Avec Thread.sleep(), tous les threads dorment en même temps sans utiliser le CPU — le pool serait artificiellement plus lent. Ici, chaque tâche fait un vrai calcul.
import java.util.concurrent.*;
import java.util.*;
public class BenchmarkThreads {
// Tâche CPU-intensive : additionne 1 million de nombres
static long tacheCPU() {
long somme = 0;
for (int i = 0; i < 1_000_000; i++) somme += i;
return somme;
}
// Benchmark avec un thread par tâche
static long avecThreads(int n) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
Thread t = new Thread(BenchmarkThreads::tacheCPU);
t.start();
threads.add(t);
}
for (Thread t : threads) t.join();
return System.currentTimeMillis() - start;
}
// Benchmark avec un pool calibré sur les cœurs disponibles
static long avecPool(int n) throws InterruptedException, ExecutionException {
int cores = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(cores);
List<Future<Long>> futures = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < n; i++) {
futures.add(pool.submit(BenchmarkThreads::tacheCPU));
}
for (Future<Long> f : futures) f.get();
pool.shutdown();
pool.awaitTermination(1, TimeUnit.MINUTES);
return System.currentTimeMillis() - start;
}
public static void main(String[] args) throws Exception {
int nbTaches = 200;
int cores = Runtime.getRuntime().availableProcessors();
long tempsThreads = avecThreads(nbTaches);
long tempsPool = avecPool(nbTaches);
System.out.println("Cœurs disponibles : " + cores);
System.out.println("Threads manuels (200) : " + tempsThreads + " ms");
System.out.println("Pool (" + cores + " threads) : " + tempsPool + " ms");
}
}
Pourquoi le pool gagne ici ?
Avec 200 threads manuels sur 8 cœurs, le CPU doit constamment switcher entre 200 threads qui veulent tous calculer en même temps — c'est du gaspillage pur.
Avec un pool de 8, seulement 8 threads tournent à la fois. Chaque cœur a un seul thread à exécuter sans interruption jusqu'à ce que la tâche soit terminée, puis il prend la suivante.
200 threads sur 8 cœurs Pool de 8 sur 8 cœurs
──────────────────────── ──────────────────────
Cœur 1 : switche entre 25 Cœur 1 : exécute 1 tâche à la fois
threads Cœur 2 : exécute 1 tâche à la fois
Cœur 2 : switche entre 25 ...
threads Aucun context switch inutile
...
Beaucoup de temps perdu
à switcher