本ページはプロモーションが含まれています

Java入門

Java非同期処理とは?基本と4つの主要手法を解説

トム

・都内自社開発IT企業勤務/javaのバックエンドエンジニア
/java歴10年以上 ・首都圏在住30代
・資格:基本情報技術者/応用情報技術者/Java Silver/Python3エンジニア認定基礎

Javaでの開発において、「処理が遅い」「応答性が悪い」と感じたことはありませんか。私自身、JavaでWebアプリケーション開発に10年以上携わってきましたが、特にユーザー数が増加するにつれて、システムのパフォーマンス問題に直面することが多々ありました。

多くのケースで、その解決策となったのが「Javaの非同期処理」の導入です。しかし、非同期処理は強力な反面、仕組みが複雑でつまずきやすい分野でもあります。

この記事は、「Javaの非同期処理がよくわからない」「どうやって実装すればいいか知りたい」という方に向けて書きました。

この記事を読めば、Java非同期処理の基本概念から、Threadを使った基本的な方法、そしてモダンなCompletableFutureまで、主要な4つの手法と実践的な注意点をしっかり学べます。

Javaの非同期処理とは?【基本をわかりやすく解説】

Javaの非同期処理は、システムのパフォーマンスと応答性を向上させるために不可欠な技術です。まずは、その基本的な考え方から見ていきましょう。

非同期処理と同期処理の違い

私たちのプログラムは通常、「同期処理」で動いています。

同期処理とは、タスクAが終わるまでタスクBを開始しない、直線的な処理方法です。スーパーのレジで、前の人の会計が完全に終わるまで、自分の会計が始まらないのと同じです。シンプルで分かりやすい反面、時間のかかる処理(タスクA)があると、後続の処理(タスクB)がすべて待たされてしまいます。

対して非同期処理は、タスクAの完了を待たずに、先にタスクBを開始できる処理方法を指します。レストランで料理を注文(タスクA)した後、ウェイターは料理が出来上がるのを待たずに、別のテーブルの注文を取りに行きます(タスクB)。

Javaの非同期処理では、時間のかかる処理を別の作業員(スレッド)に任せ、メインの作業員は待たずに次の仕事に進むイメージです。これにより、システム全体としての処理能力(スループット)が向上します。

なぜ非同期処理が必要なのか

非同期処理の最大の目的は、ユーザー体験の向上システムリソースの有効活用です。

Webアプリケーションで、ボタンを押した後に重い処理が同期的に実行されると、ユーザーは処理が終わるまで画面が固まった(フリーズした)状態で待たなければなりません。これは大きなストレスです。

Java非同期処理を使えば、重い処理をバックグラウンドで実行させ、ユーザーにはすぐに「処理を受け付けました」と応答を返せます。

また、特にI/O処理(ファイルの読み書き、データベースアクセス、外部API呼び出し)では、CPUはデータの到着を待っている「待機時間」が多く発生します。同期処理ではこの待機時間もCPUを占有しますが、非同期処理なら、待機時間に別のタスクをCPUに実行させることが可能。リソースを無駄なく使い切れるのです。

Javaでの非同期処理の主な仕組み

Javaにおける非同期処理は、主に「スレッド」という仕組みによって実現されます。

スレッドとは、プログラム内での「処理の流れ」の単位です。通常のプログラムは1つの流れ(メインスレッド)で動きますが、Javaは複数のスレッドを同時に実行できる「マルチスレッド」に対応しています。

Javaの非同期処理では、メインスレッドとは別に新しいスレッドを作成し、時間のかかる処理をそちらに任せます。

Java 1.0の時代からThreadクラスが提供されていましたが、スレッドの管理が煩雑でした。Java 5でExecutorServiceが、そしてJava 8でCompletableFutureが登場し、Javaの非同期処理は格段に扱いやすく進化しています。

Javaの非同期処理で使われる主要な手法

Javaで非同期処理を実現するには、いくつかの方法があります。ここでは代表的な4つの手法を、進化の順に解説します。

スレッド(Thread)を使った非同期処理

最も基本的かつ原始的な方法が、ThreadクラスやRunnableインタフェースを直接使うやり方です。

Runnableは「実行したい処理」を定義するインタフェースで、Threadクラスにその処理を渡してstart()メソッドを呼び出すと、新しいスレッドが起動して処理が非同期で実行されます。

この方法はJava非同期処理の土台を理解するのに役立ちます。しかし、処理のたびにスレッドを生成・破棄するため、そのコストが無視できません。また、スレッドの数を管理しないと、リソースを使い果たしてシステムダウンにつながる危険もあります。

