Java : Object.waitの注意点

Objectクラスには、現在のスレッドを待機させる wait メソッドがあります。
ただし、wait メソッドには「スプリアス・ウェイクアップ (spurious wakeup)」と呼ばれる注意すべき仕様があります。

本記事ではその注意すべき仕様と、代わりにおすすめする CountDownLatch クラスについてもご紹介します。


概要

Objectクラスには、現在のスレッドを待機させる wait と復帰するための notify メソッドがあります。

シーケンス図構成

wait によるスレッドの待機は、通常…

  • notify の呼び出し
  • スレッドの中断(interrupt)
  • タイムアウト

により復帰します。

しかし、上記の条件以外でも、「スプリアス・ウェイクアップ」によって復帰することがあります。

public final void wait​(long timeoutMillis, int nanos) throws InterruptedException
...
スレッドは通知、中断、またはタイムアウトすることなく起きることができます。いわゆる「スプリアス・ウェイクアップ」です。 スプリアス・ウェイクアップは、実際にはまれにしか発生しませんが、アプリケーションでは、スレッドが再開されることで発生する可能性がある条件をテストし、条件が満たされない場合は待機を続けて、スプリアス・ウェイクアップから保護しなければいけません。 次の例を参照してください。

スプリアス・ウェイクアップは、まれにしか発生しない少し面倒な仕様です。
詳細は上記API仕様をご確認ください。

waitnotifyの例

まずは、スプリアス・ウェイクアップを考慮しない例を見てみましょう。

// 基準となる時刻
final long current = System.nanoTime();

// 基準となる時刻からの差分を秒として取得します。
final DoubleSupplier elapsedSec = () -> (System.nanoTime() - current) / 1000000000d;

final ExecutorService executorService = Executors.newSingleThreadExecutor();
try {
    // 同期用のオブジェクトです。
    final var obj = new Object();

    // サブスレッド上で実行する処理(タスク)です。
    final Runnable task = () -> {
        try {
            System.out.printf("task start : %f sec.\n", elapsedSec.getAsDouble());
            synchronized (obj) {
                obj.wait(); // ★
            }
        } catch (InterruptedException e) {
            System.out.printf("InterruptedException発生! : %f sec.\n", elapsedSec.getAsDouble());
        } finally {
            System.out.printf("task end : %f sec.\n", elapsedSec.getAsDouble());
        }
    };

    executorService.submit(task);

    Thread.sleep(2000);

    synchronized (obj) {
        obj.notify(); // ★
    }

} finally {
    executorService.shutdown();
    System.out.printf("-- shutdown -- : %f sec.\n", elapsedSec.getAsDouble());
}

final var result = executorService.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("term %b : %f sec.\n", result, elapsedSec.getAsDouble());

おおまかなシーケンスは次のようになります。

シーケンス図構成

サブスレッドで wait して、メインのスレッドから2秒後に notify します。
結果は次のようになります。

//task start : 0.002802 sec.
//task end : 2.004060 sec.   <-- ★ここ
//-- shutdown -- : 2.004080 sec.
//term true : 2.004597 sec.

期待どおりに、タスクが2秒後に終わっているのが分かりますね。
しかし、スプリアス・ウェイクアップが発生すると、次のような結果になる可能性があります。

//task start : 0.002802 sec.
//task end : 1.000000 sec.   <-- ★ここ
//-- shutdown -- : 2.004080 sec.
//term true : 2.004597 sec.

タスクが 2秒より前に終わってしまいます。
API仕様上、このようなことが起こる可能性はゼロではありません。

スプリアス・ウェイクアップを考慮した例

public final void wait​(long timeoutMillis, int nanos) throws InterruptedException
...
APIのノート:
待機するための推奨される方法は、以下の例に示すように、waitへの呼び出しの周りのwhileループで待っている状態をチェックすることです。 とりわけ、このアプローチは、スプリアス・ウェイクアップによって引き起こされる可能性のある問題を回避します。

公式のAPIノートに、スプリアス・ウェイクアップを対処するおすすめの方法が紹介されています。
以下、抜粋します。

synchronized (obj) {
    while (<condition does not hold> and <timeout not exceeded>) {
        long timeoutMillis = ... ; // recompute timeout values
        int nanos = ... ;
        obj.wait(timeoutMillis, nanos);
    }
    ... // Perform action appropriate to condition or timeout
}

それでは、もう少し具体的に実装してみましょう。
今回はタイムアウトは考慮せずに、待機条件だけを考慮してみます。

まずは同期用のクラスを新しく定義します。

public class SyncObj {

    private boolean signaled;

