Search
Duplicate
📒

[Java Study] 10-x. Java Stream API는 왜 for-loop보다 느릴까?

상태
미진행
수업
Java Study
주제
4 more properties

핵심

NOTE
왜 Stream API가 대규모 트래픽을 감당할때 좋지 않은가?
Effective Java의 공저자인 Angelika Lanker가 2015년에 발표했던 내용으로 설명한다.

스트림이 무엇인가?

NOTE
함수형 프로그래밍 언어에서 이야기하는 sequence와 동일한 용어 sequencetask의 순서를 나열한 것이다.
ex) 군부대에서 식기 당번을 맡았다 가정해보자
# 대대 식기 당번이 해야 할 일 1. 밥차에서 밥을 받아온다. deliverMeal(); 2. 사람들이 다 먹은 식기를 설거지한다. washDishes(); 3. 사람들이 먹다 남긴 음식물 쓰레기를 짬통에 버린다. throwTrash();
Plain Text
복사
만약 1번을 하지않고 2번을하면 사람들지 먹지도 않은 그릇을 설거지하는
말도안되는 상황이 펼쳐진다
즉 정해진 일을 순서대로 처리하는것은 매우 중요한 일이다.
(이러한 순서 Sequence대로 일을 처리하라고 함수를 파라미터로 넘기는 행위를 우리는 Sequential Programming이라 부름)
객체지향 프로그래밍 관점에서는 Internal Itrerator Pattern, 즉 내부 반복자 패턴이라고 명명할 수 있다 GOF 디자인 패턴 책에서 등장하는 개념인데 ( 컬렉션 내부에서 요소들을 반복시키고 개발자는 요소당 처리해야할 코드만 제공하는 코드 패턴이라 한다) Coleection을 순환하는 것을 외부가 아닌 내부에서 순회함 외부 Client입장에서 Iterator을 관리할 것인가, 아니면 Iterator가 스스로 Iteration하는 로직을 관리할것인가의 차이
// 식기당번을 리스트로 갖고 있는 오브젝트 List<OnMealDuty> onMealDuties = new ArrayList<>(); // List Collection을 기반으로 Stream을 생성 // 일병새끼들이 음식배달하고 설거지하고 음식물쓰레기 다버려야함 ㅇㅇ onMealDuties.stream().filter(duty -> duty.grade == "일병") .map(duty -> { duty.deliverMeal(); duty.washDishes(); duty.throwTrash(); };
Java
복사
terminnal execution이 실행되면 그제서야 intermediate execution이 일어남
스트림을 반환한다는 것은 연산의 파이프라인(pipeline)을 반환한다는 의미이다.
스트림은 lazay한데, 매번 중간 연산마다 조건을 실행하지 않는다 대신, 중간연산마다 연산의 파이프라인을 리턴한다.
최종 연산 과정에 들어와서야 이전 중간 연산들을 모두 합친 후에야 이를 이용해서 최종연산에 돌입한다.
Stream자료구조를 어떻게 ‘다룰지’를 논한다 → 이미 존재하는 자료구조 내에서 새로운 스트림을 생성할 뿐이지, 기존 데이터를 수정하거나 바꾸지는 않음

Math 1 ( for-loop vs 순차 스트림 )

NOTE
총 500000개의 배열에서 가장 큰 원소를 찾는 함수
// for-loop int[] a = ints; int e = ints.length; int m = Integer.MIN_VALUE; for (int i = 0; i < e; i++) { if (a[i] > m) { m = a[i]; } } // sequential stream int m = Arrays.stream(ints).reduce(Integer.MIN_VALUE, Math::max);
Java
복사
??? for-loop의 압도적인 승리이다 (무려 15배차이임)
for-loop : 0.36ms
seq - stream : 5.35ms
for-loop는 40년간의 짬밥으로 최적화가 잘되어있지만, 스트림은 2015년 이후에 도입되어서 최적화가 부족함
그렇다면 스트림은 쓰레기인가? 다행히 그렇지는 않다 다음 예시에서는 primitive type이 아니라 wrapped type으로 확인한다 ArrayList를 하나 만들고, 500000개의 Integer 타입을 저장하도록 만든다. 이후 Integer의 가장 큰 원소를 리턴하도록 한다.
차이가 눈에 뛰게 줄어들었다 (1.27배 차이)
for-loop : 6.55ms
seq - stream : 8.33ms
ArrayList 순회 비용자체가 크기 때문에 둘의 성능차이를 압도하기 때문
Wrapped TypePrimitive type가 달리 stack이 아닌 heap에 저장됨
heap의 간접주소를 가져와 참조하므로 iteration cost자체가 높고 결국 for-loop의 컴파일러 최적화 이점이 사라진다.
지금까지 우리는 순회비용이 계산비용보다 높은 경우였다 그러면 원소를 단순히 순회하는 비용보다 원소 하나하나에 대한 계산 비용이 높으면 어떻게 될까? slowSin()이라는 대충 엄청난게 계산하는데 오래걸리는 함수를 사용해서 순회해보자.
// for-loop int[] a = ints; int e = a.length; double m = Double.MIN_VALUE; for (int i = 0; i < e; i++) { double d = Sine.slowSin(a[i]); if (d > m) m = d; } // sequential stream Arrays.stream(ints).mapToDouble(Sine::slowSin).reduce(Double.MIN_VALUE, Math::max);
Java
복사
이제는 더이상 for-loop이 빠르지 않다!
slowSin() 함수 계산 비용이 순회 비용을 압도하기 떄문이다. 이를 통해 우리는 함수 내부의 시간복잡도가 충분히 크다면, stream을 활용하는것에 속도 손실이 없다는걸 알 수 있게됬다.

Math 2 ( 순차 스트림 vs 병렬 스트림 )

NOTE
우리는 보통 병렬 스트림이 순차보다 빠르다고 생각한다 ( 쓰레드를 사용하기 때문 ) 일단 병렬 스트림은 그 자체로 순차적일 수 밖에없는 for-loop보다는 확실히 빠르다
순차 스트림과 병렬 스트림의 차이
순차 스트림 : 싱글 쓰레드를 사용, 공유 자원이슈 고민 x
병렬 스트림 : 멀티 쓰레드를 사용, 공유 자원이슈 고민 o