ExecutorServiceによるタスク管理

スレッド管理の煩雑さを解決するために、Java 5でExecutorService(実行サービス)が導入されました。

ExecutorServiceは、「スレッドプール」という仕組みを提供します。スレッドプールは、あらかじめ決められた数のスレッドを作成して待機させておく「スレッドの箱」のようなものです。

非同期で実行したいタスクが発生すると、ExecutorServiceがスレッドプールの空いているスレッドにタスクを割り当てます。処理が終わったスレッドは破棄されず、プールに戻って次のタスクを待ちます。

これにより、スレッド生成のコストがなくなり、システム全体のスレッド数を適切に管理できます。現代のJava非同期処理において、スレッドを直接扱うことは稀で、このExecutorServiceを使うのが一般的です。

FutureとCallableを使った結果の取得

Runnableは処理を実行するだけで、結果を返すことができませんでした。非同期処理の結果が欲しい場合もあります。

そこで使われるのがCallableインタフェースとFutureインタフェースです。

  • Callable: Runnableと似ていますが、処理結果を返すことができ、例外をスローできます。
  • Future: ExecutorServiceCallableを渡してsubmit()すると返ってくるオブジェクト。「非同期処理の結果引換券」とイメージすると分かりやすいです。

Futureget()メソッドを呼び出すと、非同期処理の結果を取得できます。ただし、このget()は、処理が完了するまで呼び出し元のスレッドをブロック(待機)させてしまう点に注意が必要です。処理が終わっていなければ、結局そこで待たされてしまいます。

CompletableFutureによるモダンな非同期処理

Futureの「get()でブロックしてしまう」という弱点を克服し、さらに柔軟な非同期処理を実現するために、Java 8でCompletableFutureが登場しました。

CompletableFutureは、Java非同期処理の集大成ともいえる機能です。

CompletableFutureを使うと、「処理Aが終わったら、その結果を使って処理Bを実行し、Bが終わったらCを実行する」といった、処理の依存関係を流れるように(メソッドチェーンで)記述できます。

また、「処理Aと処理Bを並列に実行し、両方が終わったらその結果を合体させて処理Cを実行する」といった複雑な非同期処理の合成も簡単です。

get()でブロックする代わりに、コールバック(処理が終わったら呼び出される処理)を登録するスタイルで記述するため、スレッドを無駄に待機させません。例外処理もチェーンの中に組み込めるため、コードが非常に読みやすくなります。

非同期処理の実装例【コードで理解する】

理論だけでは分かりにくいので、Java非同期処理の具体的なコード例を見てみましょう。

スレッドを使った基本コード例

Runnableインタフェースを使い、新しいスレッドで処理を実行する最もシンプルな例です。

