現代のシステム開発において、マルチコアCPUの性能を最大限に引き出し、スループットを向上させるための「並列処理」は不可欠な技術です。

Javaは誕生当初からマルチスレッドをサポートしてきましたが、時代と共にその実装方法は大きく進化してきました。

かつての複雑なスレッド管理から、現代の宣言的な記述、そしてJava 21で正式導入された「Virtual Threads(仮想スレッド)」による劇的な進化まで、Javaの並列処理は今、新たな局面を迎えています。

本記事では、Javaにおける並列処理の基礎から最新の実装手法、そして用途に応じた使い分けまでをプロの視点で詳細に解説します。

Javaにおける並列処理の基本概念と重要性

Javaで並列処理を扱う前に、まず整理しておくべきは「並行処理(Concurrency)」と「並列処理(Parallelism)」の違いです。

並行処理とは、複数のタスクを切り替えながら実行し、あたかも同時に動いているように見せる状態を指します。

一方、並列処理とは、マルチコアプロセッサを活用して、物理的に複数のタスクを同時に実行することを指します。

Javaの並列処理が重視される背景には、ハードウェアの進化があります。

CPUの単一コアのクロック周波数が頭打ちになった現在、性能を向上させるには計算を複数のコアに分散させるしかありません。

Javaは標準ライブラリとして豊富な並列処理APIを提供しており、これらを適切に使い分けることで、大量のデータ処理や高負荷なリクエストへの応答性能を劇的に改善することが可能です。

しかし、並列処理は単に導入すれば速くなるというものではありません。

スレッドの生成コスト、コンテキストスイッチのオーバーヘッド、そして共有リソースへのアクセス競合といった「マルチスレッド特有の課題」を正しく理解し、制御する必要があります。

本稿では、これらの課題をどのように解決し、最適なパフォーマンスを引き出すかに焦点を当てていきます。

伝統的なスレッド管理:ThreadクラスとRunnable

Javaにおける最も基本的な並列処理の実装方法は、java.lang.Threadクラスを使用することです。

これはJava 1.0から存在する最も古い手法ですが、並列処理の動作原理を理解する上で非常に重要です。

ThreadとRunnableによる実装

スレッドを作成するには、Threadクラスを継承するか、Runnableインタフェースを実装します。

一般的には、多重継承の制限を避けるためにRunnableインタフェースを使用する方法が推奨されます。

