자바 병렬 스트림-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 스트림 설명서 와 몇 가지 웹 사이트를 읽고 스트림에서 스트림이 작동하는 방식을 이해합니다.

몇 가지 질문 :

  • Java 병렬 스트림은 ArrayList, LinkedList 등의 모든 컬렉션에 의해 구현되는 SplitIterator를 기반으로 작동 합니다. 컬렉션에서 병렬 스트림을 구성 할 때 해당 분할 반복자는 컬렉션을 분할하고 반복하는 데 사용됩니다. 이것은 맵 결과 (예 : 레코드 포조)가 아닌 원래 입력 소스 (파일 라인) 레벨에서 병렬 처리가 발생한 이유를 설명합니다. 내 이해가 정확합니까?

  • 필자의 경우 입력은 파일 IO 스트림입니다. 어떤 분할 반복기가 사용됩니까?

  • parallel()파이프 라인에서 어디에 배치하든 문제가되지 않습니다 . 원래 입력 소스는 항상 분리되고 나머지 중간 작업이 적용됩니다.

    이 경우 Java는 사용자가 원본 소스를 제외하고 파이프 라인의 어느 곳에 나 병렬 작업을 배치 할 수 없도록해야합니다. Java 스트림이 내부에서 어떻게 작동하는지 모르는 사람들에게는 잘못 이해하고 있기 때문입니다. parallel()Stream 객체 유형에 대해 작업이 정의되었을 것이므로 이러한 방식으로 작동합니다. 그러나 대체 솔루션을 제공하는 것이 좋습니다.

  • 위의 코드 스 니펫에서 입력 파일의 모든 레코드에 줄 번호를 추가하려고하므로 순서를 지정해야합니다. 그러나 doSomeOperation()무거운 논리이기 때문에 병렬로 적용하고 싶습니다 . 달성하는 한 가지 방법은 나만의 맞춤형 분할 반복자를 작성하는 것입니다. 다른 방법이 있습니까?


2
Java 제작자가 인터페이스를 디자인하기로 결정한 방법과 더 관련이 있습니다. 파이프 라인에 요청하면 최종 작업이 아닌 모든 항목이 먼저 수집됩니다. parallel()기본 스트림 객체에 적용되는 일반적인 수정 자 요청에 지나지 않습니다. 파이프에 최종 작업을 적용하지 않는 경우, 즉 "실행 된"항목이없는 한 소스 스트림이 하나만 있어야합니다. 말했듯이, 당신은 기본적으로 Java 디자인 선택에 의문을 품고 있습니다. 그것은 의견에 근거한 것이며 우리는 실제로 그것을 도울 수 없습니다.
Zabuzard

1
나는 당신의 요점과 혼란을 완전히 얻었지만 훨씬 더 나은 해결책이 있다고 생각하지 않습니다. 이 방법은 Stream인터페이스에서 직접 제공되며 멋진 계단식으로 인해 모든 작업이 다시 제공 Stream됩니다. 누군가가 당신에게주고 Stream싶지만 이미 이와 비슷한 몇 가지 작업을 적용 했다고 상상해보십시오 map. 사용자는 여전히 병렬로 실행할지 여부를 결정할 수 있습니다. 따라서 parallel()스트림이 이미 존재하더라도 여전히 전화를 걸 수 있어야 합니다.
Zabuzard

1
또한 스트림의 일부를 순차적으로 실행하고 나중에 병렬로 전환하려는 이유를 묻습니다. 스트림이 병렬 실행에 적합 할만큼 충분히 큰 경우 파이프 라인의 모든 항목에도 적용됩니다. 그렇다면 해당 부분에 대해서도 병렬 실행을 사용하지 않는 이유는 무엇입니까? flatMap스레드 안전하지 않은 메소드를 사용하거나 이와 유사한 방식으로 크기를 크게 늘리 거나 같은 경우가 있습니다 .
Zabuzard

1
@ 자 부자 나는 자바 디자인 선택에 의문을 제기하지 않지만 단지 내 관심사를 제기하고있다. 기본 Java 스트림 사용자는 스트림 작동을 이해하지 않으면 동일한 혼란을 겪을 수 있습니다. 그래도 두 번째 의견에 전적으로 동의합니다. 방금 언급 한 것처럼 자체 단점을 가질 수있는 가능한 솔루션을 강조했습니다. 그러나 다른 방법으로 해결할 수 있는지 확인할 수 있습니다. 세 번째 의견에 대해서는 이미 설명의 마지막 시점에서 사용 사례를 언급했습니다.
Explorer

1
@Eugene Path이 로컬 파일 시스템에 있고 최근 JDK를 사용하는 경우 스플리터는 1024의 배수를 일괄 처리하는 것보다 더 나은 병렬 처리 기능을 갖습니다. 그러나 일부 findFirst시나리오 에서는 균형 잡힌 분할이 반 생산적 일 수도 있습니다 .
Holger

답변:


8

이것은 맵 결과 (예 : 레코드 포조)가 아닌 원래 입력 소스 (파일 라인) 레벨에서 병렬 처리가 발생한 이유를 설명합니다.

전체 스트림은 병렬 또는 순차적입니다. 순차적으로 또는 병렬로 실행할 작업의 하위 집합을 선택하지 않습니다.

터미널 작업이 시작되면 스트림 파이프 라인은 호출 된 스트림의 방향에 따라 순차적으로 또는 병렬로 실행됩니다. [...] 터미널 작업이 시작되면 스트림 파이프 라인은 호출 된 스트림의 모드에 따라 순차적으로 또는 병렬로 실행됩니다. 동일한 소스

언급했듯이 병렬 스트림은 분할 반복자를 사용합니다. 분명히 이것은 작업이 시작되기 전에 데이터를 분할하는 것입니다.


필자의 경우 입력은 파일 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 %를 처리 한 후에도 계속 발생할 수 있습니다. 또한 1000 만 줄을 가질 때 10 % 후에도 일치하는 항목을 찾으려면 여전히 100 만 줄을 처리해야합니다.
Holger

7

원래 스트림 디자인에는 다른 병렬 실행 설정으로 후속 파이프 라인 단계를 지원한다는 아이디어가 포함되었지만이 아이디어는 포기되었습니다. API는이 시점에서 비롯 될 수 있지만, 반면에 호출자가 병렬 또는 순차적 실행에 대해 명백한 단일 결정을 내 리도록하는 API 설계는 훨씬 더 복잡합니다.

실제 Spliterator사용 Files.lines(…)은 구현에 따라 다릅니다. Java 8 (Oracle 또는 OpenJDK)에서는 항상와 동일합니다 BufferedReader.lines(). 최신 JDK Path에서 기본 파일 시스템에 속하고 문자 세트가이 기능에서 지원되는 것 중 하나 인 경우 전용 Spliterator구현을 가진 Stream을 얻 습니다 java.nio.file.FileChannelLinesSpliterator. 전제 조건이 충족되지 않으면 with와 동일하게 구현 되며 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

다음은 병렬 적용이 적용되는 경우의 간단한 데모입니다. 엿봄의 결과는 두 예제의 차이점을 분명히 보여줍니다. 참고 : 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.