Java CompletableFuture
Composez des calculs asynchrones avec CompletableFuture — thenApply, thenCompose, allOf, exceptionally et les pièges à éviter.
Future est un gestionnaire de résultat à usage unique : vous soumettez, vous attendez, vous lisez. Il ne peut pas enchaîner. Si vous voulez « faire A, puis avec le résultat de A faire B, puis combiner B avec C et passer à D » sans écrire une machine à états à la main, vous avez besoin de CompletableFuture — la refonte par Java 8 de l'idée de résultat asynchrone autour de la composition.
CompletableFuture<V> implémente Future<V>, donc toute l'ancienne API est toujours là. La nouveauté est l'API combinatoire : une trentaine de méthodes qui vous permettent de construire des graphes de flux de données de travaux asynchrones — appliquer des fonctions, exécuter des effets de bord, combiner plusieurs futures, récupérer des exceptions, gérer les délais — sans jamais bloquer un thread pour attendre un résultat intermédiaire.
Les méthodes de démarrage
En général, vous ne construisez pas un CompletableFuture directement. Vous démarrez un pipeline avec l'une de ces méthodes :
CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 42);
CompletableFuture<Void> b = CompletableFuture.runAsync(() -> log("hello"));
CompletableFuture<String> c = CompletableFuture.completedFuture("ready");
CompletableFuture<String> d = CompletableFuture.failedFuture(new IOException("nope"));| Méthode de démarrage | Comportement |
|---|---|
supplyAsync(Supplier) | Exécute un Supplier sur le pool commun, retourne sa valeur |
runAsync(Runnable) | Exécute un Runnable sur le pool commun, sans valeur |
completedFuture(v) | Un future déjà résolu avec la valeur donnée |
failedFuture(t) | Un future déjà en échec avec le throwable donné |
supplyAsync et runAsync ont des surcharges qui acceptent un Executor explicite. Vous voudrez presque toujours en passer un. La valeur par défaut est ForkJoinPool.commonPool() — un pool partagé dimensionné selon le nombre de vos CPUs, correct pour du travail CPU court mais désastreux si vous y mettez des I/O (un appel lent bloque un cœur pour tout le monde). Passez toujours un executor explicite pour les I/O ou les travaux de coût inconnu.
Enchaînement : thenApply, thenAccept, thenRun
Les combinateurs les plus simples transforment un future en un autre :
CompletableFuture<Integer> a = CompletableFuture.supplyAsync(() -> 42);
CompletableFuture<String> b = a.thenApply(n -> "value is " + n); // transform
CompletableFuture<Void> c = a.thenAccept(n -> System.out.println(n)); // consume, no result
CompletableFuture<Void> d = a.thenRun(() -> System.out.println("done")); // side-effect, ignore value| Méthode | Type de lambda | Retourne |
|---|---|---|
thenApply | Function<T,U> | CompletableFuture<U> |
thenAccept | Consumer<T> | CompletableFuture<Void> |
thenRun | Runnable | CompletableFuture<Void> |
Chaque méthode a trois variantes :
thenApply(fn)— s'exécute sur le thread qui complète l'étape précédentethenApplyAsync(fn)— s'exécute sur le pool communthenApplyAsync(fn, executor)— s'exécute sur un executor spécifique
La forme non-Async est la plus rapide (pas de changement de thread) mais signifie que votre fn s'exécute sur le thread qui a complété l'étape précédente — possiblement le thread I/O que vous ne voulez pas occuper avec du travail CPU. Les formes *Async sont la valeur par défaut la plus sûre dans les pipelines hétérogènes.
thenCompose — aplatir un future de future
thenApply convient quand la fonction retourne une valeur simple. Quand elle retourne un autre CompletableFuture, vous ne voulez pas un CompletableFuture<CompletableFuture<V>> — vous voulez thenCompose :
CompletableFuture<User> user = lookupUser(id);
CompletableFuture<Profile> profile = user.thenCompose(u -> loadProfile(u.profileId()));
// ^ Function<User, CompletableFuture<Profile>>thenCompose est le flatMap pour les futures. Utilisez-le quand l'étape suivante est elle-même asynchrone ; utilisez thenApply quand elle ne l'est pas.
Combiner deux futures : thenCombine
Quand vous avez deux valeurs asynchrones indépendantes et souhaitez les combiner :
CompletableFuture<Integer> price = fetchPrice(symbol);
CompletableFuture<Integer> shares = fetchShares(account);
CompletableFuture<Integer> total = price.thenCombine(shares, (p, s) -> p * s);thenCombine attend les deux entrées, puis applique une BiFunction à leurs résultats. Les deux futures s'exécutent en parallèle — price et shares sont déjà en cours lorsque thenCombine est enregistré. Le combinateur s'exécute sur le thread qui complète en second.
La version « n'importe lequel », applyToEither, prend le premier résultat et ignore le second.
Plusieurs futures : allOf et anyOf
Quand le parallélisme porte sur une collection de futures :
List<CompletableFuture<String>> all = ids.stream()
.map(this::fetchAsync)
.toList();
CompletableFuture<Void> doneAll = CompletableFuture.allOf(all.toArray(new CompletableFuture[0]));
CompletableFuture<Object> firstOne = CompletableFuture.anyOf(all.toArray(new CompletableFuture[0]));allOf se complète quand toutes les entrées sont terminées. Il retourne CompletableFuture<Void> — pour récupérer la liste des résultats, vous devez utiliser thenApply et les extraire :
CompletableFuture<List<String>> results = doneAll.thenApply(v ->
all.stream().map(CompletableFuture::join).toList()); // .join() never blocks here — they're all completeanyOf retourne la valeur du premier futur à se compléter (en tant qu'Object — il n'y a aucun moyen d'exprimer « n'importe lequel de ces futures typés » avec un seul type de retour).
Gestion des erreurs : exceptionally et handle
Un CompletableFuture peut échouer (toute exception levée dans une étape produit un future en échec en aval). Les combinateurs qui récupèrent ou transforment :
CompletableFuture<String> safe = riskyAsync()
.exceptionally(ex -> "fallback for: " + ex.getMessage());
CompletableFuture<String> either = riskyAsync()
.handle((value, ex) -> ex == null ? value : "fallback");| Méthode | Quand elle s'exécute | Ce qu'elle retourne |
|---|---|---|
exceptionally(fn) | Seulement en cas d'échec ; reçoit la cause | Valeur de récupération |
handle(bi) | Toujours ; reçoit (value, ex) (l'un est null) | Valeur transformée |
whenComplete(bi) | Toujours ; reçoit (value, ex) | Même future, effet de bord uniquement |
exceptionally est le chemin simple « attraper et remplacer ». handle est plus général : « toujours s'exécuter, décider selon le résultat » — utile quand vous voulez journaliser chaque complétion indépendamment du succès.
orTimeout et completeOnTimeout
Java 9 a ajouté les délais directement dans l'API des futures :
CompletableFuture<String> withDeadline = riskyAsync()
.orTimeout(2, TimeUnit.SECONDS); // completes exceptionally if not done in 2s
CompletableFuture<String> withDefault = riskyAsync()
.completeOnTimeout("fallback", 2, TimeUnit.SECONDS);Ceux-ci vous permettent d'exprimer des délais sans écrire votre propre surveillance. Ils utilisent des threads planifiés internes, donc ils sont peu coûteux à attacher.
Ne pas bloquer dans les étapes asynchrones
La plus grande erreur avec CompletableFuture : appeler .get() ou .join() à l'intérieur d'une étape Async. C'est un thread du pool d'executors assis à ne rien faire en attendant un autre thread du même pool — en charge, vous pouvez bloquer tout le pool dans un deadlock.
// WRONG — joining inside an async stage on the common pool
CompletableFuture.supplyAsync(() -> {
Integer x = anotherFuture().join(); // blocks a pool thread
return x * 2;
});
// RIGHT — compose instead of join
anotherFuture().thenApply(x -> x * 2);Si vous vous trouvez à atteindre .get() à l'intérieur d'une étape Async, c'est que vous vouliez thenCompose/thenApply à la place.
Utiliser votre propre executor
Le pool commun par défaut convient pour du travail CPU court. Pour les I/O ou tout ce qui pourrait bloquer, utilisez le vôtre :
ExecutorService io = Executors.newFixedThreadPool(50, namedFactory("io"));
ExecutorService cpu = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors(), namedFactory("cpu"));
CompletableFuture.supplyAsync(this::loadFromDb, io)
.thenApplyAsync(this::transform, cpu)
.thenAcceptAsync(this::sendToClient, io);Chaque étape s'exécute sur le bon pool. Le pool commun reste disponible pour parallelStream et d'autres usages du framework. Ce mélange est au cœur d'un Java asynchrone bien conçu.
Un exemple concret : un petit pipeline asynchrone
Le programme ci-dessous récupère un « utilisateur » et un « profil » en parallèle, les combine, applique une limite de temps et récupère un chemin d'erreur.
Ce qu'il faut retenir de l'exécution :
- La section 1 a utilisé
thenCombinesur deux récupérations indépendantes. Elles se sont exécutées en parallèle —name(50 ms) etage(80 ms) étaient déjà en cours avant que le combinateur soit attaché. Le future combiné s'est complété peu après la fin du plus lent. C'est le parallélisme : un pipeline asynchrone n'attend pas chaque étape, il compose les étapes en un graphe. - La section 2 a utilisé
thenComposepour enchaîner des étapes où chaque étape est elle-même asynchrone.thenApplyvous aurait donnéCompletableFuture<CompletableFuture<String>>— inutilisable.thenComposeaplatit, à la manière dontflatMaple fait pour les streams etOptional. - La section 3 a utilisé
allOfsur une liste, puisthenApplypour extraire les valeurs.allOflui-même retourneVoid; la récolte des résultats est un stream séparé sur les futures (maintenant complétés) utilisantjoin(). Les appelsjoin()ne bloquent pas ici parce queallOfs'est déjà complété. - La section 4 a montré
exceptionallyrécupérant d'une tâche qui a levé une exception. Le future en amont a échoué ; le future en aval a retourné la chaîne de repli. Sansexceptionally(ouhandle), l'échec se serait propagé jusqu'à.join()sous forme deCompletionException. - La section 5 a utilisé
orTimeoutpour appliquer une limite de 100 ms à une tâche de 500 ms. Le future s'est complété de manière exceptionnelle avecTimeoutException; lejoinl'a relancé dans uneCompletionException. C'est la bonne forme pour « je veux ce résultat, mais seulement s'il arrive assez vite. » - La section 6 a utilisé
handlepour se brancher sur le succès ou l'échec en une seule étape.handles'exécute toujours et reçoit(value, ex)— l'un est null. Utile quand vous voulez une fin de pipeline uniforme qu'importe si le travail a réussi ou non.
Et ensuite
Le prochain chapitre, Java Fork/Join, couvre le ForkJoinPool — le pool à vol de travail qui alimente les streams parallèles et le pool commun de CompletableFuture, et l'outil idéal pour le travail CPU de type diviser-pour-régner.