    // Object.waitの代わりとなるメソッドです。
    public synchronized void await() throws InterruptedException {
        // スプリアス・ウェイクアップが発生してwaitから復帰しても、
        // signalメソッドが呼び出されていなければ、もう1度waitします。
        while (!signaled) {
            wait();
        }
    }

    // Object.notifyの代わりとなるメソッドです。
    public synchronized void signal() {
        signaled = true;
        notify();
    }
}

そして、先ほどのコード例の同期用オブジェクトを、SyncObjに置き換えます。

// 基準となる時刻
final long current = System.nanoTime();

// 基準となる時刻からの差分を秒として取得します。
final DoubleSupplier elapsedSec = () -> (System.nanoTime() - current) / 1000000000d;

final ExecutorService executorService = Executors.newSingleThreadExecutor();
try {
    // 同期用のオブジェクトです。
    final var syncObj = new SyncObj();

    // サブスレッド上で実行する処理です。
    final Runnable task = () -> {
        try {
            System.out.printf("task start : %f sec.\n", elapsedSec.getAsDouble());
            syncObj.await(); // ★
        } catch (InterruptedException e) {
            System.out.printf("InterruptedException発生! : %f sec.\n", elapsedSec.getAsDouble());
        } finally {
            System.out.printf("task end : %f sec.\n", elapsedSec.getAsDouble());
        }
    };

    executorService.submit(task);

    Thread.sleep(2000);

    syncObj.signal(); // ★

} finally {
    executorService.shutdown();
    System.out.printf("-- shutdown -- : %f sec.\n", elapsedSec.getAsDouble());
}

final var result = executorService.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("term %b : %f sec.\n", result, elapsedSec.getAsDouble());
//task start : 0.003254 sec.
//task end : 2.016415 sec.
//-- shutdown -- : 2.016554 sec.
//term true : 2.017053 sec.

このコードであれば、スプリアス・ウェイクアップが発生しても、タスクは2秒後に終了します。

標準APIのCountDownLatchを使おう

public class CountDownLatch extends Object
ほかのスレッドで実行中の操作セットが完了するまで、1つ以上のスレッドを待機可能にする同期化支援機能です。

先ほどは、SyncObjという独自のクラスを定義しましたが、標準APIにはもっとよいAPIが用意されています。
それが CountDownLatch クラスです。

シンプルで、汎用的に使えて良いAPIだと思います。
そして、API仕様的にスプリアス・ウェイクアップの影響は受けません。

以下は CountDownLatch を使った例になります。

// 基準となる時刻
final long current = System.nanoTime();

// 基準となる時刻からの差分を秒として取得します。
final DoubleSupplier elapsedSec = () -> (System.nanoTime() - current) / 1000000000d;

final ExecutorService executorService = Executors.newSingleThreadExecutor();
try {
    // 同期用のオブジェクトです。
    final var latch = new CountDownLatch(1);

    // サブスレッド上で実行する処理です。
    final Runnable task = () -> {
        try {
            System.out.printf("task start : %f sec.\n", elapsedSec.getAsDouble());
            latch.await(); // ★
        } catch (InterruptedException e) {
            System.out.printf("InterruptedException発生! : %f sec.\n", elapsedSec.getAsDouble());
        } finally {
            System.out.printf("task end : %f sec.\n", elapsedSec.getAsDouble());
        }
    };

    executorService.submit(task);

    Thread.sleep(2000);

    latch.countDown(); // ★

} finally {
    executorService.shutdown();
    System.out.printf("-- shutdown -- : %f sec.\n", elapsedSec.getAsDouble());
}

final var result = executorService.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("term %b : %f sec.\n", result, elapsedSec.getAsDouble());

//task start : 0.004346 sec.
//task end : 2.018145 sec.
//-- shutdown -- : 2.018165 sec.
//term true : 2.018829 sec.

もし、CountDownLatchで目的が達成できるのであれば、Object.waitではなく CountDownLatch を使うことをおすすめします。

オブジェクト・モニターを厳密に制御したい場合などは、Object.waitでないと実現できない…ということもあるかもしれません。

補足

スプリアス・ウェイクアップについて、外部サイトで参考となる記事をいくつかご紹介します。

まとめ

Object.wait には、スプリアス・ウェイクアップと呼ばれる少しやっかいな仕様があります。
もし、Object.waitを使う場合は、スプリアス・ウェイクアップを考慮した実装が必要となります。

もしくは、スプリアス・ウェイクアップの影響を受けない CountDownLatch を代わりに使うのもおすすめです。


関連記事

ページの先頭へ