CompletableFutureとは、Javaで「時間のかかる処理を別スレッドで動かし、終わった結果を後からつなげて使う」ためのクラスです。 ExecutorService(スレッドの貸し出し係)と組み合わせると、在庫の取得と会員情報の取得を同時に走らせて、両方そろったら合算する、といった処理を短いコードで書けます。

この記事では、すべてのコードを Java 21(OpenJDK 21.0.10)で実際に動かし、スレッド名と所要時間つきの出力をそのまま載せています。 「Executorを省略するとどのスレッドで動くのか」「固定プールと仮想スレッドで1万タスクの所要時間はどれだけ違うのか」「exceptionally に届く例外はなぜ CompletionException なのか」を、出力を見ながら確かめられます。最後に、join と get の違いや「プロセスが終わらない」原因など、つまずきやすい点もまとめました。

CompletableFutureの早見表

まずはやりたいこと別の早見表です。これだけ見れば、どのメソッドを使えばよいか決められます。

やりたいこと メソッド 引数の関数 戻り値 ポイント
値を返す処理を非同期で始める supplyAsync(supplier[, executor]) () -> T CF<T> Executor省略時は commonPool
値を返さない処理を非同期で始める runAsync(runnable[, executor]) () -> void CF<Void> 通知・ログ書き込みなど
結果を変換する thenApply(fn) T -> U CF<U> Stream の map に近い
結果を使って次の非同期処理につなぐ thenCompose(fn) T -> CF<U> CF<U> flatMap に近い。入れ子を防ぐ
2つの結果を合わせる thenCombine(other, fn) (T, U) -> V CF<V> 2つは並列に動く
結果を受け取って使うだけ thenAccept(consumer) T -> void CF<Void> 画面表示・保存など
全部の完了を待つ allOf(cf...) ― CF<Void> 値は各 CF の join で取り出す
どれか1つの完了を待つ anyOf(cf...) ― CF<Object> 型は Object になる
時間切れで失敗させる orTimeout(time, unit) ― CF<T> TimeoutException で失敗
時間切れで既定値にする completeOnTimeout(value, time, unit) ― CF<T> 失敗にせず代わりの値
失敗したときだけ代わりの値 exceptionally(fn) Throwable -> T CF<T> 成功時は呼ばれない
成功・失敗どちらでも結果を作り直す handle(fn) (T, Throwable) -> U CF<U> 片方は null で届く
成功・失敗どちらでも後処理だけ whenComplete(action) (T, Throwable) -> void CF<T> 結果は変わらない(ログ用)
結果を受け取る(待つ) join() / get() ― T 例外の型が違う(後述)
待ち時間に上限をつける get(time, unit) ― T TimeoutException を投げる

※ 表の CF は CompletableFuture の略です。thenApplyAsync のように末尾に Async がつく版は、次の処理を別スレッドで動かす版です(後で実行結果で確認します)。

ExecutorService の作り方も早見表にしておきます。

作り方 スレッド数 向いている処理 1,000件×100ms 待ちの所要(実測)
Executors.newFixedThreadPool(n) 常に n 本 CPUを使う計算、同時実行数を絞りたい処理 10,042ms(10本)
Executors.newCachedThreadPool() 必要に応じて増減(上限なし) 短い非同期処理が散発的に来る場合 393ms(886本)
Executors.newScheduledThreadPool(n) n 本 一定間隔・遅延実行 ―(定期実行で確認)
Executors.newVirtualThreadPerTaskExecutor() タスクごとに仮想スレッド1本 待ち時間が長い I/O 処理を大量に 151ms(1,000本)
省略(ForkJoinPool.commonPool()) CPU数−1(最低1) 短い計算処理 100件で 3,410ms(並列度3)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CompletableFuture入門|Java21の並列処理と例外処理コード集」

動作環境と実行方法

この記事のコードは、外部ライブラリを使わず 1ファイルのまま java ファイル名.java で実行できます(Java 11 以降のソースファイル直接実行機能)。

項目 内容
Java OpenJDK 21.0.10(Ubuntu 24.04 パッケージ)
実行コマンド java -Dstdout.encoding=UTF-8 -XX:ActiveProcessorCount=4 ファイル名.java
CPU 検証環境は2コア。commonPool の動きをわかりやすくするため、-XX:ActiveProcessorCount=4 で4コア扱いにしています