Java
public class BasicThreadExample {
    public static void main(String[] args) {
        // Runnableインタフェースをラムダ式で実装
        Runnable task = () -> {
            String threadName = Thread.currentThread().getName();
            System.out.println("Hello from " + threadName);
            try {
                // 1秒間スリープして処理をシミュレート
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(threadName + " task completed.");
        };

        // Threadオブジェクトを作成して開始
        Thread thread = new Thread(task);
        thread.start();

        System.out.println("Main thread continues...");
    }
}
実行結果
Main thread continues...
Hello from Thread-0
Thread-0 task completed.

直接スレッドを扱う際のリスク

このように直接Threadを生成する方法には、現代のアプリケーション開発においては重大な欠点があります。

それは、スレッドの生成コストが非常に高いという点です。

Javaの伝統的なスレッド(プラットフォームスレッド)は、OSのスレッドと1対1で対応しています。

OSスレッドはメモリを大量に消費(通常1MB程度)し、作成や切り替えに多大なCPUサイクルを要します。

そのため、大量のリクエストごとに新しいスレッドを作成すると、メモリ不足(OutOfMemoryError)や、コンテキストスイッチの多発による性能劣化を招きます。

これを解決するために登場したのが、次に解説するExecutorフレームワーク(スレッドプール)です。

スレッドプールの活用:Executor Service

Java 5で導入されたjava.util.concurrent.ExecutorServiceは、スレッドの生成と管理をタスクの実行から切り離す画期的な仕組みでした。

これにより、あらかじめ作成しておいたスレッドを再利用する「スレッドプール」の利用が容易になりました。

ExecutorServiceの種類と選び方

Executorsファクトリクラスを使用することで、用途に応じた様々なスレッドプールを作成できます。

FixedThreadPool

固定数のスレッドを維持します。

負荷が予測可能な場合に最適です。

CachedThreadPool

必要に応じて新しいスレッドを作成し、空きがあれば再利用します。

短時間の非同期タスクを大量に処理する場合に向いています。

ScheduledThreadPool

一定時間後や周期的なタスク実行に使用します。

WorkStealingPool

各スレッドが自身のキューを持ち、空いたスレッドが他のキューからタスクを「盗む」ことで、全コアを効率的に利用します。

実装例:ExecutorServiceによるタスク実行

Java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class ExecutorExample {
    public static void main(String[] args) {
        // 3つのスレッドを持つ固定スレッドプールを作成
        ExecutorService executor = Executors.newFixedThreadPool(3);

        for (int i = 0; i < 5; i++) {
            int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " is running on " + Thread.currentThread().getName());
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        // 新規タスクの受付を終了し、実行中のタスク完了を待つ
        executor.shutdown();
        try {
            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
        System.out.println("All tasks finished.");
    }
}
実行結果
Task 0 is running on pool-1-thread-1
Task 1 is running on pool-1-thread-2
Task 2 is running on pool-1-thread-3
Task 3 is running on pool-1-thread-1
Task 4 is running on pool-1-thread-2
All tasks finished.

この例では、5つのタスクを3つのスレッドで効率よく回していることがわかります。

タスク3と4は、タスク0や1が終了して空いたスレッドを再利用して実行されています。

大規模計算の最適化:Fork/Joinフレームワーク

Java 7で導入されたFork/Joinフレームワークは、一つの大きなタスクを小さなサブタスクに分割(Fork)し、それらの結果を統合(Join)する「分割統治法」を並列で行うための仕組みです。

ワークスティーリング(Work-Stealing)アルゴリズム

Fork/Joinフレームワークの最大の特徴は、ワークスティーリングアルゴリズムを採用している点です。

これは、自分のタスクを終えて暇になったスレッドが、忙しいスレッドのタスクを肩代わりする仕組みです。

これにより、再帰的な処理においてスレッド間の負荷の偏りを防ぎ、CPUのリソースを極限まで使い切ることができます。

後述する「Parallel Stream」も、内部的にはこのForkJoinPool(共通の共通プール)を利用しています。

宣言的な並列処理:Parallel Stream

Java 8で導入されたStream APIは、データの集合に対する操作を直感的に記述できるようにしました。

この中で最も手軽に並列化を実現できるのがparallelStream()です。

Parallel Streamの使い方と注意点

リストなどのコレクションに対して.parallelStream()を呼び出すだけで、処理が自動的に分割され、マルチコアで実行されます。

Java
import java.util.Arrays;
import java.util.List;

public class ParallelStreamExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // 並列ストリームで各要素を2倍にして合計を求める
        long startTime = System.nanoTime();
        int sum = numbers.parallelStream()
                         .mapToInt(n -> {
                             // 処理スレッドを確認
                             System.out.println(Thread.currentThread().getName() + " processing: " + n);
                             return n * 2;
                         })
                         .sum();
        long endTime = System.nanoTime();

        System.out.println("Sum: " + sum);
        System.out.println("Time taken: " + (endTime - startTime) / 1_000_000 + "ms");
    }
}

並列ストリームを使うべきケース

Parallel Streamは非常に強力ですが、副作用(外部変数の書き換え)がある場合や、要素の順序が重要な場合には不適切です。

また、処理内容が極めて単純(例:整数の加算のみ)な場合、スレッドの分割と統合のオーバーヘッドの方が大きくなり、通常のStreamより遅くなることがあります。

「計算負荷が高く、各要素の処理が独立している」場合にのみ使用するのが、パフォーマンス最適化の鉄則です。

非同期プログラミングの進化:CompletableFuture

Java 8で導入されたCompletableFutureは、非同期タスクの実行結果を待ち合わせたり、複数のタスクを連結(パイプライン化)したりするための強力なツールです。

これは「Future」の概念を拡張し、コールバック地獄に陥ることなく非同期処理を記述できるように設計されています。

タスクの合成と連携