public class ThreadExample {
    public static void main(String[] args) {
        System.out.println("メインスレッド: 開始");

        // 実行したい処理をRunnable(ラムダ式)で定義
        Runnable task = () -> {
            try {
                System.out.println("  別スレッド: 開始");
                Thread.sleep(2_000); // 2秒かかる重い処理をシミュレート
                System.out.println("  別スレッド: 終了");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        // Runnableを渡してThreadを生成
        Thread subThread = new Thread(task);

        // 別スレッドを起動
        subThread.start();

        // メインスレッドは待たずに次の処理へ
        System.out.println("メインスレッド: 別の作業を実行中...");
        System.out.println("メインスレッド: 終了");
    }
}

実行すると、メインスレッドの「終了」が、別スレッドの「終了」よりも先に表示されるはずです。

ExecutorService+Futureの実装例

スレッドプールを使い、Callableで結果を取得する例です。

import java.util.concurrent.*;

public class ExecutorFutureExample {
    public static void main(String[] args) {
        System.out.println("メインスレッド: 開始");

        // 2つのスレッドを持つスレッドプールを作成
        ExecutorService executor = Executors.newFixedThreadPool(2);

        // 結果を返す処理をCallable(ラムダ式)で定義
        Callable<String> task = () -> {
            System.out.println("  別スレッド: 開始");
            Thread.sleep(2_000); // 2秒かかる処理
            System.out.println("  別スレッド: 終了");
            return "非同期処理の結果"; // 文字列を返す
        };

        // スレッドプールにタスクを投入し、Future(引換券)をもらう
        Future<String> future = executor.submit(task);

        System.out.println("メインスレッド: 別の作業を実行中...");

        try {
            // future.get()で結果を取得。処理が終わるまで待機(ブロック)する
            String result = future.get();
            System.out.println("メインスレッド: " + result + "を受け取りました");
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }

        // スレッドプールをシャットダウン(忘れずに)
        executor.shutdown();
        System.out.println("メインスレッド: 終了");
    }
}

future.get()で結果を受け取れますが、その時点で処理が終わっていないと待機が発生します。

CompletableFutureで並列処理を行う例

CompletableFutureを使い、2つのAPI呼び出しを並列実行し、結果を結合するモダンな例です。

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

public class CompletableFutureExample {

    public static void main(String[] args) {
        System.out.println("メイン処理: 開始");

        // 非同期処理1: ユーザー情報を取得(2秒かかると仮定)
        CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> {
            System.out.println("  [A] ユーザー情報 取得開始...");
            sleep(2_000);
            System.out.println("  [A] ユーザー情報 取得完了");
            return "ユーザーA";
        });

        // 非同期処理2: 商品情報を取得(3秒かかると仮定)
        CompletableFuture<String> productFuture = CompletableFuture.supplyAsync(() -> {
            System.out.println("  [B] 商品情報 取得開始...");
            sleep(3_000);
            System.out.println("  [B] 商品情報 取得完了");
            return "商品B";
        });

        System.out.println("メイン処理: [A]と[B]の結果を待たずに進行中...");

        // 処理1と処理2が両方終わったら、結果を結合して次の処理を行う
        CompletableFuture<String> combinedFuture = userFuture.thenCombine(
            productFuture,
            (user, product) -> { // userが[A]の結果, productが[B]の結果
                System.out.println("  [C] 結合処理 開始");
                return user + " が " + product + " を購入";
            }
        );

        // 最終結果を受け取って表示する(コールバック)
        combinedFuture.thenAccept(result -> {
            System.out.println("最終結果: " + result);
        });

        System.out.println("メイン処理: 完了(非同期処理の完了は待たない)");

        // サンプルコードのため、非同期処理が終わるまでメインスレッドを待機
        combinedFuture.join(); // join()はget()と似ているが、検査例外をスローしない
    }

    // 擬似的に時間がかかる処理
    private static void sleep(int millis) {
        try {
            TimeUnit.MILLISECONDS.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

処理A(2秒)と処理B(3秒)が並列で動くため、get()で待つ実装と比べて、システム全体は約3秒で完了します(逐次なら2+3=5秒)。

Javaの非同期処理を使うメリットと注意点

Java非同期処理は強力ですが、正しく使わないと予期せぬ不具合を生みます。メリットとリスクを理解しましょう。

パフォーマンス向上とスケーラビリティ

非同期処理の最大のメリットは、システムの応答性(レスポンス)と処理能力(スループット)の向上です。

特に、データベースアクセスや外部API呼び出しといった、CPUが結果を待っている時間(I/O待機)が多い処理で絶大な効果を発揮します。

待機時間にスレッドを解放し、別のリクエスト処理に回せるため、少ないリソースで多くのリクエストをさばけるようになります(スケーラビリティの向上)。

また、CPUをフルに使う計算処理(CPUバウンド)であっても、マルチコアCPUの各コアに処理を割り当てる「並列処理」を行うことで、全体の処理時間を短縮可能です。

デッドロックや競合状態などのリスク

非同期処理(マルチスレッド)を導入する上で、最も注意すべきリスクが2つあります。

リスク

  1. 複数のスレッドが、同じデータ(変数やオブジェクト)に同時に読み書きしようとすると発生します。アクセスする順番によって結果が変わってしまい、意図しない値になる危険な状態です。対策としては、synchronizedキーワードで一度に1つのスレッドしかアクセスできないように制御(排他制御)したり、Atomicクラスを使ったりします。
  2. 2つ以上のスレッドが、互いに相手が保持しているリソース(ロック)の解放を待ち続け、全員が動けなくなる状態です。対策としては、ロックを取得する順序を常に一定にする、などのルール作りが重要になります。

これらの問題は「スレッドセーフティ」と呼ばれ、Java非同期処理を扱う上で最も難解な部分です。

例外処理やエラーハンドリングのコツ

非同期処理の例外は、呼び出し元のスレッドに直接伝わらないため、ハンドリングを忘れがちです。

  • Threadで例外がスローされると、そのスレッドは停止しますが、メインスレッドには通知されません。UncaughtExceptionHandlerで捕捉する必要があります。
  • Futureの場合、非同期処理中の例外はfuture.get()を呼び出したタイミングでExecutionExceptionとしてスローされます。get()try-catchで囲む必要があります。
  • CompletableFutureでは、exceptionally()handle()といった専用のメソッドチェーンを使って、エラー処理を記述するのがベストプラクティスです。

エラーハンドリングを怠ると、処理が失敗しているのにシステムは正常に動き続けているように見え、デバッグが非常に困難になります。

実践的な活用シーンとベストプラクティス

Javaの非同期処理は、どのような場面で活躍するのでしょうか。

Webアプリ開発での非同期処理(例:API呼び出し)

現代のWebアプリケーション開発、特にSpring Bootなどを使った開発では、非同期処理が多用されます。

ユーザーからリクエストを受け付けた際、その処理に時間がかかる場合(例:複数の外部APIを呼び出して情報を集める、重いデータベースクエリを実行する)、その処理を非同期化します。

Springでは@Asyncアノテーションをメソッドに付けるだけで、簡単に処理を非同期化できます。

これにより、Webサーバーの貴重なスレッド(サーブレットスレッド)をすぐに解放できます。スレッドがすぐに解放されれば、そのスレッドは別のユーザーからのリクエストを受け付けられるようになり、サーバー全体の応答性が劇的に改善します。

並列処理によるデータ分析の高速化

大量のデータを処理するバッチプログラムや、データ分析基盤でもJava非同期処理は活躍します。

例えば、1億件のログデータを処理する必要があるとします。これを1つのスレッドで逐次処理すると膨大な時間がかかります。

データを1,000万件ずつ10個に分割し、ExecutorServiceを使って10個のスレッドで「並列処理」させれば、理論上は処理時間を1/10に短縮できます(CPUコア数によります)。

Java 8から導入された「Parallel Streams」を使うと、このようなデータ並列処理をさらに簡単に記述可能です。

CompletableFutureのthenCombine/thenCompose活用術

CompletableFutureを使いこなす鍵は、メソッドの使い分けです。

  • thenCombine (結合)「API Aの結果」と「API Bの結果」が両方必要な場合に使います。AとBは並列に実行され、両方が完了した時点で、2つの結果を使って次の処理を行います。
  • thenCompose (合成)「API Aの結果」を使って、「API Bを呼び出す」必要がある場合に使います。処理Aと処理Bに依存関係がある場合です。thenApply(Aの結果を加工するだけ)と似ていますが、thenComposeは次の非同期処理を返す場合に用いることで、CompletableFutureのネスト(入れ子)を防ぎ、コードを平坦に保てます。

これらのメソッドを適切に使い分けることで、「コールバック地獄」を避け、読みやすくメンテナンス性の高い非同期コードが書けます。

まとめ|Javaの非同期処理を使いこなすために

Javaの非同期処理は、アプリケーションのパフォーマンスを飛躍的に高める可能性を秘めた技術です。しかし、その強力さゆえに、スレッドセーフティやエラーハンドリングといった特有の難しさも伴います。

初心者がつまずきやすいポイント

多くの初学者がつまずくのは、やはり「競合状態」と「例外処理」です。

「自分のローカル環境では動いていたのに、本番環境で時々データがおかしくなる」という現象は、多くが競合状態の見落としによるものです。

また、非同期処理中の例外が握りつぶされ、エラーに気づけないケースも頻発します。CompletableFutureexceptionally()などを使い、エラーパスを常に意識する訓練が必要です。

非同期処理を安全に使うための心構え

非同期処理は「銀の弾丸」ではありません。むやみに使うと、コードは複雑化し、デバッグは困難になります。

Java非同期処理を導入する際は、以下の心構えが大切です。

  1. 本当に非同期処理が必要か見極める。
  2. スレッド間で共有するデータは最小限にする。(可能な限り不変(Immutable)なオブジェクトを使う)
  3. 例外処理のパスを必ず実装する。
  4. スレッドプールのサイズは適切に設定する。(CPUコア数やI/O待機時間に応じて調整)

Java非同期処理を使いこなせれば、大規模なトラフィックをさばくスケーラブルなシステムを構築できる、ワンランク上のJavaエンジニアになれるはずです。

  • この記事を書いた人
  • 最新記事

トム

・都内自社開発IT企業勤務/javaのバックエンドエンジニア
/java歴10年以上 ・首都圏在住30代
・資格:基本情報技術者/応用情報技術者/Java Silver/Python3エンジニア認定基礎

-Java入門