이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › Java › 람다와 스트림
병렬 스트림과 공용 풀
남의 병렬 스트림 때문에 내 코드가 느려진다
parallelStream()은 스레드 풀을 따로 만들지 않고, JVM 전체가 함께 쓰는 공용 풀 하나에서 실행됩니다. 누군가 그 안에서 오래 기다리는 작업을 돌리면 공용 풀의 스레드가 모두 묶여, 전혀 상관없는 다른 병렬 스트림까지 느려집니다.
실행으로 대조 · JDK 21에서 실제로 잰 값
이 실습에 나오는 개념
- ForkJoinPool과 공용 풀
- 큰 작업을 작은 작업으로 나눠 여러 스레드에서 처리하고 결과를 합치는 데 쓰는 스레드 풀이다. JVM에는 모두가 함께 쓰는 공용 풀(commonPool)이 있으며 병렬 스트림은 기본적으로 이 풀에서 실행된다. 여기서 오래 걸리는 작업을 실행하면 같은 풀을 쓰는 다른 작업까지 느려진다.
- Stream API
- 컬렉션 등의 데이터에 대해 필터링, 변환, 집계를 선언적으로 표현하는 API다. filter, map 같은 중간 연산을 이어 붙이고 collect, forEach 같은 최종 연산으로 마무리한다. 중간 연산은 최종 연산이 호출될 때까지 실행되지 않으며(지연 평가), 스트림은 원본 데이터를 변경하지 않고 한 번만 사용할 수 있다.
- 스레드 풀
- 스레드를 필요할 때마다 만들고 없애는 대신 일정 수의 스레드를 유지하며 재사용하는 구조다. 스레드 생성 비용을 줄이고, 동시에 실행되는 스레드 수를 제한해 과도한 메모리 사용과 컨텍스트 스위칭을 막는다. 처리할 작업은 큐에 쌓이고 빈 스레드가 가져가 실행한다. 톰캣의 요청 처리, DB 커넥션 풀도 같은 발상이다.
- 블로킹과 논블로킹
- 블로킹은 호출한 작업이 끝날 때까지 호출한 스레드가 멈춰서 다른 일을 하지 못하는 것이다. 논블로킹은 작업이 끝나지 않았어도 호출이 바로 반환되어 스레드가 다른 일을 할 수 있는 것이다. 일반적인 JDBC 호출은 DB 응답이 올 때까지 스레드가 멈추는 블로킹 호출이다.
이렇게 바꿔 보세요
- 처음 설정으로 — A가 공용 풀을 붙잡습니다. B가 4배 느려집니다.
- B만 실행할 때 — 공용 풀을 온전히 써서 빠릅니다.
- A를 전용 풀로 옮기면 — B가 다시 빨라집니다.
처음 설정으로 실행하면
- 공용 풀(ForkJoinPool.commonPool)에는 일꾼 스레드가 3개 있습니다. 기본 크기는 "CPU 코어 수 - 1"이고, 병렬 스트림을 호출한 스레드 자신도 일을 거듭니다. JVM 안의 모든 병렬 스트림이 이 풀 하나를 함께 씁니다.
- 요청 A가 병렬 스트림 안에서 외부 API를 호출합니다. 공용 풀의 일꾼 3개가 모두 A의 작업을 하나씩 집어 들고, 응답을 기다리며 2초씩 멈춰 있습니다. 계산을 하는 것이 아니라 그냥 기다리는 중인데도 스레드는 묶여 있습니다.
- 요청 B가 0.1초짜리 작업 8개를 parallelStream()으로 실행합니다. 작업을 쪼개 공용 풀에 맡깁니다.
- 공용 풀의 일꾼이 전부 A에 묶여 있어서 B의 작업을 가져갈 스레드가 없습니다. B를 호출한 스레드 혼자서 8개를 하나씩 처리합니다. 병렬 스트림인데 사실상 순차 실행입니다.
- B가 끝났습니다. 소요 시간 862ms. B만 실행할 때(217ms)의 약 4배입니다. B의 코드는 한 줄도 바뀌지 않았습니다.
실행 결과
- 공용 풀은 JVM 전체가 공유합니다. 한 곳에서 오래 기다리는 작업을 병렬 스트림에 넣으면, 서로 모르는 다른 코드의 병렬 스트림과 CompletableFuture(풀을 지정하지 않은 경우)까지 함께 느려집니다. 원인이 되는 코드와 느려지는 코드가 달라서 찾기도 어렵습니다.
- 병렬 스트림은 CPU를 쓰는 계산을 큰 데이터에 적용할 때만 이득입니다. 공용 풀의 크기가 코어 수에 맞춰져 있는 것도 그 용도이기 때문입니다. 원소가 적거나, 작업이 가볍거나, 기다림이 섞여 있으면 순차 스트림보다 느려질 수 있습니다. 웹 서버의 요청 처리 코드에서는 대부분 쓸 이유가 없습니다.
코드
// 요청 A: 외부 API를 병렬 스트림 안에서 호출 (건당 2초)
orders.parallelStream()
.forEach(o -> deliveryApi.track(o)); // 공용 풀의 스레드가 2초씩 묶인다
// 요청 B: 0.1초짜리 작업 8개
items.parallelStream().forEach(this::resize);
이 모형의 전제
B의 소요 시간 세 가지(217ms, 862ms, 218ms)는 이 PC의 JDK 21에서 공용 풀의 일꾼 수를 3개로 맞추고 실제로 실행해 잰 값입니다. 0.1초짜리 작업과 2초짜리 호출은 Thread.sleep으로 대신했습니다. 공용 풀의 기본 크기가 "코어 수 - 1"이라는 점은 이 PC의 JDK 21에서 확인했습니다(프로세서 12개에서 공용 풀의 병렬 수준이 11). 병렬 스트림이 공용 풀을 쓴다는 점은 위의 측정에서 드러납니다(남의 병렬 스트림이 공용 풀을 차지하자 B가 느려졌습니다).
앞 실습: 스트림으로 모으기 · 다음 실습: 프로세스와 스레드