Java並列ストリーム-parallel()メソッドを呼び出す順序[終了]


11
AtomicInteger recordNumber = new AtomicInteger();
Files.lines(inputFile.toPath(), StandardCharsets.UTF_8)
     .map(record -> new Record(recordNumber.incrementAndGet(), record)) 
     .parallel()           
     .filter(record -> doSomeOperation())
     .findFirst()

これを書いたとき、並列はマップの後に配置されるため、スレッドはマップ呼び出しのみで生成されると想定しました。しかし、ファイルのいくつかの行は、実行ごとに異なるレコード番号を取得していました。

公式のJavaストリームドキュメントといくつかのWebサイトを読んで、ストリームが内部でどのように機能するかを理解しました。

いくつかの質問:

  • Java並列ストリームは、ArrayList、LinkedListなどのすべてのコレクションによって実装されるSplitIteratorに基づいて機能します。これらのコレクションから並列ストリームを構築する場合、対応する分割イテレータを使用して、コレクションを分割および反復します。これは、マップの結果(つまり、レコードポジョ)ではなく、元の入力ソース(ファイル行)レベルで並列処理が発生した理由を説明しています。私の理解は正しいですか?

  • 私の場合、入力はファイルIOストリームです。使用される分割イテレーターはどれですか?

  • parallel()パイプラインのどこに配置してもかまいません。元の入力ソースは常に分割され、残りの中間操作が適用されます。

    この場合、Javaは、ユーザーが元のソース以外のパイプラインの任意の場所に並列操作を配置できないようにする必要があります。それは、Javaストリームが内部でどのように機能するかを知らない人たちに間違った理解を与えているからです。私が知っているparallel()操作はStreamオブジェクトタイプのために、それがこのように働いている、ように定義されていたであろう。ただし、代替ソリューションを提供することをお勧めします。

  • 上記のコードスニペットでは、入力ファイルのすべてのレコードに行番号を追加しようとしているので、順序付けする必要があります。ただし、doSomeOperation()ヘビーウェイトのロジックなので並行して適用したい。達成する1つの方法は、独自にカスタマイズした分割イテレーターを作成することです。他に方法はありますか?


2
それは、Javaクリエーターがどのようにインターフェースを設計することを決定したかに関係しています。パイプラインにリクエストを送信すると、最終的な操作ではないものすべてが最初に収集されます。parallel()基になるストリームオブジェクトに適用される一般的な修飾子リクエストにすぎません。パイプに最終操作を適用しない場合、つまり何も「実行」されない限り、ソースストリームは1つしかないことに注意してください。そうは言っても、基本的にはJavaデザインの選択に疑問を投げかけるだけです。これは意見に基づくものであり、私たちは本当にそれを助けることはできません。
ザブザード

1
私は完全にあなたの要点と混乱を理解しますが、もっと良い解決策があるとは思いません。このメソッドはStreamインターフェースで直接提供されますStream。また、カスケードが適切に行われているため、すべての操作が再び返されます。誰かがあなたに与えたいと思っているStreamが、すでにmapそれにいくつかの操作を適用していると想像してください。ユーザーとして、並行して実行するかどうかを決定できるようにしたいと考えています。したがってparallel()、ストリームはすでに存在していますが、あなたが静止して呼び出すことが可能でなければなりません。
ザブザード

1
さらに、ストリームの一部を順次実行してから、後で並列に切り替えたい理由を質問します。ストリームがすでに並列実行に適格な大きさである場合、これはおそらくパイプラインの前のすべてにも当てはまります。それで、その部分にも並列実行を使用しないのはなぜですか?サイズが劇的に増加しflatMapたり、スレッドに対して安全でないメソッドなどを実行したりするような、エッジケースが発生することを理解しています。
ザブザード

1
@ Zabuza私はJavaデザインの選択に疑問を投げかけていませんが、懸念を表明しています。基本的なJavaストリームユーザーは、ストリームの動作を理解していない限り、同じ混乱を招く可能性があります。私はあなたの2番目のコメントに完全に同意します。私はあなたが述べたようにそれ自身の欠点を持つ可能性のある一つの可能​​な解決策を強調しました。しかし、他の方法で解決できるかどうかはわかります。3番目のコメントについては、説明の最後のポイントでユースケースについてすでに説明しました
エクスプローラー

1
@Eugene時にPathローカルファイルシステム上にあり、最近のJDKを使用している、spliteratorは、1024の倍数をバッチ処理よりも優れた並列処理能力を持っていますが、バランスの取れた分割は、いくつかの中でも、逆効果かもしれfindFirst...シナリオ
ホルガー