-Dstdout.encoding=UTF-8 は日本語の出力が ?? に化けるのを防ぐ指定です。CompletableFuture 自体は Java 8 から、orTimeout と completeOnTimeout は Java 9 から、ExecutorService の try-with-resources(close())は Java 19 から、仮想スレッドは Java 21 から使えます。

基本:supplyAsyncで非同期処理を始めて結果を受け取る

最初に、Executorを省略した場合と指定した場合で、どのスレッドで動くかを確かめます。

import java.util.concurrent.*;

public class Basics {
    static final long START = System.currentTimeMillis();

    static void log(String msg) {
        System.out.printf("[%4dms] %-28s %s%n",
                System.currentTimeMillis() - START, Thread.currentThread().getName(), msg);
    }

    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    public static void main(String[] args) {
        log("main 開始");

        // 1) Executor を指定しない → ForkJoinPool.commonPool() で動く
        CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> {
            log("A: 在庫を取得中");
            sleep(300);
            return "在庫=12";
        });

        // 2) Executor を指定する → 自分で作ったスレッドで動く
        try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
            CompletableFuture<String> b = CompletableFuture.supplyAsync(() -> {
                log("B: 会員を取得中");
                sleep(300);
                return "会員=佐藤";
            }, pool);

            String result = a.thenCombine(b, (x, y) -> x + " / " + y).join();
            log("結果: " + result);
        } // ← ここで close() が呼ばれ、全タスクの終了を待ってからプールを閉じる
        log("main 終了  commonPool並列度=" + ForkJoinPool.commonPool().getParallelism()
                + " CPU=" + Runtime.getRuntime().availableProcessors());
    }
}

実行結果(-XX:ActiveProcessorCount=4):

[   1ms] main                         main 開始
[  34ms] ForkJoinPool.commonPool-worker-1 A: 在庫を取得中
[  42ms] pool-1-thread-1              B: 会員を取得中
[ 349ms] main                         結果: 在庫=12 / 会員=佐藤
[ 353ms] main                         main 終了  commonPool並列度=3 CPU=4

300ms かかる処理を2つ動かして、合計は約350ms です。 A と B が同時に動いたことがわかります。A は ForkJoinPool.commonPool-worker-1、B は自分で作った pool-1-thread-1 で動いています。

CPUが1〜2コアの環境ではスレッド名が変わる

同じコードを、オプションなし(検証環境の2コアのまま)で動かすと次のようになりました。

[   1ms] main                         main 開始
[  15ms] Thread-0                     A: 在庫を取得中
[  19ms] pool-1-thread-1              B: 会員を取得中
[ 328ms] main                         結果: 在庫=12 / 会員=佐藤
[ 334ms] main                         main 終了  commonPool並列度=1 CPU=2

A が Thread-0 で動いています。commonPool の並列度は「CPU数−1」なので、2コアでは1になります。CompletableFuture は並列度が2未満のとき、commonPool を使わずタスクごとに新しいスレッドを作る仕様です(Javadoc に記載があります)。コンテナや小さなクラウドインスタンスでは CPU 数が少なく見えることが多いので、本番コードでは Executor を明示的に渡すと動きが環境に左右されにくくなります。

つなぐ・合わせる・待つ:メソッドごとの実行結果

早見表のメソッドを1つのファイルでまとめて動かします。

import java.util.List;
import java.util.concurrent.*;

public class Chain {
    static final long START = System.currentTimeMillis();
    static void log(String msg) {
        System.out.printf("[%4dms] %-34s %s%n",
                System.currentTimeMillis() - START, Thread.currentThread().getName(), msg);
    }
    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
    // 会員IDから会員名を取ってくる(時間のかかる処理のつもり)
    static CompletableFuture<String> findMember(int id, ExecutorService ex) {
        return CompletableFuture.supplyAsync(() -> { sleep(200); return "会員" + id; }, ex);
    }