例えば、「外部APIからデータを取得し、その結果を使って別の計算を行い、最後にDBへ保存する」といった一連の流れを、非同期かつノンブロッキングで記述できます。

Java
import java.util.concurrent.CompletableFuture;

public class CompletableFutureExample {
    public static void main(String[] args) throws Exception {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            // ステップ1: データの取得(非同期)
            simulateDelay(1000);
            return "Raw Data";
        }).thenApply(data -> {
            // ステップ2: データの加工
            return data + " -> Processed";
        }).thenCompose(processedData -> {
            // ステップ3: 別の非同期タスクとの連携
            return CompletableFuture.supplyAsync(() -> processedData + " -> Final Result");
        });

        // 結果の受け取り
        future.thenAccept(result -> System.out.println("Result: " + result));

        // メインスレッドが先に終了しないように待機(デモ用)
        Thread.sleep(2000);
    }

    private static void simulateDelay(int ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) {}
    }
}

このプログラミングモデルにより、複数のスレッドを効率的に使い分けながら、コードの可読性を維持することが可能になりました。

Java並列処理の新時代:Virtual Threads(仮想スレッド)

Java 21で正式に導入されたVirtual Threads(Project Loom)は、Javaの並列処理における過去20年で最大の革命と言えます。

これまでのJavaスレッド(プラットフォームスレッド)が抱えていた制限を根本から打破する技術です。

仮想スレッドとは何か?

従来のプラットフォームスレッドがOSスレッドと1対1で紐付いていたのに対し、Virtual ThreadsはJavaランタイム(JVM)内で管理される軽量なスレッドです。

一つのOSスレッド上で、数百万個の仮想スレッドを走らせることができます。

最大の利点は、「スレッドをブロックしてもリソースを消費しない」ことです。

仮想スレッド内でブロッキングI/O(ネットワーク待ちやDBクエリ)が発生すると、JVMはその仮想スレッドをOSスレッドから切り離し、別の仮想スレッドを割り当てます。

I/Oが完了すると、再びOSスレッドにマウントされて再開されます。

仮想スレッドの実装例

仮想スレッドの作成は非常に簡単で、既存のExecutorServiceの仕組みを利用できます。

Java
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class VirtualThreadExample {
    public static void main(String[] args) {
        // 仮想スレッドを使用するExecutor
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 10_000).forEach(i -> {
                executor.submit(() -> {
                    // 1万個のスレッドを起動しても、OSスレッドはごく少数
                    Thread.sleep(100); 
                    return i;
                });
            });
        } // ここで全てのタスク完了を待機(AutoCloseable)
        
        System.out.println("Finished 10,000 tasks with Virtual Threads.");
    }
}

なぜ仮想スレッドが必要なのか?

これまでのJavaでは、高並列なサーバーを構築しようとすると、ノンブロッキングI/O(Nettyなど)を用いた複雑なリアクティブプログラミングが必要でした。

しかし、Virtual Threadsの登場により、「シンプルで読みやすい逐次的なコード」のまま、極めて高いスループットを実現できるようになりました。

もはや、スレッドプールを慎重にチューニングしてスレッド数を節約する必要はありません。

並列処理における排他制御とデータ整合性

並列処理を導入する際に必ず直面するのが、複数のスレッドが同じデータにアクセスすることで発生する競合状態(Race Condition)です。

Javaではこれらを制御するために様々な同期メカニズムを提供しています。

synchronizedとLock

最も基本的なのはsynchronizedキーワードです。

特定のメソッドやブロックに対して、一度に一つのスレッドしか入れないように制限します。

より高度な制御が必要な場合は、java.util.concurrent.locks.ReentrantLockを使用します。

これは「ロックの取得を試みる(tryLock)」「タイムアウトを設定する」「公平なロック順序を保証する」といった柔軟な操作が可能です。

Atomic変数とConcurrentコレクション

ロックを使用するとスレッドの待機が発生し、パフォーマンスが低下します。

これを避けるために、ロックフリー(Lock-free)な操作を提供するクラス群があります。

AtomicInteger / AtomicLong

CPUのCAS(Compare-And-Swap)命令を利用して、ロックなしで安全に値を更新します。