回答:


8

これは、マップの結果(レコードポジョ)ではなく、元の入力ソース(ファイル行)レベルで並列処理が発生した理由を説明しています。

ストリーム全体は並列または順次です。順次または並列に実行する操作のサブセットは選択しません。

端末操作が開始されると、ストリームパイプラインは、それが呼び出されるストリームの方向に応じて、順次または並列に実行されます。[...]端末操作が開始されると、呼び出されたストリームのモードに応じて、ストリームパイプラインが順次または並列に実行されます。同じソース

ご指摘のとおり、並列ストリームは分割イテレータを使用します。明らかに、これは操作が実行を開始する前にデータを分割することです。


私の場合、入力はファイルIOストリームです。使用される分割イテレーターはどれですか?

ソースを見ると、 java.nio.file.FileChannelLinesSpliterator


パイプラインのどこにparallel()を配置してもかまいません。元の入力ソースは常に分割され、残りの中間操作が適用されます。

正しい。あなたも呼び出すことができますparallel()し、sequential()複数回。最後に呼び出された方が勝ちます。を呼び出すとparallel()、返されるストリームにそれを設定します。前述のとおり、すべての操作は順次または並列に実行されます。


この場合、Javaは、ユーザーが元のソース以外のパイプラインのどこにでも並列操作を配置できないようにする必要があります...

これは意見の問題になります。ZabuzaはJDKデザイナーの選択をサポートする正当な理由を与えていると思います。


達成する1つの方法は、独自にカスタマイズした分割イテレーターを作成することです。他に方法はありますか?

これはオペレーションによって異なります

  • 場合はfindFirst()、あなたの本当の端末操作であると多くの呼び出しがありませんので、その後、あなたも、並列実行を心配する必要はありませんdoSomething()とにかく(findFirst()短絡です)。.parallel()実際には、複数の要素が処理される可能性がありますがfindFirst()、シーケンシャルストリームでは処理されません。
  • 端末操作で多くのデータが作成されない場合はRecord、シーケンシャルストリームを使用してオブジェクトを作成し、結果を並列処理することができます。

    List<Record> smallData = Files.lines(inputFile.toPath(), 
                                         StandardCharsets.UTF_8)
      .map(record -> new Record(recordNumber.incrementAndGet(), record)) 
      .collect(Collectors.toList())
      .parallelStream()     
      .filter(record -> doSomeOperation())
      .collect(Collectors.toList());
    
  • パイプラインがメモリに大量のデータをロードする場合(これがを使用している理由である可能性がありますFiles.lines())、おそらくカスタムの分割反復子が必要になります。ただし、そこに行く前に、他のオプションを検討します(最初にid列を使用して行を保存するなど、それは私の意見です)。
    次のように、レコードを小さいバッチで処理することも試みます。

    AtomicInteger recordNumber = new AtomicInteger();
    final int batchSize = 10;
    
    try(BufferedReader reader = Files.newBufferedReader(inputFile.toPath(), 
            StandardCharsets.UTF_8);) {
        Supplier<List<Record>> batchSupplier = () -> {
            List<Record> batch = new ArrayList<>();
            for (int i = 0; i < batchSize; i++) {
                String nextLine;
                try {
                    nextLine = reader.readLine();
                } catch (IOException e) {
                    //hanlde exception
                    throw new RuntimeException(e);
                }
    
                if(null == nextLine) 
                    return batch;
                batch.add(new Record(recordNumber.getAndIncrement(), nextLine));
            }
            System.out.println("next batch");
    
            return batch;
        };
    
        Stream.generate(batchSupplier)
            .takeWhile(list -> list.size() >= batchSize)
            .map(list -> list.parallelStream()
                             .filter(record -> doSomeOperation())
                             .collect(Collectors.toList()))
            .flatMap(List::stream)
            .forEach(System.out::println);
    }
    

    これはdoSomeOperation()、すべてのデータをメモリにロードせずに並行して実行されます。しかし、それbatchSizeは考えを与える必要があることに注意してください。


1
説明をありがとう。あなたが強調した3番目のソリューションについて知っておくのは良いことです。takeWhileとSupplierを使用していないので、見ていきます。
エクスプローラ

2
カスタムSpliterator実装はこれよりも複雑ではなく、より効率的な並列処理を可能にします…
Holger