    public static void main(String[] args) {
        try (ExecutorService ex = Executors.newFixedThreadPool(4)) {
            // thenApply:値を変換(同期関数)
            String upper = CompletableFuture.supplyAsync(() -> "order-001", ex)
                    .thenApply(String::toUpperCase)
                    .join();
            log("thenApply → " + upper);

            // thenCompose:CompletableFuture を返す関数をつなぐ(入れ子にしない)
            CompletableFuture<String> composed = CompletableFuture.supplyAsync(() -> 42, ex)
                    .thenCompose(id -> findMember(id, ex));
            log("thenCompose → " + composed.join());

            // thenApply で同じことをすると入れ子になる
            CompletableFuture<CompletableFuture<String>> nested =
                    CompletableFuture.supplyAsync(() -> 42, ex).thenApply(id -> findMember(id, ex));
            log("thenApply(入れ子) → " + nested.join().getClass().getSimpleName());

            // thenCombine:2つを並列に走らせて合流
            long t0 = System.currentTimeMillis();
            var price = CompletableFuture.supplyAsync(() -> { sleep(300); return 1200; }, ex);
            var qty   = CompletableFuture.supplyAsync(() -> { sleep(300); return 3; }, ex);
            int total = price.thenCombine(qty, (p, q) -> p * q).join();
            log("thenCombine → " + total + "(" + (System.currentTimeMillis() - t0) + "ms)");

            // allOf:全部待つ(戻り値は Void なので join で取り出す)
            t0 = System.currentTimeMillis();
            List<CompletableFuture<String>> list = List.of(1, 2, 3, 4).stream()
                    .map(id -> findMember(id, ex)).toList();
            CompletableFuture.allOf(list.toArray(CompletableFuture[]::new)).join();
            List<String> names = list.stream().map(CompletableFuture::join).toList();
            log("allOf → " + names + "(" + (System.currentTimeMillis() - t0) + "ms)");

            // anyOf:最初に終わった1つ(型は Object)
            Object first = CompletableFuture.anyOf(
                    CompletableFuture.supplyAsync(() -> { sleep(500); return "倉庫A"; }, ex),
                    CompletableFuture.supplyAsync(() -> { sleep(100); return "倉庫B"; }, ex)).join();
            log("anyOf → " + first);

            // orTimeout:時間内に終わらなければ TimeoutException で失敗させる
            try {
                CompletableFuture.supplyAsync(() -> { sleep(1000); return "遅い応答"; }, ex)
                        .orTimeout(200, TimeUnit.MILLISECONDS).join();
            } catch (CompletionException e) {
                log("orTimeout → " + e.getCause());
            }

            // completeOnTimeout:時間内に終わらなければ既定値で完了させる
            String v = CompletableFuture.supplyAsync(() -> { sleep(1000); return "遅い応答"; }, ex)
                    .completeOnTimeout("キャッシュの値", 200, TimeUnit.MILLISECONDS).join();
            log("completeOnTimeout → " + v);

            // thenApply と thenApplyAsync で動くスレッドの違い
            CompletableFuture.supplyAsync(() -> { sleep(50); return 1; }, ex)
                    .thenApply(x -> { log("thenApply は前段と同じスレッド"); return x; })
                    .thenApplyAsync(x -> { log("thenApplyAsync(Executor省略)は commonPool"); return x; })
                    .thenApplyAsync(x -> { log("thenApplyAsync(fn, ex) は指定プール"); return x; }, ex)
                    .join();
        }
    }
}

実行結果:

[  11ms] main                               thenApply → ORDER-001
[ 232ms] main                               thenCompose → 会員42
[ 236ms] main                               thenApply(入れ子) → CompletableFuture
[ 554ms] main                               thenCombine → 3600(303ms)
[ 764ms] main                               allOf → [会員1, 会員2, 会員3, 会員4](204ms)
[ 869ms] main                               anyOf → 倉庫B
[1074ms] main                               orTimeout → java.util.concurrent.TimeoutException
[1275ms] main                               completeOnTimeout → キャッシュの値
[1327ms] pool-1-thread-3                    thenApply は前段と同じスレッド
[1330ms] ForkJoinPool.commonPool-worker-1   thenApplyAsync(Executor省略)は commonPool
[1331ms] pool-1-thread-4                    thenApplyAsync(fn, ex) は指定プール

出力から読み取れるポイントです。

