web-dev-qa-db-ja.com

静的初期化子にラムダを使用した並列ストリームがデッドロックを引き起こすのはなぜですか?

静的イニシャライザーでラムダを使用したパラレルストリームを使用すると、CPUの使用率がゼロになると思われる奇妙な状況に遭遇しました。コードは次のとおりです。

class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(i -> i).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

これは、この動作の最小再現テストケースのようです。もし私が:

  • 静的初期化子の代わりにブロックをmainメソッドに配置し、
  • 並列化を削除する、または
  • ラムダを削除し、

コードはすぐに完了します。誰でもこの動作を説明できますか?それはバグですか、これは意図したものですか?

OpenJDKバージョン1.8.0_66-internalを使用しています。

76

非常によく似たケース( JDK-814338 )のバグレポートを見つけましたが、Stuart Marksによって「問題ではない」としてクローズされました。

これはクラス初期化デッドロックです。テストプログラムのメインスレッドは、クラスの初期化進行中フラグを設定するクラス静的初期化子を実行します。このフラグは、静的初期化子が完了するまで設定されたままです。静的イニシャライザは、並列ストリームを実行します。これにより、ラムダ式が他のスレッドで評価されます。これらのスレッドは、クラスが初期化を完了するのを待ってブロックします。ただし、並列タスクが完了するまでメインスレッドがブロックされ、デッドロックが発生します。

テストプログラムを変更して、パラレルストリームロジックをクラスの静的初期化子の外に移動する必要があります。問題ではありません。


その別のバグレポート( JDK-813675 )を見つけることができました。これも、スチュアートマークスによる「Not an Issue」としてクローズされました。

これは、Fruit enumの静的初期化子がクラスの初期化と不適切に相互作用しているために発生しているデッドロックです。

クラスの初期化の詳細については、Java Language Specification、セクション12.4.2を参照してください。

http://docs.Oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2

簡単に言えば、何が起こっているのかは次のとおりです。

  1. メインスレッドはFruitクラスを参照し、初期化プロセスを開始します。これにより、初期化の進行中フラグが設定され、メインスレッドで静的初期化子が実行されます。
  2. 静的初期化子は、別のスレッドでいくつかのコードを実行し、終了するまで待機します。この例では並列ストリームを使用していますが、これ自体はストリームとは関係ありません。何らかの方法で別のスレッドでコードを実行し、そのコードが終了するのを待っても同じ効果があります。
  3. 他のスレッドのコードは、初期化の進行中フラグをチェックするFruitクラスを参照します。これにより、フラグがクリアされるまで他のスレッドがブロックされます。 (JLS 12.4.2のステップ2を参照してください。)
  4. メインスレッドは、他のスレッドが終了するのを待ってブロックされているため、静的初期化子は完了しません。初期化の進行中フラグは、静的初期化子が完了するまでクリアされないため、スレッドはデッドロックされます。

この問題を回避するには、このクラスの初期化を完了する必要があるコードを他のスレッドに実行させずに、クラスの静的初期化が迅速に完了するようにしてください。

問題ではありません。


FindBugsには警告を追加するための未解決の問題がある この状況に注意してください。

66
Tunaki

Deadlockクラス自体を参照する他のスレッドがどこにあるのか疑問に思っている人のために、Javaラムダはあなたがこれを書いたように振る舞います:

public class Deadlock {
    public static int lambda1(int i) {
        return i;
    }
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return lambda1(operand);
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

通常の匿名クラスでは、デッドロックは発生しません。

public class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return operand;
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}
16
Tamas Hegedus

Andrei Pangin によるこの問題の優れた説明があります。日付は2015年4月7日です。利用可能です here ですが、ロシア語で書かれています(コードサンプルを確認することをお勧めします)とにかく-彼らは国際的です)。一般的な問題は、クラスの初期化中のロックです。

この記事からの引用を以下に示します。


[〜#〜] jls [〜#〜] によると、すべてのクラスには固有の初期化ロックがあり、初期化。初期化中に他のスレッドがこのクラスにアクセスしようとすると、初期化が完了するまでロックでブロックされます。クラスが同時に初期化されると、デッドロックが発生する可能性があります。

整数の合計を計算する簡単なプログラムを作成しましたが、何を印刷する必要がありますか?

_public class StreamSum {
    static final int SUM = IntStream.range(0, 100).parallel().reduce((n, m) -> n + m).getAsInt();

    public static void main(String[] args) {
        System.out.println(SUM);
    }
} 
_

parallel()を削除するか、ラムダを_Integer::sum_呼び出しに置き換えます。何が変わるのでしょうか?

ここで再びデッドロックが表示されます[以前の記事でクラス初期化子にデッドロックの例がいくつかありました]。 parallel()ストリーム操作が別のスレッドプールで実行されるため。これらのスレッドは、StreamSumクラス内の_private static_メソッドとしてバイトコードで記述されたラムダ本体を実行しようとします。ただし、このメソッドは、ストリームの完了結果を待機するクラス静的イニシャライザの完了前に実行できません。

さらに驚いたことに、このコードは環境によって動作が異なります。シングルCPUマシンでは正常に動作し、マルチCPUマシンではハングする可能性が高いでしょう。この違いは、Fork-Joinプールの実装に起因しています。パラメータ_-Djava.util.concurrent.ForkJoinPool.common.parallelism=N_を変更して、自分で確認できます

13
AdamSkywalker