ConcurrentHashMap

マップ全体をロックせず、セグメントごとにロックをかける(またはロックフリーな読み取りを提供する)ことで、高い並列性を維持します。

CopyOnWriteArrayList

書き込み時に配列全体をコピーすることで、読み取り操作を完全にノンブロッキングにします。

種類別の使い分け:いつ何を使うべきか?

Javaの並列処理には多くの選択肢があるため、状況に応じた最適なツールを選ぶことが重要です。

以下の表に、一般的なユースケースと推奨される技術をまとめました。

ユースケース推奨される手法理由
計算集約型のタスク (画像処理、数値計算)Parallel Stream / ForkJoinPool全CPUコアをフル活用し、スループットを最大化できるため。
大量のI/O待ちが発生するタスク (Web API呼び出し)Virtual Threads (Java 21+)スレッド生成コストを気にせず、ブロッキングコードをシンプルに書けるため。
複雑な依存関係がある非同期処理CompletableFutureタスクの連鎖や合成、例外ハンドリングを宣言的に記述できるため。
負荷が一定のサーバーサイド処理ExecutorService (スレッドプール)リソース消費量を一定に保ち、システムの安定性を維持しやすいため。
単純な周期実行タスクScheduledExecutorServiceタイマー処理に特化しており、Thread.sleepよりも正確で安全なため。

パフォーマンスを左右する「スレッド数の決定」

並列処理の設計において最も難しいのが、スレッド数の設定です。

CPU集約型タスク

スレッド数は「CPUのコア数 + 1」程度が最適とされます。

これ以上増やしてもコンテキストスイッチのオーバーヘッドが増えるだけです。

I/O集約型タスク

待ち時間が長いため、スレッド数はコア数よりも大幅に多く設定するのが通例でした。

しかし、Virtual Threadsを使用する場合は、この計算自体が不要になります。

構造化された並行性(Structured Concurrency)

Java 21以降で導入が進んでいる(現在はプレビュー版を含む)「Structured Concurrency」についても触れておく必要があります。

これは、複数の並行タスクを「一つのまとまった作業」として扱うための仕組みです。

従来のExecutorServiceなどでは、あるサブタスクが失敗しても、他のタスクがゾンビのように残り続け、リソースを浪費することがありました。

Structured Concurrencyを使用すると、「親タスクが終了すれば、子タスクも自動的にキャンセルされる」という階層構造を持たせることができ、エラー処理とリソース管理の信頼性が飛躍的に向上します。

Java
// 注:Java 21以降のプレビュー機能(将来的な標準)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Supplier<String> user = scope.fork(() -> fetchUser());
    Supplier<Integer> order = scope.fork(() -> fetchOrder());

    scope.join();           // 両方の完了を待つ
    scope.throwIfFailed();  // どちらかが失敗していれば例外を投げる

    System.out.println(user.get() + " ordered " + order.get());
}

このように、Javaは並列処理を「ただ動く」状態から、「安全に、予測可能に動く」状態へと進化させています。

まとめ

Javaの並列処理は、Threadクラスから始まり、Executorフレームワーク、Fork/Join、Stream API、そしてVirtual Threadsへと進化を続けてきました。

現代のJava開発者にとって、これらの道具を適切に使い分けることは、アプリケーションの性能と保守性を左右する極めて重要なスキルです。

特にJava 21で登場したVirtual Threadsは、これまでの「並列処理=難しい・複雑」という常識を塗り替えました。

I/O待ちの多いWebアプリケーションにおいては、従来のリアクティブなコードを捨て、シンプルでスケーラブルなコードへ回帰することを可能にしています。

並列処理を実装する際は、以下の3点を常に意識してください。

  1. タスクの性質を見極める(CPU集約型かI/O集約型か)。
  2. 適切な抽象度を選択する(低レベルなThreadではなく、高レベルなAPIを選ぶ)。
  3. 共有リソースへのアクセスを最小限にする(イミュータブルな設計やConcurrentコレクションの活用)。

最新のJava機能を積極的に取り入れることで、マルチコア時代の恩恵を最大限に享受し、高品質なソフトウェア開発を実現しましょう。