確認したこと 出力 読み取れること
thenCompose と thenApply 会員42 と CompletableFuture CF を返す関数は thenCompose でつなぐ。 thenApply だと CF<CF<String>> の入れ子になる
thenCombine 300ms×2 が 303ms 2つの処理は並列に動いている
allOf 200ms×4 が 204ms 4本のプールで同時に動いた。値は各 CF の join() で取り出す
anyOf 倉庫B 速く終わった方が返る(遅い方は止まらずに動き続ける)
orTimeout TimeoutException join() からは CompletionException に包まれて届く
thenApply / thenApplyAsync スレッド名が変わる Async なしは前段と同じスレッド、Async で Executor 省略は commonPool

thenApplyAsync で Executor を省略すると、最初に専用プールを渡していても commonPool に戻ります。 仮想スレッドや専用プールで処理したい場合は、Async 版にも Executor を渡すのが確実です。

allOfで結果をリストにまとめる定番パターン

allOf の戻り値は CompletableFuture<Void> なので、そのままでは値が取れません。次の形で List<T> を返す CF にしておくと使い回せます。

static <T> CompletableFuture<List<T>> allAsList(List<CompletableFuture<T>> fs) {
    return CompletableFuture.allOf(fs.toArray(CompletableFuture[]::new))
            .thenApply(v -> fs.stream().map(CompletableFuture::join).toList());
}

allOf が完了した後の join() は待たずにすぐ値を返すので、ここでの join はブロックしません。

ExecutorServiceの選び方:固定・キャッシュ・スケジュール・仮想スレッド

次に、「100ms 待つだけ」の I/O 風タスクを大量に流し、Executor ごとの所要時間と使ったスレッド数を測ります。

import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.concurrent.*;
import java.util.function.Supplier;

public class Executors10k {
    static void sleep(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    // 「100ms 待つだけ」の I/O 風タスクを n 件流して、所要時間と使ったスレッド数を測る
    static void run(String label, Supplier<ExecutorService> factory, int n) {
        Set<String> threads = ConcurrentHashMap.newKeySet();
        long t0 = System.nanoTime();
        try (ExecutorService ex = factory.get()) {
            List<CompletableFuture<Integer>> fs = new ArrayList<>();
            for (int i = 0; i < n; i++) {
                int id = i;
                fs.add(CompletableFuture.supplyAsync(() -> {
                    Thread t = Thread.currentThread();
                    threads.add(t.isVirtual() ? "virtual#" + t.threadId() : t.getName());
                    sleep(100);
                    return id;
                }, ex));
            }
            CompletableFuture.allOf(fs.toArray(CompletableFuture[]::new)).join();
        }
        long ms = (System.nanoTime() - t0) / 1_000_000;
        System.out.printf("%-38s 件数=%,6d 所要=%,6dms スレッド数=%,6d%n", label, n, ms, threads.size());
    }

    public static void main(String[] args) {
        System.out.println("Java " + Runtime.version() + " / CPU=" + Runtime.getRuntime().availableProcessors());
        run("newFixedThreadPool(10)", () -> Executors.newFixedThreadPool(10), 1_000);
        run("newCachedThreadPool()", Executors::newCachedThreadPool, 1_000);
        run("newVirtualThreadPerTaskExecutor()", Executors::newVirtualThreadPerTaskExecutor, 1_000);
        run("newFixedThreadPool(10)", () -> Executors.newFixedThreadPool(10), 10_000);
        run("newVirtualThreadPerTaskExecutor()", Executors::newVirtualThreadPerTaskExecutor, 10_000);

        // commonPool(Executor 省略)の場合
        long t0 = System.nanoTime();
        List<CompletableFuture<Void>> fs = new ArrayList<>();
        for (int i = 0; i < 100; i++) fs.add(CompletableFuture.runAsync(() -> sleep(100)));
        CompletableFuture.allOf(fs.toArray(CompletableFuture[]::new)).join();
        System.out.printf("%-38s 件数=%,6d 所要=%,6dms 並列度=%d%n", "commonPool(Executor省略)", 100,
                (System.nanoTime() - t0) / 1_000_000, ForkJoinPool.commonPool().getParallelism());

        // スケジュール実行
        try (ScheduledExecutorService sch = Executors.newScheduledThreadPool(1)) {
            long s = System.currentTimeMillis();
            ScheduledFuture<?> f = sch.scheduleAtFixedRate(
                    () -> System.out.printf("  定期実行 %dms後 %s%n", System.currentTimeMillis() - s,
                            Thread.currentThread().getName()), 0, 200, TimeUnit.MILLISECONDS);
            sleep(650);
            f.cancel(false); // 定期タスクを明示的に止める
        }
    }
}

実行結果:

Java 21.0.10+7-Ubuntu-124.04 / CPU=4
newFixedThreadPool(10)                 件数= 1,000 所要=10,042ms スレッド数=    10
newCachedThreadPool()                  件数= 1,000 所要=   393ms スレッド数=   886
newVirtualThreadPerTaskExecutor()      件数= 1,000 所要=   151ms スレッド数= 1,000
newFixedThreadPool(10)                 件数=10,000 所要=100,241ms スレッド数=    10
newVirtualThreadPerTaskExecutor()      件数=10,000 所要=   211ms スレッド数=10,000
commonPool(Executor省略)                 件数=   100 所要= 3,410ms 並列度=3
  定期実行 2ms後 pool-4-thread-1
  定期実行 202ms後 pool-4-thread-1
  定期実行 402ms後 pool-4-thread-1
  定期実行 602ms後 pool-4-thread-1

結果の読み方

