1. Le problème du code synchrone
Imaginons que vous avez un programme qui doit charger des données depuis Internet ou lire un gros fichier. Vous écrivez quelque chose comme :
String data = readFromFile("bigfile.txt");
System.out.println("Données: " + data);
Tout irait bien, mais si le fichier est volumineux ou le réseau lent, le programme se fige simplement sur la ligne de lecture. L’utilisateur regarde une interface « figée », le serveur ne peut pas traiter d’autres requêtes, et le développeur… est triste.
Une telle situation s’appelle un blocage : un thread (par exemple, le thread principal de votre application) est contraint d’attendre que l’opération se termine. Et s’il y a beaucoup de telles opérations — bonjour les latences et les mauvaises performances.
C’est comme si vous veniez dans un café, passiez commande et… deviez rester au comptoir jusqu’à ce que votre café soit prêt. Les autres clients attendent derrière vous jusqu’à ce que le barista ait fini avec vous. Peu efficace, n’est-ce pas ?
Asynchronisme : comment il sauve le monde
La programmation asynchrone est une approche où les opérations longues (par exemple, lecture de fichier, requête à un serveur, accès à une base de données) s’exécutent dans un thread d’arrière-plan, tandis que le thread principal continue de travailler : servir les utilisateurs, accepter de nouvelles requêtes, réagir aux événements.
Autrement dit, vous passez commande (vous lancez une tâche), vous allez vaquer à vos occupations, et quand le café est prêt (la tâche est terminée), on vous dit simplement : « C’est prêt ! »
En Java, avant l’arrivée de CompletableFuture, ce n’était pas très pratique. Voyons comment cela a évolué.
2. Approches historiques: Future et ses limitations
En Java 5 est apparue l’interface Future — une première tentative de rendre un peu plus confortable le travail avec les tâches asynchrones. Elle permettait de confier une tâche à un pool de threads et d’obtenir le résultat un jour ou l’autre.
ExecutorService executor = Executors.newSingleThreadExecutor();
Future<Integer> future = executor.submit(() -> 2 + 2);
int result = future.get(); // Attention : le thread se bloquera jusqu’à ce que la tâche soit terminée !
L’idée paraît bonne, mais en pratique, Future — c’est comme une vieille boîte aux lettres : vous avez envoyé une lettre, mais pour savoir si une réponse est arrivée, il faut constamment aller vérifier à l’intérieur.
Il ne sait pas notifier quand le résultat est prêt, ne prend pas en charge des enchaînements d’actions du type « fais ceci, puis cela », ne permet pas de gérer élégamment les erreurs. Tout bute sur cet appel bloquant get(), à cause duquel l’asynchronisme redevient de l’attente.
3. L’arrivée de CompletableFuture: un nouveau style d’asynchronisme
Avec Java 8, un véritable héros de l’asynchronisme est arrivé pour remplacer le vieillissant Future — CompletableFuture. Cette classe du package java.util.concurrent est devenue un outil universel pour tous ceux qui en avaient assez d’attendre les résultats « manuellement » et voulaient écrire du code asynchrone de façon élégante, concise et claire.
CompletableFuture sait faire presque tout. Il peut lancer des tâches dans d’autres threads, les chaîner — par exemple, d’abord calculer un résultat, puis le traiter, puis faire encore autre chose. Il combine facilement plusieurs tâches: on peut attendre l’achèvement de toutes à la fois ou seulement de la première qui termine. Les erreurs se gèrent aussi avec élégance — sans surplus de try-catch. Et le style général se rapproche du fonctionnel: au lieu d’appels ennuyeux et d’attentes, on obtient des méthodes expressives comme thenApply, thenAccept et d’autres.
flowchart LR
A[Lancement de la tâche de façon asynchrone] --> B[Traitement du résultat]
B --> C[Opération suivante]
C --> D[Gestion des erreurs]
Ainsi, CompletableFuture a transformé l’asynchronisme d’un art difficile en un outil pratique et flexible, avec lequel le code respire enfin.
4. Exemple très simple: premier pas dans le monde CompletableFuture
Regardons un exemple minimal de tâche asynchrone :
import java.util.concurrent.CompletableFuture;
public class AsyncDemo {
public static void main(String[] args) {
// Lançons la tâche de façon asynchrone
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
// On récupère le résultat (bloque le thread !)
try {
int result = future.get();
System.out.println("Résultat: " + result); // 4
} catch (Exception e) {
e.printStackTrace();
}
}
}
Ce code exécute déjà le calcul dans un thread séparé — le thread principal n’est pas bloqué au moment du lancement de la tâche. Mais l’appel get() bloque tout de même le thread jusqu’à ce que le résultat soit prêt.
Et comment NE PAS bloquer le thread ?
C’est simple : utilisez des méthodes de callback qui sont appelées lorsque la tâche se termine :
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2);
future.thenAccept(result -> System.out.println("Résultat: " + result));
System.out.println("Je ne me bloque pas et je peux faire autre chose !");
Conclusion :
- thenAccept — c’est « s’abonner au résultat » : lorsque la tâche est terminée, exécute ce code.
- Le thread principal n’attend pas la fin de la tâche et continue de travailler.
Visualisation (pseudo-code des événements)
[Thread principal] --> [Lancement de la tâche]
| |
v v
[Fait quelque chose] [Le thread d’arrière-plan calcule 2+2]
| |
v v
[Affiche "Je ne me bloque pas..."]
| |
v v
[Quand c’est calculé — thenAccept est appelé]
5. À quoi cela ressemble-t-il dans une application ?
Imaginons que vous développez une application console où l’utilisateur peut demander de charger des données (par exemple depuis une base ou un serveur) et, pendant que les données se chargent, le programme ne se « fige » pas mais continue d’accepter des commandes.
Exemple: simulation d’une opération longue
import java.util.concurrent.CompletableFuture;
public class AsyncApp {
public static void main(String[] args) {
System.out.println("Nous commençons le chargement des données...");
CompletableFuture<String> dataFuture = CompletableFuture.supplyAsync(() -> {
// Simulation d’un chargement long
try {
Thread.sleep(2000); // 2 secondes
} catch (InterruptedException e) {
return "Erreur de chargement";
}
return "Données chargées avec succès !";
});
// On s’abonne au résultat
dataFuture.thenAccept(result -> System.out.println("Résultat: " + result));
// Le programme continue de fonctionner
System.out.println("Pendant que les données se chargent, je peux faire autre chose !");
// Pour éviter que le programme ne se termine trop tôt (démo uniquement !)
try {
Thread.sleep(2500);
} catch (InterruptedException ignored) {}
}
}
Ce que vous verrez dans la console :
Nous commençons le chargement des données...
Pendant que les données se chargent, je peux faire autre chose !
[après 2 secondes]
Résultat: Données chargées avec succès !
6. Nuances utiles
Un peu sur les threads sous le capot
Quand vous écrivez CompletableFuture.supplyAsync(...), la tâche s’exécute par défaut dans le ForkJoinPool — un pool de threads spécial que Java utilise pour les tâches parallèles. Si vous avez besoin de plus de contrôle (par exemple, votre propre ExecutorService), vous pouvez le passer en second paramètre :
ExecutorService executor = Executors.newFixedThreadPool(2);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 2 + 2, executor);
Mais pour des tâches simples, le pool standard suffit.
Obtenir le résultat: get(), join(), thenAccept
- get() — bloque le thread jusqu’à ce que le résultat soit prêt (lève des exceptions checked).
- join() — bloque aussi, mais lève des exceptions unchecked (RuntimeException).
- thenAccept(), thenApply() et autres — NE bloquent PAS, mais appellent la fonction fournie quand le résultat est prêt.
Dans des applications asynchrones réelles, évitez autant que possible get()/join() dans le thread principal !
7. Erreurs typiques lors des premiers pas avec CompletableFuture
Erreur n° 1: Utiliser get() ou join() dans le thread principal.
De cette façon, vous re-bloquez le programme et perdez tous les avantages de l’asynchronisme. À la place, utilisez thenAccept, thenApply et d’autres méthodes pour traiter le résultat.
Erreur n° 2: Oublier de gérer l’erreur.
Si une exception survient dans une tâche asynchrone, elle ne « remontera » pas dans le thread principal. Sans traitement via exceptionally ou handle, vous ne saurez tout simplement pas que quelque chose s’est mal passé.
Erreur n° 3: Ne pas attendre la fin du programme.
Dans les exemples de démo, il faut souvent « freiner » le thread main via Thread.sleep — sinon le programme se termine avant que la tâche n’ait fini. Dans les applications réelles (par exemple, les serveurs web) ce n’est pas un problème, mais dans les démos console — tenez-en compte.
Erreur n° 4: Confondre thenAccept et thenApply.
thenAccept — pour les « effets de bord » (ne renvoie rien), thenApply — pour transformer le résultat (renvoie un nouveau résultat).
Erreur n° 5: Mélanger inutilement code asynchrone et code synchrone.
Si vous avez commencé à écrire de l’asynchrone — ne ramenez pas le flux en synchrone via get()/join(), sauf cas extrême (par exemple, dans des tests).
GO TO FULL VERSION