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


86

静的初期化子でラムダを使用して並列ストリームを使用すると、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を使用しています。


4
範囲(0、1)の場合、プログラムは正常に終了します。(0、2)以上でハングします。
Laszlo Hirdi 2016年


2
実際には、APIが異なるだけで、まったく同じ質問/問題です。
ディディエL

3
クラスの初期化が完了していないため、バックグラウンドスレッドで使用できないときに、バックグラウンドスレッドでクラスを使用しようとしています。
Peter Lawrey 2016年

4
@ Solomonoff'sSecreti -> iはメソッド参照ではないためstatic method、Deadlockクラスに実装されています。交換した場合i -> iFunction.identity()、このコードは問題ないはずです。
Peter Lawrey 2016年

回答:


71

Stuart Marksによって「NotaIssue」としてクローズされた非常に類似したケース(JDK-8143380)のバグレポートを見つけました。

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

並列ストリームロジックをクラス静的初期化子の外に移動するには、テストプログラムを変更する必要があります。問題ではないとして閉じる。


その別のバグレポート(JDK-8136753)を見つけることができました。これも、StuartMarksによって「NotaIssue」としてクローズされました。

これは、Fruit列挙型の静的初期化子がクラスの初期化とうまく相互作用していないために発生しているデッドロックです。

クラスの初期化の詳細については、Java言語仕様のセクション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には、この状況に対する警告追加するための未解決の問題があることに注意してください。


20
「これは、機能を設計したときに考慮されました」および「このバグの原因はわかっていますが、修正方法はわかりませ」は、「これはバグではない」という意味ではありません。これは絶対にバグです。
BlueRaja-Danny Pflughoeft 2016年

13
@ bayou.io主な問題は、ラムダではなく、静的イニシャライザー内でスレッドを使用することです。
スチュアートマークス

5
ところで、Tunakiは私のバグレポートを掘り下げてくれてありがとう。:-)
スチュアートマークス

13
@ bayou.io:これは、クラスレベルではコンストラクターの場合と同じであり、thisオブジェクトの構築中にエスケープできます。基本的なルールは、初期化子でマルチスレッド操作を使用しないことです。これは理解しにくいとは思いません。ラムダ実装関数をレジストリに登録する例は別のものです。これらのブロックされたバックグラウンドスレッドの1つを待たない限り、デッドロックは発生しません。それでも、クラス初期化子でこのような操作を行うことは強くお勧めしません。それは彼らが意図しているものではありません。
ホルガー2016年

9
プログラミングスタイルのレッスンは、静的イニシャライザーをシンプルに保つことだと思います。
レドワルド2016年

16

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) {}
}

5
@ Solomonoff'sSecretこれは実装の選択です。ラムダのコードはどこかに行かなければなりません。Javacは、それを包含クラスの静的メソッドにコンパイルします(lambda1この例に類似しています)。各ラムダを独自のクラスに配置すると、かなりコストがかかります。
スチュアートマークス

1
@StuartMarksラムダが関数型インターフェースを実装するクラスを作成することを考えると、この投稿の2番目の例のように、ラムダの実装を関数型インターフェースのラムダの実装に配置するのと同じくらい効率的ではないでしょうか。それは確かに物事を行うための明白な方法ですが、私は彼らが彼らがそうであるように行われる理由があると確信しています。
モニカを2016

6
@ Solomonoff'sSecretラムダは実行時に(java.lang.invoke.LambdaMetafactoryを介して)クラスを作成する場合がありますが、ラムダ本体はコンパイル時にどこかに配置する必要があります。したがって、ラムダクラスは、VMの魔法を利用して、.classファイルからロードされる通常のクラスよりも安価にすることができます。
ジェフリーボスブーム2016年

1
@ Solomonoff'sSecretはい、JeffreyBosboomの返信は正しいです。将来のJVMで既存のクラスにメソッドを追加できるようになった場合、メタファクトリは新しいクラスをスピンする代わりにそれを行う可能性があります。(純粋な憶測。)
スチュアートマークス

3
@Solomonoffの秘密:あなたのような些細なラムダ式を見ても判断しないでくださいi -> i。彼らは標準ではありません。ラムダ式は、メンバーを含む周囲のクラスのすべてのメンバーを使用できます。privateこれにより、定義クラス自体が自然な場所になります。これらすべてのユースケースを、定義クラスのメンバーを使用せずに、トリビアルラムダ式をマルチスレッドで使用するクラス初期化子の特殊なケースに最適化された実装に苦しめることは、実行可能なオプションではありません。
ホルガー2016年

14

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

記事からの引用は次のとおりです。


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::sumcallに置き換えます-何が変わりますか?

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

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

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.