web-dev-qa-db-ja.com

共有の可変性が悪いのはなぜですか?

私はJavaに関するプレゼンテーションを見ていましたが、ある時点で、講師はこう言いました。

"可変性はOK、共有はニース、共有可変性は悪魔の仕事です。"

彼が言及していたのは、次のコードで、「非常に悪い習慣」だと考えていました。

//double the even values and put that into a list.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 1, 2, 3, 4, 5);
List<Integer> doubleOfEven = new ArrayList<>();

numbers.stream()
       .filter(e -> e % 2 == 0)
       .map(e -> e * 2)
       .forEach(e -> doubleOfEven.add(e));

次に、使用する必要があるコードの作成を進めました。

List<Integer> doubleOfEven2 =
      numbers.stream()
             .filter(e -> e % 2 == 0)
             .map(e -> e * 2)
             .collect(toList());

コードの最初の部分が「悪い習慣」である理由がわかりません。私にとって、両者は同じ目標を達成します。

46
George Cernat

最初のサンプルスニペットの説明

並列処理を実行すると問題が発生します。

//double the even values and put that into a list.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 1, 2, 3, 4, 5);
List<Integer> doubleOfEven = new ArrayList<>();

numbers.stream()
       .filter(e -> e % 2 == 0)
       .map(e -> e * 2)
       .forEach(e -> doubleOfEven.add(e)); // <--- Unnecessary use of side-effects!

これは不必要に副作用を使用しますが、ストリームの使用に関しては正しく使用してもすべての副作用が悪いわけではありませんが、入力の異なる部分で同時に実行しても安全な動作を提供する必要があります。つまり、作業を行うために共有された可変データにアクセスしないコードを記述します。

この線:

.forEach(e -> doubleOfEven.add(e)); // Unnecessary use of side-effects!

副作用を不必要に使用し、並列で実行すると、ArrayListのスレッドセーフではないために誤った結果が生じます。

しばらく前に、私はHenrik Eichenhardt共有の可変状態がすべての悪の根源である理由について答えています。

これは、共有の可変性がnot良い理由に関する短い推論です。ブログから抽出。

非決定性=並列処理+可変状態

この方程式は、基本的に、並列処理と可変状態の両方の組み合わせにより、非決定的なプログラムの動作が発生することを意味します。並列処理を行うだけで不変の状態しか持たない場合は、すべてうまくいき、プログラムについて推論するのは簡単です。一方、可変データを使用して並列処理を行いたい場合は、プログラムのこれらのセクションを本質的にシングルスレッドにする可変変数へのアクセスを同期する必要があります。これは新しいものではありませんが、この概念がそれほどエレガントに表現されているのを見たことはありません。 非決定的なプログラムが壊れています

このブログでは、適切な同期のない並列プログラムが壊れている理由に関する内部詳細を導き出します。これは、追加のリンク内で見つけることができます。

2番目のサンプルスニペットの説明

List<Integer> doubleOfEven2 =
      numbers.stream()
             .filter(e -> e % 2 == 0)
             .map(e -> e * 2)
             .collect(toList()); // No side-effects! 

これは、Collectorを使用してこのストリームの要素に対してcollectreduction操作を使用します。

これは、はるかにより安全であり、より効率的であり、並列化により適しています。

36
Aomine

問題は、講義が同時に間違っていることです。彼が提供した例では、forEachを使用しています。

この操作の動作は明示的に非決定的です。並列ストリームパイプラインの場合、この操作は、ストリームの遭遇順序を尊重することを保証しません。

次を使用できます。

 numbers.stream()
            .filter(e -> e % 2 == 0)
            .map(e -> e * 2)
            .parallel()
            .forEachOrdered(e -> doubleOfEven.add(e));

そして、常に同じ結果が保証されます。

一方、コレクターはCollectors.toListを尊重するため、encounter orderを使用する例の方が適切であり、正常に動作します。

興味深い点は、Collectors.toListがその下でArrayListを使用することですスレッドセーフコレクションではありません。それは、それらの多くを(並列処理のために)使用し、最後にマージするだけです。

最後の注意点は、並列および順次は遭遇順序に影響を与えない、それはStreamに適用される操作です。優れた読み取り こちら

また、特にside-effectsに依存している場合、スレッドセーフコレクションを使用しても、Streamsでは完全に安全ではないことを考慮する必要があります。

 List<Integer> numbers = Arrays.asList(1, 3, 3, 5);
    Set<Integer> seen = Collections.synchronizedSet(new HashSet<>());
    List<Integer> collected = numbers.stream()
            .parallel()
            .map(e -> {
                if (seen.add(e)) {
                    return 0;
                } else {
                    return e;
                }
            })
            .collect(Collectors.toList());

    System.out.println(collected);

この時点でcollected[0,3,0,0] OR [0,0,3,0]または他の何かである可能性があります。

13
Eugene

2つのスレッドが同時にこのタスクを実行し、2番目のスレッドが最初の命令の1命令後ろにあると仮定します。

最初のスレッドはdoubleOfEvenを作成します。 2番目のスレッドはdoubleOfEvenを作成し、最初のスレッドによって作成されたインスタンスはガベージコレクションされます。次に、両方のスレッドがdoubleOfEventにすべての偶数の倍を追加するため、0、4、8、12 ...の代わりに0、0、4、4、8、8、12、12、...が含まれます(実際には、これらのスレッドは完全に同期していないため、問題が発生する可能性のあるものはすべて問題になります。

2番目のソリューションの方がはるかに優れているわけではありません。同じグローバルを設定する2つのスレッドがあります。この場合、彼らは両方を論理的に等しい値に設定していますが、2つの異なる値に設定した場合、その後の値はわかりません。 1つのスレッドがnot必要な結果を取得します。

6
gnasher729