  • 固定プール(10本)は「件数÷10×100ms」どおりです。1,000件で約10秒、1万件で約100秒。同時実行数がスレッド数で決まることがそのまま数字に出ています。
  • キャッシュプールは886本のスレッドを作って393msでした。速いものの、スレッド数に上限がないため、件数が増えるとその分 OS スレッドが増えます。
  • 仮想スレッドは1万タスクを211msで終えました。待ち時間(sleep や I/O)の間はキャリアスレッド(実際に動かす OS スレッド)を手放すため、1万本でも軽く動きます。
  • Executor 省略(commonPool)は並列度3なので、100件で約3.4秒です。commonPool は CPU を使う短い計算向けの共有プールなので、待ち時間の長い処理を流すと他の処理も待たされます。

Executorの判断表

処理の性質 おすすめ 理由
HTTP呼び出し・DBアクセスなど待ちが長い処理を大量に newVirtualThreadPerTaskExecutor() 1万件でも211ms。スレッド数を気にせず書ける
CPUを使う計算(画像処理・集計) newFixedThreadPool(CPU数) または commonPool CPU数を超えて増やしても速くならない
外部サービスへの同時接続数を絞りたい 固定プール、または仮想スレッド+Semaphore 同時実行数を明示的に制御できる
一定間隔の処理・遅延実行 newScheduledThreadPool(n) scheduleAtFixedRate / schedule が使える
散発的な短い処理 newCachedThreadPool() 使わないスレッドは60秒で回収される

仮想スレッドの仕組みや pinning(キャリアスレッドへの固定)など、仮想スレッドそのものの詳しい話は「Java 仮想スレッド入門」の記事で扱っています。ここでは CompletableFuture の第2引数に newVirtualThreadPerTaskExecutor() を渡せば、そのまま仮想スレッドで動くことを押さえておけば十分です。

try-with-resourcesでcloseする(Java 19以降)

Java 19 から ExecutorService は AutoCloseable になり、close() が追加されました。close() は shutdown() してから全タスクの終了を待つので、try-with-resources で囲むだけで後片付けが完了します。

書き方 対応バージョン 動き
try (var ex = Executors.newFixedThreadPool(4)) { ... } Java 19以降 ブロックを抜けると shutdown → 終了待ち
ex.shutdown(); ex.awaitTermination(1, TimeUnit.MINUTES); Java 5以降 新規受付を止め、指定時間まで終了を待つ
ex.shutdownNow(); Java 5以降 実行中のタスクに割り込み(interrupt)を送る

Java 17 以前でも動くコードにする場合は、finally で shutdown() と awaitTermination() を呼ぶ形にします。

例外処理:exceptionally・handle・whenCompleteの違い

非同期処理で投げた例外は、呼び出し元の try-catch には直接届きません。例外は CompletableFuture の中に「失敗した結果」として保存され、後段のメソッドに渡されます。

import java.util.concurrent.*;

public class Errors {
    static CompletableFuture<Integer> stock(boolean fail) {
        return CompletableFuture.supplyAsync(() -> {
            if (fail) throw new IllegalStateException("在庫サービスが応答しません");
            return 12;
        });
    }

