Java Stream

Tony·2023년 5월 15일
0

Java

목록 보기
1/1

Stream

Stream 생성하기

<plain stream>
myCollection.stream() ...
Arrays.stream(myArray)...

<parallel stream>
myCollection.parallelStream()...
Arrays.stream(myArray).parallel()...

Functional vs Imperative

  • 함수형 프로그래밍에서는 상태를 바꾸면 안됩니다.
  • 함수형 프로그래밍에서는 for,while과 같은 loop을 피해야합니다.
  • side effect가 있으면 안됩니다.(함수 바깥에 있는 요소에 의존하거나 변형해서는 안된다.) 예를 들어, non-local 변수나 static local 변수를 바꾸거나 reference를 통해 argument로 들어온 값을 바꾸는 경우(원래의 값이 바뀌므로)를 뜻합니다. 또한, I/O나 side effect가 있는 함수를 호출하는 것도 포함입니다.

Java는 이러한 함수형 프로그래밍의 패러다임을 강제하지 않기 때문에 자유롭게 프로그래밍할 수 있습니다. 하지만, 함수형 프로그래밍의 장점을 이용하려면 이러한 원칙을 지켜야합니다.
또한, side effect에 대해 흔히 갖는 오해는 "그러면 IO는 도대체 어떻게 하는건데?"라는 오해입니다. 함수형 프로그래밍은 어플리케이션 전체를 함수형으로 구현하는 것이 아니라, 코어 도메인 로직을 함수형 프로그래밍으로 구현하고 side effect를 다른 곳에 모음으로써 두 가지 부분을 최대한 분리하고자 하는 것입니다. 그러므로, 함수형 프로그래밍은 Uncle Bob Martin의 클린 아키텍처와, Alistair Cockburn의 헥사고날 아키텍처와 같은 문맥에 있습니다.

Operations

filter, map, reduce는 함수형 프로그래밍에서 가장 많이 쓰이는 연산입니다. 보통 filter-> map -> reduce 순으로 chaining하여 사용됩니다. stream은 중간 연산자와 최종 연산자를 가지며, 최종 연산자가 사용되었을때 stream은 소모되고 결과값이 반환됩니다.

filter(Predicate<? super T> predicate)

Predicate는 함수형 인터페이스(@FunctionalInterface)이므로, 람다 함수로 표현할 수 있습니다. Predicate가 true인 요소들만 Stream에 담아 반환합니다.

map(Function<? super T, ? extends R> mapper)

T는 input의 타입이고, R는 output의 타입을 의미합니다. Fucntion 또한 함수형 인터페이스이므로, 람다 함수로 표현할 수 있습니다.
Stream의 각 요소를 input으로 받아 output으로 매핑시킵니다.

reduce

reduce는 3가지 변이체가 있습니다. reduce의 메커니즘은 다음과 같습니다.
Stream를 "단계"로 표현하면, 현재 단계에서 계산한 결과를 다음 단계의 input으로 넘깁니다. 따라서, 다음 단계에서는 이전 단계들에서 축적된 결과를 이용할 수 있습니다. 람다 함수로 표현하자면, (이전 단계까지 축적된 값, 현재 element) -> { .... }가 실행되어 다음 축적값이 됩니다.

reduce(T identity, BinaryOperator<T> accumulator)

identity는 초기값입니다. reduce를 가장 처음 실행할 때, 현재까지의 축적값으로 identity가 들어갑니다.

reduce(BinaryOperator<T> accumulator)

초기값은 Stream의 가장 처음에 있는 null이 아닌값입니다. 모든 Stream 요소들이 null인 경우도 있을 수 있습니다. 따라서, Optional 래퍼를 씌워서 결과값을 반환합니다.

reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner)

초기값 identity(U 타입)와 Stream 요소의 타입(T 타입)이 다르고, 반환값을 U 타입으로 반환합니다. 축적값은 반환값과 같아야 하기 때문에, 축적값도 U 타입입니다.
이 함수는, parallelStream을 이용할 떄 사용합니다. parallelStream을 사용하면, stream이 나뉘어져서 병렬적으로 처리됩니다. 그러므로 나중에 combiner를 통해 다시 합쳐져야합니다. 이때, 이 함수를 사용합니다.

peek

Stream을 그대로 반환하면서, 중간에 추가적인 작업을 하기 위해 사용합니다. 중간 연산자이기 때문에, 최종 연산자를 사용하지 않으면 peek이 수행되지 않습니다.

Parallel Stream

로컬 머신의 코어의 개수만큼 parallel하게 stream을 처리할 수 있도록 합니다. ExecutorService의 구현체인 ForkJoinPool 스레드풀을 사용하여 동작됩니다. ForkJoinPool parallel stream을 사용하지 않고는 이용할 수 없습니다.

작동 방식

  1. Stream을 적당하게 나눕니다.(내부구현에 따라) 이를 FORK라고 합니다.
  2. 병렬적으로 처리합니다.
  3. 병렬적으로 처리한 결과를 모아서 결합합니다. 이를 JOIN이라고 합니다.
  4. 결과를 반환합니다.

성능

병렬적으로 처리되므로 항상 parallel stream이 빠르다고 생각할 수 있지만, 상황에 따라 다릅니다.
1. parallel stream은 스레드를 생성해야하므로 Stream의 크기가 작으면 오히려 병렬 처리가 느릴 수 있습니다.
2. HashSet, TreeSet, LinkedList와 같이 요소를 index로 random access할 수 없는 자료구조에 대한 stream은 병렬 처리가 느립니다.
3. 싱글 코어 머신의 경우 병렬적으로 처리되지 못하기 때문에 스레드 생성의 오버헤드만 지게 됩니다. 이때는, 병렬 처리를 사용하면 안됩니다.

내 생각

primitive 타입에 대해서, Stream은 for-loop에 비해 현저히 낮은 성능을 보여줍니다. 하지만, reference 타입에 대해서는 차이가 크지 않습니다. 이러한 이유는, stream이 내부적으로 spatial locality를 활용할 수 없는 구조로 작동하고 있기 때문이지 않을까라고 생각하고 있습니다.
이렇게 생각하는 이유는 다음과 같습니다. reference 타입의 경우 힙에 분산되어 저장되기 때문에 spatial locality를 활용할 수 없습니다.(reference의 주소를 가진 array는 연속적으로 힙에 저장되지만, 실제 객체는 힙에 분산적으로 저장되어 있습니다.) 실제로, for-loop와 stream의 성능 차는 크지 않습니다. 반면에, primitive 타입의 경우 array가 통채로 연속적으로 힙에 저장되기 때문에 spatial locality를 활용할 수 있습니다.

0개의 댓글