【Java学習|豆知識】Javaの伝統的な並行処理!wait(), notify(), notifyAll() を使いこなす

導入: なぜwait(), notify(), notifyAll()が重要なのか

Javaで複数のスレッドが連携して処理を進める際、あるスレッドが特定の条件が満たされるまで待機し、別のスレッドがその条件が満たされたことを通知する、というシナリオは非常に一般的です。このようなスレッド間の協調を実現するために、Javaは`Object`クラスに`wait()`, `notify()`, `notifyAll()`というメソッドを提供しています。これらはJavaの並行処理の基礎であり、特に古いバージョンのJavaや、ExecutorService、CompletableFutureなどの高レベルな並行処理APIが登場する以前から利用されてきました。これらのメソッドを理解することは、Javaの並行処理の歴史をたどり、その根幹を理解する上で非常に重要です。

基礎知識: スレッド間同期の基本

`wait()`, `notify()`, `notifyAll()`は、すべて`Object`クラスのメソッドであり、どのオブジェクトに対しても呼び出すことができます。これらのメソッドは、オブジェクトの「モニター」と呼ばれる仕組みと密接に関連しています。

  • モニター (Monitor): Javaでは、各オブジェクトは内部的にモニターを持っています。モニターは、ある時点でそのオブジェクトに対して一度に一つのスレッドしか実行できないようにする排他制御(ロック)の仕組みを提供します。`synchronized`ブロックや`synchronized`メソッドでオブジェクトにアクセスする際、そのオブジェクトのモニターをロックします。
  • `wait()`: `wait()`メソッドを呼び出したスレッドは、そのオブジェクトのモニターを解放し、待機状態に入ります。待機状態のスレッドは、他のスレッドによって`notify()`または`notifyAll()`が呼び出されるまで、CPUリソースを消費せずに待機します。
  • `notify()`: `notify()`メソッドは、そのオブジェクトのモニターを待機しているスレッドの中から、任意の一つのスレッドを呼び覚まします(ウェイクアップします)。呼び覚まされたスレッドは、再びそのオブジェクトのモニターのロックを取得しようと試みます。
  • `notifyAll()`: `notifyAll()`メソッドは、そのオブジェクトのモニターを待機している全てのスレッドを呼び覚まします。呼び覚まされたスレッドは、それぞれがモニターのロックを取得しようと試みます。

これらのメソッドは、必ず`synchronized`ブロックまたは`synchronized`メソッドの中から呼び出す必要があります。なぜなら、モニターをロックしているスレッドだけが、そのモニター上で待機している他のスレッドを通知したり、自身が待機したりできるからです。`synchronized`ブロック外でこれらのメソッドを呼び出すと、`IllegalMonitorStateException`が発生します。

実装/解決策: 生産者-消費者問題の実装例

`wait()`, `notify()`, `notifyAll()`の典型的な使い道として、生産者-消費者問題 (Producer-Consumer Problem) があります。これは、あるスレッド(生産者)がデータを生成し、別のスレッド(消費者)がそのデータを消費する、というシナリオです。データ共有のためのバッファ(例: `List`)があり、バッファが満杯の場合は生産者は待機し、バッファが空の場合は消費者は待機します。

この問題を解決するために、以下のロジックを考えます。

1. 共有リソース: データを格納するための共有リスト(例: `ArrayList`)。
2. 生産者スレッド:

  • 共有リストが満杯かどうかをチェックします。
  • 満杯であれば、`wait()`を呼び出して待機します。
  • 満杯でなければ、データをリストに追加します。
  • データを追加したら、`notifyAll()`を呼び出して、待機している消費者スレッドを呼び覚まします。

3. 消費者スレッド:

  • 共有リストが空かどうかをチェックします。
  • 空であれば、`wait()`を呼び出して待機します。
  • 空でなければ、リストからデータを削除(消費)します。
  • データを消費したら、`notifyAll()`を呼び出して、待機している生産者スレッドを呼び覚まします。

`notify()`ではなく`notifyAll()`を使う理由ですが、複数の生産者や消費者がいる場合、`notify()`だと意図しないスレッドだけが起こされてしまい、デッドロック(プログラムが永久に停止する状態)に陥る可能性があります。`notifyAll()`を使えば、待機している全てのスレッドが条件を確認し、適切なスレッドだけが処理を続行できるため、より安全です。

サンプルプログラム

以下に、生産者-消費者問題を`wait()`, `notifyAll()`を使って実装したサンプルプログラムを示します。

import java.util.ArrayList;
import java.util.List;