    public static void main(String[] args) throws InterruptedException {
        // 1) exceptionally:失敗したときだけ呼ばれ、代わりの値を返す
        int a = stock(true)
                .exceptionally(ex -> {
                    System.out.println("exceptionally 受け取った例外: " + ex);
                    return 0;
                }).join();
        System.out.println("exceptionally の結果: " + a);

        // 2) handle:成功でも失敗でも呼ばれ、結果を作り直せる
        for (boolean fail : new boolean[]{false, true}) {
            String h = stock(fail).handle((v, ex) ->
                    ex == null ? "在庫あり:" + v : "在庫不明(" + ex.getCause().getMessage() + ")").join();
            System.out.println("handle fail=" + fail + " → " + h);
        }

        // 3) whenComplete:成功でも失敗でも呼ばれるが、結果は変えられない(ログ用)
        try {
            stock(true).whenComplete((v, ex) -> System.out.println("whenComplete v=" + v + " ex=" + ex)).join();
        } catch (CompletionException e) {
            System.out.println("whenComplete の後も失敗のまま → " + e.getClass().getSimpleName());
        }

        // 4) join と get の違い
        try {
            stock(true).join();
        } catch (CompletionException e) {
            System.out.println("join  → " + e.getClass().getName() + " / cause=" + e.getCause().getClass().getSimpleName());
        }
        try {
            stock(true).get();
        } catch (ExecutionException e) {
            System.out.println("get   → " + e.getClass().getName() + " / cause=" + e.getCause().getClass().getSimpleName());
        }

        // 5) 自分で失敗させた future は包まれない/チェーンの後段では包まれる
        CompletableFuture<Integer> failed = CompletableFuture.failedFuture(new IllegalArgumentException("ID不正"));
        failed.exceptionally(ex -> { System.out.println("直接: " + ex.getClass().getSimpleName()); return 0; }).join();
        failed.thenApply(x -> x + 1)
              .exceptionally(ex -> { System.out.println("後段: " + ex.getClass().getSimpleName()
                      + " → cause=" + ex.getCause().getClass().getSimpleName()); return 0; }).join();

        // 6) 例外を握りつぶす例:結果を誰も見ないと何も表示されない
        CompletableFuture.runAsync(() -> { throw new RuntimeException("誰にも気づかれない例外"); });
        Thread.sleep(200);
        System.out.println("6) 何も出力されずに終了");
    }
}

実行結果:

exceptionally 受け取った例外: java.util.concurrent.CompletionException: java.lang.IllegalStateException: 在庫サービスが応答しません
exceptionally の結果: 0
handle fail=false → 在庫あり:12
handle fail=true → 在庫不明(在庫サービスが応答しません)
whenComplete v=null ex=java.util.concurrent.CompletionException: java.lang.IllegalStateException: 在庫サービスが応答しません
whenComplete の後も失敗のまま → CompletionException
join  → java.util.concurrent.CompletionException / cause=IllegalStateException
get   → java.util.concurrent.ExecutionException / cause=IllegalStateException
直接: IllegalArgumentException
後段: CompletionException → cause=IllegalArgumentException
6) 何も出力されずに終了

3つのメソッドの比較表

メソッド 呼ばれるとき 受け取るもの 結果を変えられるか 主な用途
exceptionally(fn) 失敗したときだけ Throwable できる(代わりの値) 既定値・フォールバック
handle(fn) 成功・失敗どちらも (値, 例外) 片方は null できる(別の型にもできる) 成功・失敗をまとめて1つの結果に
whenComplete(action) 成功・失敗どちらも (値, 例外) 片方は null できない(そのまま流す) ログ・計測・後片付け

whenComplete は例外を処理済みにしません。 出力のとおり、whenComplete の後に join すると CompletionException のままです。失敗を回復させたいときは exceptionally か handle を使います。