1
内部parallelStream処理のそれぞれには、処理の開始と最終結果の待機のための固定オーバーヘッドがあり、並列処理はに制限されていbatchSizeます。まず、アイドルスレッドを回避するには、現在利用可能なCPUコア数の倍数が必要です。次に、この数は固定オーバーヘッドを補正するのに十分な高さである必要がありますが、数値が大きいほど、並列処理が開始される前に発生する順次読み取り操作による一時停止が大きくなります。
Holger

1
外部ストリームを並列にすると、現在の実装では内部ストリームとの干渉が悪くなり、さらにStream.generate、順序付けされていないストリームが生成されるため、OPの意図したユースケースでは機能しませんfindFirst()。対照的に、チャンクを返すスプリッターを備えた単一の並列ストリームはtrySplit簡単に機能し、ワーカースレッドは前のチャンクの完了を待たずに次のチャンクを処理できます。
Holger

2
findFirst()操作が少数の要素のみを処理すると想定する理由はありません。すべての要素の90%を処理した後でも、最初の一致が発生する場合があります。さらに、1,000万行ある場合、10%の後に一致を見つける場合でも、100万行を処理する必要があります。
Holger

7

元のStreamデザインには、異なる並列実行設定で後続のパイプラインステージをサポートするというアイデアが含まれていましたが、このアイデアは放棄されました。APIはこの時期に由来する可能性がありますが、一方で、呼び出し側に並列実行または順次実行の明確な単一の決定を強制するAPI設計は、はるかに複雑になります。

による実際のSpliterator使用Files.lines(…)は実装に依存します。Java 8(OracleまたはOpenJDK)では、常にと同じになりBufferedReader.lines()ます。最近のJDKではPath、がデフォルトのファイルシステムに属し、文字セットがこの機能でサポートされているものの1つである場合、専用のSpliterator実装であるを取得しますjava.nio.file.FileChannelLinesSpliterator。前提条件が満たされていなくても、と同じ結果が得られますBufferedReader.lines()。これは、Iterator内に実装されBufferedReader、を介してラップされていることに基づいていますSpliterators.spliteratorUnknownSize

特定のタスクはSpliterator、並列処理の前にソースで行番号付けを直接実行できるカスタムで処理するのが最適です。これにより、後続の並列処理が制限なしに可能になります。

public static Stream<Record> records(Path p) throws IOException {
    LineNoSpliterator sp = new LineNoSpliterator(p);
    return StreamSupport.stream(sp, false).onClose(sp);
}

private static class LineNoSpliterator implements Spliterator<Record>, Runnable {
    int chunkSize = 100;
    SeekableByteChannel channel;
    LineNumberReader reader;

    LineNoSpliterator(Path path) throws IOException {
        channel = Files.newByteChannel(path, StandardOpenOption.READ);
        reader=new LineNumberReader(Channels.newReader(channel,StandardCharsets.UTF_8));
    }

    @Override
    public void run() {
        try(Closeable c1 = reader; Closeable c2 = channel) {}
        catch(IOException ex) { throw new UncheckedIOException(ex); }
        finally { reader = null; channel = null; }
    }

    @Override
    public boolean tryAdvance(Consumer<? super Record> action) {
        try {
            String line = reader.readLine();
            if(line == null) return false;
            action.accept(new Record(reader.getLineNumber(), line));
            return true;
        } catch (IOException ex) {
            throw new UncheckedIOException(ex);
        }
    }

    @Override
    public Spliterator<Record> trySplit() {
        Record[] chunks = new Record[chunkSize];
        int read;
        for(read = 0; read < chunks.length; read++) {
            int pos = read;
            if(!tryAdvance(r -> chunks[pos] = r)) break;
        }
        return Spliterators.spliterator(chunks, 0, read, characteristics());
    }

    @Override
    public long estimateSize() {
        try {
            return (channel.size() - channel.position()) / 60;
        } catch (IOException ex) {
            return 0;
        }
    }

    @Override
    public int characteristics() {
        return ORDERED | NONNULL | DISTINCT;
    }
}

0

そして、以下は、並列の適用が適用される場合の簡単なデモです。peekの出力は、2つの例の違いを明確に示しています。注:map呼び出しは、の前に別のメソッドを追加するために投げられparallelます。

IntStream.rangeClosed (1,20).peek(a->System.out.print(a+" "))
        .map(a->a + 200).sum();
System.out.println();
IntStream.rangeClosed(1,20).peek(a->System.out.print(a+" "))
        .map(a->a + 200).parallel().sum();
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.