public class ProducerConsumerExample {

// 共有リソース(バッファ)
private final List buffer = new ArrayList<>();
// バッファの最大容量
private final int CAPACITY = 5;

// 生産者スレッドのRunnable
class Producer implements Runnable {
@Override
public void run() {
try {
for (int i = 0; i < 10; i++) { produce(i); // 処理の合間に少し待機させる(デモ用) Thread.sleep(50); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println("Producer interrupted."); } } private void produce(int item) throws InterruptedException { // synchronizedブロックでオブジェクトのモニターをロック synchronized (buffer) { // バッファが満杯の場合、待機する // wait()は必ずループ内で呼び出す(Spurious Wakeup対策) while (buffer.size() == CAPACITY) { System.out.println("Buffer is full, producer waiting..."); // モニターを解放して待機状態に入る buffer.wait(); } // バッファにアイテムを追加 buffer.add(item); System.out.println(Thread.currentThread().getName() + " produced: " + item + ", Buffer size: " + buffer.size()); // 待機している可能性のある全てのスレッドに通知する buffer.notifyAll(); } } } // 消費者スレッドのRunnable class Consumer implements Runnable { @Override public void run() { try { for (int i = 0; i < 10; i++) { consume(); // 処理の合間に少し待機させる(デモ用) Thread.sleep(100); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println("Consumer interrupted."); } } private void consume() throws InterruptedException { // synchronizedブロックでオブジェクトのモニターをロック synchronized (buffer) { // バッファが空の場合、待機する // wait()は必ずループ内で呼び出す(Spurious Wakeup対策) while (buffer.isEmpty()) { System.out.println("Buffer is empty, consumer waiting..."); // モニターを解放して待機状態に入る buffer.wait(); } // バッファからアイテムを消費 int item = buffer.remove(0); // 先頭の要素を削除 System.out.println(Thread.currentThread().getName() + " consumed: " + item + ", Buffer size: " + buffer.size()); // 待機している可能性のある全てのスレッドに通知する buffer.notifyAll(); } } } public static void main(String[] args) { ProducerConsumerExample example = new ProducerConsumerExample(); // 生産者スレッドと消費者スレッドを作成 Thread producerThread = new Thread(example.new Producer(), "Producer-1"); Thread consumerThread = new Thread(example.new Consumer(), "Consumer-1"); // スレッドを開始 producerThread.start(); consumerThread.start(); // メインスレッドは、子スレッドが終了するまで待機(オプション) try { producerThread.join(); consumerThread.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("All tasks finished."); } } コードのポイント:

  • `synchronized (buffer)`: `buffer`オブジェクトのモニターをロックします。これにより、一度に一つのスレッドだけが`buffer`にアクセスできるようになります。
  • `while (buffer.size() == CAPACITY)` / `while (buffer.isEmpty())`: `wait()`メソッドは、必ず`while`ループの中で呼び出す必要があります。これは「Spurious Wakeup(偽のウェイクアップ)」と呼ばれる現象を防ぐためです。まれに、`wait()`から復帰したスレッドが、`notify()`や`notifyAll()`によって明示的に呼び出されなくても、待機条件が満たされていないにも関わらず処理を再開してしまうことがあります。`while`ループで条件を再確認することで、このような場合に正しく待機状態に戻ることができます。
  • `buffer.wait()`: 現在のスレッドを待機状態にし、`buffer`オブジェクトのモニターを解放します。
  • `buffer.notifyAll()`: `buffer`オブジェクトのモニターを待機している全てのスレッドを呼び覚まします。
  • `Thread.sleep()`: デモのために、スレッドの動作を一時停止させています。実際のアプリケーションでは、このスリープは不要な場合が多いです。

応用・注意点: 現場で役立つ補足情報

  • `notify()` vs `notifyAll()`: 基本的には`notifyAll()`を使うのが安全です。`notify()`は、どのスレッドが呼び覚まされるか予測しにくく、デッドロックのリスクを高めます。ただし、非常に限定的な状況(例えば、単一の生産者と単一の消費者の場合で、かつ通知する側が「生産可能になった」「消費可能になった」という状態を正確に把握できる場合)では、パフォーマンス向上のために`notify()`が検討されることもあります。しかし、一般的には`notifyAll()`を選択するのが賢明です。
  • `wait(long timeout)`: `wait()`メソッドにはタイムアウトを指定できるオーバーロードもあります。指定した時間待機しても通知がない場合、タイムアウトして処理を再開します。これは、永続的な待機を防ぐために有効です。
  • `interrupt()`: 待機中のスレッド (`wait()`中など) は、`interrupt()`メソッドを呼び出すことで割り込みさせることができます。`interrupt()`が呼び出されると、`InterruptedException`が発生し、スレッドは待機状態から復帰します。
  • ExecutorServiceやCompletableFutureとの関係: modern Javaでは、`ExecutorService`によるスレッドプールの管理や、`CompletableFuture`による非同期処理の構築が推奨されています。これらの高レベルAPIは、スレッドの生成・管理や、スレッド間の連携をより簡潔かつ安全に記述できるように設計されています。しかし、それでも`wait()`, `notify()`, `notifyAll()`の基本的な同期メカニズムを理解していることは、これらのAPIの内部動作を理解したり、まれに必要となる低レベルな同期処理を実装したりする上で役立ちます。特に、`ExecutorService`と組み合わせて、カスタムな同期メカニズムを実装する際に、これらのメソッドが使われることがあります。
  • デッドロックの回避: `synchronized`ブロックのネストが深くなると、デッドロックが発生しやすくなります。例えば、スレッドAがオブジェクトXをロックし、オブジェクトYのロックを取得しようと待機している間に、スレッドBがオブジェクトYをロックし、オブジェクトXのロックを取得しようと待機している、といった状況です。`wait()`と`notify()`/`notifyAll()`を用いる場合も、ロックの取得順序を統一するなど、慎重な設計が必要です。

`wait()`, `notify()`, `notifyAll()`は、Javaにおけるスレッド間協調の古典的な手法ですが、その理解はJavaの並行処理の奥深さを知る上で欠かせません。これらのメソッドを正しく理解し、適切に利用することで、堅牢で効率的な並行アプリケーションを開発することができます。

コメント

タイトルとURLをコピーしました