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()

当我写这篇文章时,我假设线程将仅在map调用中产生,因为在map之后放置了parallel。但是文件中的某些行每次执行都会获得不同的记录号。

我阅读了Java流的正式文档和一些网站,以了解流是如何工作的。

几个问题:

  • Java并行流基于SplitIterator进行工作,它由ArrayList,LinkedList等每个集合实现。当我们从这些集合构造并行流时,将使用相应的split迭代器对集合进行拆分和迭代。这解释了为什么并行性发生在原始输入源(文件行)级别而不是映射结果(即Record pojo)。我的理解正确吗?

  • 就我而言,输入是文件IO流。将使用哪个拆分迭代器?

  • 我们放置parallel()在管道中的位置无关紧要。原始输入源将始终被分割,其余的中间操作将被应用。

    在这种情况下,Java不应允许用户在管道中除原始源之外的任何地方进行并行操作。因为,它为那些不知道java流内部工作方式的人提供了错误的理解。我知道parallel()操作将为Stream对象类型定义,因此,它是以这种方式工作的。但是,最好提供一些替代解决方案。

  • 在上面的代码片段中,我试图将行号添加到输入文件中的每个记录,因此应该对它进行排序。但是,我想doSomeOperation()并行应用,因为它是繁重的逻辑。一种实现方式是编写自己的自定义拆分迭代器。还有其他办法吗?


2
它与Java创建者决定设计接口的方式有关。您将请求放置到管道中,所有不是最终操作的内容都将首先被收集。parallel()只不过是应用于基础流对象的常规修饰符请求。请记住,如果您不对管道应用最终操作,即只要没有“执行”,就只有一个源流。话虽如此,您基本上只是在质疑Java设计选择。这是基于意见的,我们对此无能为力。
Zabuzard

1
我完全明白你的意思和困惑,但是我认为没有更好的解决方案。该方法Stream直接在界面中提供,并且由于很好的级联,每个操作又返回Stream了。想象有人想要给您一个Stream但已经应用了一些类似的操作map。作为用户,您仍然希望能够决定是否使其并行执行。因此parallel(),尽管该流已经存在,但您仍然必须可以仍然呼叫。
Zabuzard

1
此外,我想问一下为什么要顺序执行流的一部分,然后稍后切换到并行。如果流已经足够大,可以进行并行执行,那么这可能也适用于管道中的所有内容。那么,为什么不对该部分也使用并行执行呢?我得到了一些极端的情况,例如如果您使用flatMap或不执行线程不安全的方法或类似方法大大增加了大小。
Zabuzard

1
@Zabuza我不是在质疑Java设计的选择,但我只是在引起我的关注。除非他们了解流的工作原理,否则任何基本的Java流用户都可能会感到困惑。我完全同意您的第二点意见。我刚刚强调了一种可能的解决方案,正如您所提到的那样,它可能会有其不利之处。但是,我们可以看看是否可以通过其他任何方式解决它。关于您的第三条评论,我已经在描述的最后一点提到了我的用例
Explorer

1
@Eugene当s在Path本地文件系统上并且您使用的是最新的JDK时,分离器将具有比批处理1024的倍数更好的并行处理能力。但是在某些findFirst情况下,均衡的拆分甚至可能适得其反……
Holger

Answers:


8

这解释了为什么并行性发生在原始输入源(文件行)级别而不是映射结果(即Record pojo)。

整个流是并行的或顺序的。我们不选择操作的子集来顺序或并行运行。

启动终端操作后,将根据调用流的方向依次或并行执行流管道。当终端操作启动时,流管线根据被调用的流的模式顺序或并行执行。同一来源

如您所述,并行流使用拆分迭代器。显然,这是在操作开始运行之前对数据进行分区。


就我而言,输入是文件IO流。将使用哪个拆分迭代器?

从源头看,我看到它的用途 java.nio.file.FileChannelLinesSpliterator


在管道中放置parallel()的位置都没有关系。原始输入源将始终被分割,其余的中间操作将被应用。

对。你甚至可以打电话parallel()sequential()多次。最后调用的那个将获胜。当我们调用时parallel(),我们将其设置为返回的流。并且如上所述,所有操作都按顺序或并行运行。


在这种情况下,Java不应允许用户在管道中的任何地方(除了原始来源处)进行并行操作。

这成为意见的问题。我认为Zabuza提供了支持JDK设计师选择的充分理由。


一种实现方式是编写自己的自定义拆分迭代器。还有其他办法吗?

这取决于您的操作

  • 如果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
感谢您的澄清。很高兴知道您突出显示的第三个解决方案。我将看一下,因为我还没有使用takeWhile和Supplier。
探险家

2
自定义Spliterator实现不会比这更复杂,同时允许更有效的并行处理……
Holger

1
您的每个内部parallelStream操作都有固定的开销来启动操作并等待最终结果,同时限于的并行度batchSize。首先,您需要多个当前可用的CPU核心数,以避免空闲线程。然后,该数目应该足够大以补偿固定的开销,但是数目越大,甚至在并行处理开始之前发生的顺序读取操作所施加的暂停就越高。
Holger

1
在当前实现中,将外部流并行转换将对内部流造成严重干扰,除了Stream.generate会产生无序流的那点之外,这与OP的预期用例(如)不兼容findFirst()。相比之下,带有分隔符的单个并行流trySplit可以直接返回工作块,并允许工作线程处理下一个块,而无需等待前一个块的完成。
Holger

2
没有理由假定某个findFirst()操作将仅处理少量元素。处理所有元素的90%之后,仍可能会出现第一个匹配项。此外,当具有一千万行时,即使在10%之后找到匹配项,仍然需要处理一百万行。
Holger

7

最初的Stream设计包含支持具有不同并行执行设置的后续管道阶段的想法,但该想法已被放弃。API可能是从这个时候开始的,但是另一方面,强制调用者为并行或顺序执行做出单个明确决定的API设计会复杂得多。

实际Spliterator使用的Files.lines(…)是依赖于实现的。在Java 8(Oracle或OpenJDK)中,您始终会获得与相同的东西BufferedReader.lines()。在最新的JDK中,如果Path属于默认文件系统,并且字符集是此功能支持的字符集,则将获得具有专用Spliterator实现的Stream 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的输出清楚地显示了两个示例之间的区别。注意: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();
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.