Aller au contenu principal

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;
}
RunnableCallable<V>
Méthodevoid run()V call() throws Exception
Retourne une valeurNonOui
Peut lever une exception vérifiéeNonOui

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éthodeDescription
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éthodeDescriptionBloquant
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