例外がCompletionExceptionで包まれる理由

出力の1行目のとおり、supplyAsync の中で投げた IllegalStateException は、後段には CompletionException に包まれて届きます。一方、failedFuture で直接失敗させた CF に exceptionally を付けると、元の例外がそのまま届きました。

状況 exceptionally / handle に届く例外
supplyAsync などの中で投げた CompletionException(cause に元の例外)
failedFuture(e) / completeExceptionally(e) で直接失敗させた CF に付けた 元の例外そのまま
失敗した CF の後に thenApply などをはさんだ CompletionException(cause に元の例外)
orTimeout で時間切れ TimeoutException(join では CompletionException に包まれる)

どちらで届いても扱えるように、包みを外すヘルパーを1つ用意しておくと安心です。

import java.util.concurrent.*;

public class Unwrap {
    // CompletionException / ExecutionException を外して元の例外を取り出す
    static Throwable unwrap(Throwable t) {
        while ((t instanceof CompletionException || t instanceof ExecutionException) && t.getCause() != null) {
            t = t.getCause();
        }
        return t;
    }
    public static void main(String[] args) {
        try {
            CompletableFuture.supplyAsync(() -> { throw new IllegalStateException("在庫0"); })
                    .thenApply(x -> x).join();
        } catch (CompletionException e) {
            Throwable root = unwrap(e);
            switch (root) {
                case IllegalStateException ise -> System.out.println("在庫エラーとして処理: " + ise.getMessage());
                default -> System.out.println("その他: " + root);
            }
        }
        // get(timeout):待ち時間に上限をつける(future 自体は動き続ける)
        var slow = CompletableFuture.supplyAsync(() -> { try { Thread.sleep(1000); } catch (InterruptedException e) {} return "OK"; });
        try {
            slow.get(100, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            System.out.println("get(100ms) → TimeoutException / isDone=" + slow.isDone());
        } catch (Exception e) { throw new RuntimeException(e); }
    }
}

実行結果:

在庫エラーとして処理: 在庫0
get(100ms) → TimeoutException / isDone=false

switch のパターンマッチ(case IllegalStateException ise ->)は Java 21 で正式機能になった書き方です。get(time, unit) は「待つのをやめる」だけで、タスク自体は止まらない(isDone=false)点も覚えておきましょう。タスクの結果自体をタイムアウトさせたい場合は orTimeout を使います。

つまずきやすいポイントと直し方

joinとgetの違い

項目 join() get()
失敗時に投げる例外 CompletionException(非検査例外) ExecutionException(検査例外)
待機中の割り込み 割り込みで中断しない InterruptedException を投げる
throws 宣言 不要。ラムダや Stream の中で使いやすい 必要。try-catch が増える
タイムアウト付き なし(orTimeout と組み合わせる) get(time, unit) がある

ラムダや Stream の中で結果を取り出すなら join、割り込みに応じたい・待ち時間に上限をつけたいなら get と使い分けると迷いません。

ExecutorServiceを閉じないとプロセスが終わらない

import java.util.concurrent.*;

public class Forget {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2); // close も shutdown もしない
        String r = CompletableFuture.supplyAsync(() -> "タスク完了", pool).join();
        System.out.println(r + " / main メソッドはここで終わり");
    }
}

timeout 5 を付けて実行した結果:

$ timeout 5 java -Dstdout.encoding=UTF-8 Forget.java; echo "exit=$?"
タスク完了 / main メソッドはここで終わり
exit=124

main メソッドの最後まで進んでいるのに、5秒後に timeout コマンドで止められるまでプロセスが終わりませんでした(exit=124 は timeout で止められたことを表します)。固定プールのスレッドは非デーモンスレッドで、次のタスクを待ち続けるため JVM が終了しないのが理由です。

直す前 直した後
ExecutorService pool = Executors.newFixedThreadPool(2); を作りっぱなし try (ExecutorService pool = Executors.newFixedThreadPool(2)) { ... } で囲む
アプリ全体で共有するプールを閉じていない アプリ終了処理(シャットダウンフック、フレームワークの破棄処理)で close() / shutdown()

