wait() したい場合は、専用のオブジェクトで wait() しなければならない

今日遭遇した問題。

以下のコードは、起動引数に何かを与えるとスレッドを起動して無限に待ち、何も与えないとスレッドを起動せずに無限に待つ。

public class Wait extends Thread {
    public static void main(String[] args) throws Exception {
        Wait w = new Wait();
        synchronized (w) {
            if (args.length != 0) {
                w.start();
            }
            System.out.println("AAA");
            w.wait();
            System.out.println("BBB");
        }
    }
    public void run() {
        System.out.println("XXX");
    }
}

スレッドを起動せずに実行すると、以下が出力されて止まる。無限に待っているわけだ。

AAA

ところが、スレッドを起動して実行すると、以下のように出力されて、プログラムが終了してしまう。

AAA
XXX
BBB

サンプルコードは Thread のインスタンスで wait() や notifyAll() を行っているが、Oracle VM の実装は、start() が呼び出された thread インスタンスに対しておそらくスレッド管理の都合で notifyAll() が呼び出されるように実装されており、意図せず wait が解除されてしまう。

特に Thread のインスタンスで wait() を行うのは不具合の原因となるが、それ以外でも、例えば Foo.class.wait() や list.wait() などが安全かどうかは保障されない。

確実なのは、Object のインスタンスなので、面倒くさがらずに排他処理専用の Object インスタンスを作成するようにしよう。

2013/07/26 追記

この挙動は実は Java 言語仕様に明記されていることが分かった。

An internal action by the implementation. Implementations are permitted, although not encouraged, to perform "spurious wake-ups", that is, to remove threads from wait sets and thus enable resumption without explicit instructions to do so.

Chapter 17. Threads and Locks

条件としては「start() が呼び出された thread インスタンスに対しておそらくスレッド管理の都合で notifyAll() が呼び出されるように実装されており」ではなく、「thread が終了して削除される時」ということで、実装を以下のように変更すると、確かにそのように動作していることが確認できる。

public class Wait extends Thread {
    public static void main(String[] args) throws Exception {
        Wait w = new Wait();
        synchronized (w) {
            if (args.length != 0) {
                w.start();
            }
            System.out.println("AAA");
            w.wait();
            System.out.println("BBB");
        }
    }
    public void run() {
        System.out.println("XXX");
        try {
            Thread.sleep(10000); // 待ってみる
        } catch (Exception e) {}
    }
}

Effective Java では、この "spurious wake-ups"、日本語に直すと「偽りの目覚め」について言及されていて、以下のような(バッドノウハウを用いて)実装をすることを推奨している。

while (!condition) {
    wait();
}

要するに、誰とも知れない輩に wait を起こされてしまう可能性を考慮して、条件を満たしていなかったら再び wait せよ、ということだ。

これは、現在では Javadoc にも取り込まれて(しまって)いる。

スレッドは、通知、割り込み、タイムアウトなしに再開されることがあります。 これは、「スプリアスウェイクアップ」と呼ばれています。スプリアスウェイクアップは、実際にはまれにしか発生しませんが、アプリケーションでは、スレッドが再開されることで発生する可能性がある条件をテストし、条件が満たされない場合は待機を続けて、スプリアスウェイクアップから保護しなければいけません。つまり、次のようにループで常に待機が発生するようにする必要があります。

Object (Java Platform SE 6)

Effective Java で指摘された時点で、実装を直すべきだったが、仕様を変更してしまったということだと思う。残念なことだ。