現代のアプリケーション開発において、ハードウェアのマルチコアCPUを最大限に活用し、高い応答性とスループットを実現することは不可欠な要素となっています。
Javaは誕生当初からマルチスレッドプログラミングを言語レベルでサポートしており、その機能はJavaの進化とともに高度に洗練されてきました。
本記事では、Javaにおけるマルチスレッドの基礎から、スレッドセーフな実装方法、さらにJava 21で導入された画期的な新機能である仮想スレッド (Virtual Threads)まで、プロフェッショナルな開発者が知っておくべき知識を網羅的に解説します。
マルチスレッドの基本概念とJavaでの実装
マルチスレッドとは、1つのプロセス内で複数の処理を並行して実行する仕組みのことです。
Javaにおいて、スレッドは java.lang.Thread クラスによって表されます。
まず、スレッドを作成・実行するための基本的な方法を見ていきましょう。
ThreadクラスとRunnableインターフェース
Javaでスレッドを作成する方法は大きく分けて2つあります。
Threadクラスを継承する方法と、Runnableインターフェースを実装する方法です。
現代的なJava開発では、クラスの単一継承制限を避け、タスクと実行メカニズムを分離するために、Runnableインターフェースを利用する手法が推奨されます。
// Runnableインターフェースを実装する例
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 + " has finished.");
};
// Threadオブジェクトを作成して開始
Thread thread = new Thread(task, "MyWorkerThread");
thread.start(); // スレッドの開始
System.out.println("Main thread continues...");
}
}
Main thread continues...
Hello from MyWorkerThread
MyWorkerThread has finished.
スレッドを開始する際は、run() メソッドを直接呼び出すのではなく、必ず start() メソッド を呼び出す必要があります。
run() を直接呼ぶと、新しいスレッドは作成されず、現在のスレッド(メインスレッドなど)で同期的に処理が実行されてしまうため注意が必要です。
スレッドのライフサイクルと管理
スレッドには「NEW」「RUNNABLE」「BLOCKED」「WAITING」「TIMED_WAITING」「TERMINATED」といった状態が存在します。
開発者はこれらを意識し、適切に制御しなければなりません。
例えば、join() メソッドを使用すると、特定のスレッドが終了するまで現在のスレッドを待機させることができます。
これは、並行して実行した複数の計算結果を最後に集計する場合などに利用されます。
また、スレッドの停止については、かつて存在した stop() メソッドは現在非推奨(Deprecated)となっており、割り込み (Interrupt) 仕組み を用いた安全な停止が推奨されています。
Executorフレームワークによる効率的なスレッド管理
Java 5で導入された java.util.concurrent パッケージ(JUC)は、スレッド管理を劇的に進化させました。
生の Thread オブジェクトを直接操作するのではなく、ExecutorService を使用することで、スレッドの再利用(スレッドプール)が可能になります。
スレッドプールの重要性
スレッドの生成と破棄は、OSレベルで見ると非常にコストの高い操作です。
大量のリクエストが来るたびに新しいスレッドを作成すると、メモリ不足やCPUのコンテキストスイッチのオーバーヘッドにより、システム全体のパフォーマンスが低下します。
スレッドプールを使用することで、あらかじめ作成された一定数のスレッドを使い回し、リソースを効率的に管理できます。
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class ThreadPoolExample {
public static void main(String[] args) {
// 固定サイズのスレッドプール(4スレッド)を作成
ExecutorService executor = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10; i++) {
int taskId = i;
executor.submit(() -> {
System.out.println("Task " + taskId + " is running on " + Thread.currentThread().getName());
});
}
// 実行サービスを停止
executor.shutdown();
try {
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
}
}
}
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-4
Task 4 is running on pool-1-thread-1
... (スレッドが再利用される)
CallableとFuture
Runnable は戻り値を返せず、チェック例外もスローできません。
これに対し、Callable インターフェースを使用すると、処理結果を返し、例外をハンドリングすることが可能です。
実行結果は Future オブジェクトを介して取得します。
排他制御とスレッドセーフな設計
複数のスレッドが同じメモリ領域(インスタンス変数など)を同時に読み書きすると、競合状態 (Race Condition) が発生し、データの整合性が失われます。
これを防ぐための仕組みが排他制御です。
synchronizedキーワード
最も基本的な排他制御は synchronized キーワードです。
これにより、特定のメソッドやブロックに対して一度に1つのスレッドしかアクセスできないようにロックをかけることができます。
public class Counter {
private int count = 0;
// メソッド全体を同期化
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
しかし、synchronized は強力ですが、粒度が粗すぎるとパフォーマンスの低下を招きます。
また、デッドロック(互いにロックの解放を待ち続ける状態)のリスクにも注意を払う必要があります。
ReentrantLockによる高度な制御
java.util.concurrent.locks.ReentrantLock を使用すると、より柔軟なロック制御が可能になります。
タイムアウト付きのロック取得試行や、公平性の設定(長く待っているスレッドを優先する)などが可能です。
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private final Lock lock = new ReentrantLock();
private int count = 0;
public void safeIncrement() {
lock.lock();
try {
count++;
} finally {
// 例外が発生しても必ずロックを解放する
lock.unlock();
}
}
}
Atomic変数の活用
単純な数値のカウントアップなどであれば、ロックを使用するよりも java.util.concurrent.atomic パッケージのクラスを使用する方が高速です。
これらは、CPUレベルの CAS (Compare-And-Swap) 命令を利用しており、ロックフリーでスレッドセーフな操作を実現します。
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
public int getCount() {
return count.get();
}
}
Javaメモリモデルとvolatileキーワード
マルチスレッドにおいて、単に「同時に実行されないこと(原子性)」だけでなく、「変更が他から見えること(可視性)」も重要です。
各CPUコアは独自のキャッシュを持っており、メインメモリの値を書き換えても他のスレッドがその変更をすぐに検知できるとは限りません。
volatile キーワードを付与された変数は、常にメインメモリから読み書きされることが保証されます。
ただし、volatile は可視性のみを保証し、count++ のような複合操作の原子性は保証しない点に注意してください。
コンカレントコレクションの利用
標準の ArrayList や HashMap はスレッドセーフではありません。
マルチスレッド環境では、java.util.concurrent に用意された最適化済みのコレクションを使用すべきです。
| クラス名 | 特徴 |
|---|---|
ConcurrentHashMap | 高性能なスレッドセーフMap。ロック分割技術により高い並行性を実現。 |
CopyOnWriteArrayList | 書き込み時に配列全体をコピーする。読み込みが多く、書き込みが極端に少ない場合に有効。 |
BlockingQueue | プロデューサー・コンシューマー・パターンで利用される、スレッド間のデータ受け渡し用キュー。 |
特に ConcurrentHashMap は、古い Hashtable や Collections.synchronizedMap よりも格段にパフォーマンスが良いため、現代のJava開発における標準的な選択肢となっています。
CompletableFutureによる非同期プログラミング
Java 8で導入された CompletableFuture は、複数の非同期処理を組み合わせるための強力なツールです。
いわゆる「Promise」や「Future」をより高度に扱えるようにしたもので、コールバック地獄に陥ることなく非同期処理のパイプラインを構築できます。
import java.util.concurrent.CompletableFuture;
public class AsyncExample {
public static void main(String[] args) {
CompletableFuture.supplyAsync(() -> {
// 重い処理
return "Result of Task 1";
}).thenApply(result -> {
// 結果を加工
return result + " -> Task 2 processed";
}).thenAccept(finalResult -> {
// 最終結果を出力
System.out.println(finalResult);
});
// 非同期実行のため、メインスレッドが先に終わらないように待機(デモ用)
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
}
thenCompose や thenCombine を使うことで、複数の非同期タスクの依存関係を宣言的に記述でき、可読性の高いマルチスレッドコードを実現できます。
Java 21の革新:仮想スレッド (Virtual Threads)
Javaマルチスレッドの歴史の中で、最も劇的な変化をもたらしたのが Java 21 で正式導入された 仮想スレッド (Virtual Threads) です。
従来のプラットフォームスレッドの限界
これまでのJavaのスレッド(プラットフォームスレッド)は、OSのスレッドと 1:1 で対応していました。
OSのスレッドはスタックメモリの消費が大きく(通常1MB程度)、生成数に数千程度の限界がありました。
そのため、大量の同時接続を裁くWebサーバーなどでは、スレッドの枯渇がボトルネックとなり、リアクティブプログラミングなどの複雑な非同期モデルを採用せざるを得ませんでした。
仮想スレッドの仕組み
仮想スレッドは、JVM内部で管理される 「非常に軽量なスレッド」 です。
1つのプラットフォームスレッドの上で数百万もの仮想スレッドを動かすことが可能です。
仮想スレッドがI/O待ち(データベースアクセスやAPI呼び出し)に入ると、JVMは自動的にその仮想スレッドをアンマウントし、基盤となるプラットフォームスレッドを別の仮想スレッドに割り当てます。
これにより、「1リクエストにつき1スレッド」 というシンプルかつ直感的な同期プログラミングモデルを維持したまま、圧倒的なスループットを実現できるようになりました。
仮想スレッドの実装例
import java.util.concurrent.Executors;
public class VirtualThreadExample {
public static void main(String[] args) {
// 仮想スレッドを使用するExecutor
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
// 非常に多くのタスクを並行実行可能
System.out.println(Thread.currentThread());
});
}
} // ここでcloseが呼ばれ、すべてのタスク完了を待つ
}
}
仮想スレッドの導入により、複雑な非同期APIを駆使することなく、高並列なシステムを構築できるようになりました。
これはJavaにおける並行処理のパラダイムシフトと言えます。
マルチスレッドプログラミングのベストプラクティス
強力なマルチスレッド機能ですが、誤った使い方はデバッグ困難なバグを引き起こします。
以下の原則を守ることが重要です。
- 不変性 (Immutability) の活用
オブジェクトの状態を変更不可にすれば、排他制御そのものが不要になります。
Java 14以降の
recordはこれに最適です。- スレッドセーフなコレクションの選択
自分でロックを実装する前に、標準ライブラリに適切なコレクションがないか確認してください。
- ThreadLocalの慎重な使用
スレッド固有のデータを保持する
ThreadLocalは、スレッドプール環境ではデータ漏洩の原因になるため、Java 21で導入されたScopedValueの検討も推奨されます。- 共有状態を避ける
可能な限りスレッド間でデータを共有せず、メッセージパッシングや戻り値によるデータの受け渡しを行う設計を心がけましょう。
まとめ
Javaのマルチスレッドは、初期の Thread クラスから始まり、Executorフレームワーク、Fork/Joinフレームワーク、CompletableFuture、そして最新の 仮想スレッド へと進化を続けてきました。
現代のJava開発においては、従来の重いプラットフォームスレッドをプールして使い回す手法に加え、軽量な仮想スレッドをタスクごとに生成して使い捨てるという新しい選択肢が加わりました。
これにより、開発者は複雑な非同期コードから解放され、ビジネスロジックの記述に集中できるようになります。
しかし、どのような技術を使おうとも、「競合状態」「デッドロック」「可視性」といったマルチスレッドの本質的な課題が消えるわけではありません。
本記事で解説した基礎知識を土台とし、適切なツールを選択することで、堅牢で高パフォーマンスなJavaアプリケーションを構築してください。