Executor を省略した commonPool のスレッドはデーモンスレッドなので、この現象は起きません。その代わり、main が先に終わると commonPool のタスクは途中で打ち切られるので、結果は必ず join などで待ちます。

待ち時間の長い処理をcommonPoolで動かす

先の実測のとおり、commonPool は並列度3(4コア時)で、100件の100ms待ちに3.4秒かかりました。commonPool は parallel Stream なども共有しているため、I/O 待ちの処理は専用の Executor(できれば仮想スレッド)を第2引数に渡すのが基本です。

直す前 直した後
supplyAsync(() -> callApi(id)) supplyAsync(() -> callApi(id), vexec)(vexec は newVirtualThreadPerTaskExecutor())
thenApplyAsync(fn) で I/O thenApplyAsync(fn, vexec)

非同期の中でjoinして待ち合わせる

thenApply の中で別の CF を join() すると、そのスレッドが待ち状態で占有されます。CF を返す処理は thenCompose、2つを合わせるなら thenCombine、複数なら allOf でつなぎ、join は最後に1回だけにするとスレッドを有効に使えます。

例外を誰も受け取らない

実行結果の「6) 何も出力されずに終了」のとおり、runAsync の中で投げた例外は、結果を誰も見なければ表示もされません。 戻り値の CF を捨てずに、最後に exceptionally や whenComplete でログを出すようにしておくと、失敗に気づけます。

つまずきチェックリスト

チェック項目 確認方法
Executor を第2引数に渡しているか supplyAsync(..., ex)、thenApplyAsync(..., ex)
ExecutorService を閉じているか try-with-resources か終了処理で close()
戻り値の CF を捨てていないか 最後に join / exceptionally / whenComplete がある
CF を返す関数を thenApply でつないでいないか 型が CF<CF<T>> になっていたら thenCompose
例外の型を想定しているか CompletionException の cause を見る
タイムアウトを設定しているか orTimeout / completeOnTimeout

よくある質問

Q. CompletableFutureとFutureの違いは何ですか? A. Future は「結果を get で待って受け取る」だけですが、CompletableFuture は thenApply や thenCombine で結果を待たずに次の処理をつなげ、例外処理やタイムアウトもメソッドで書けます。 CompletableFuture は Future インターフェースも実装しているので、get もそのまま使えます。

Q. 仮想スレッドがあればCompletableFutureは不要ですか? A. 仮想スレッドでは「1件ずつ同期的に書いて、大量に並べる」書き方がしやすくなりました。一方で、複数の結果を合わせる・最初の1つを使う・時間切れで既定値にする、といった組み合わせは CompletableFuture のメソッドが便利です。両者は組み合わせて使えて、supplyAsync(..., Executors.newVirtualThreadPerTaskExecutor()) で仮想スレッド上で動かせます。

Q. thenApplyとthenApplyAsyncはどちらを使えばよいですか? A. 変換処理が軽いなら thenApply で十分です。重い処理や I/O を含むなら thenApplyAsync に Executor を渡して別スレッドで動かします。Executor を省略した Async 版は commonPool で動く点に注意します。

Q. allOfで1つが失敗したらどうなりますか? A. allOf は全部の完了を待ったうえで、どれか1つでも失敗していれば失敗(CompletionException)として完了します。失敗したものだけ既定値にしたい場合は、allOf に渡す前に各 CF に exceptionally を付けておきます。

まとめ

  • CompletableFuture は「非同期で動かして、結果をつなげる」クラス。supplyAsync で始め、thenApply・thenCompose・thenCombine でつなぐ
  • Executor は第2引数で明示する。省略すると commonPool(2コア以下ではタスクごとの新スレッド)で動く
  • I/O 待ちの大量処理は仮想スレッド。実測で1万タスク×100ms が211ms、固定10本では約100秒
  • ExecutorService は try-with-resources で close。閉じ忘れると main が終わってもプロセスが終わらない
  • 例外は CompletionException に包まれて届く。exceptionally・handle で回復、whenComplete はログ用
  • join は非検査例外、get は検査例外と割り込み対応。待ち時間の上限は orTimeout / get(time, unit)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CompletableFuture入門|Java21の並列処理と例外処理コード集」

株式会社RJCでは、Javaの非同期処理を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?