자바 스트림·람다·Optional: 지연 평가와 병렬 함정 실측
peek로 보는 지연 평가, toMap 중복 키 예외, for·stream·parallelStream 워밍업 후 실측, Optional 오용, 람다 속 검사 예외, Java 24 gatherer까지 JDK 26에서 실행.
시리즈: 자바 강좌 4/6 (중급). 람다는 읽을 수 있지만 스트림이 실제로 언제 실행되는지, 컬렉터가 왜 터지는지, parallelStream()이 도움이 될지 확신이 없는 분을 위한 글입니다. 다 읽으면 파이프라인의 실행 순서를 예측하고, 초보 때 만나는 스트림 예외 네 가지를 고치고, 루프·스트림·병렬 스트림을 실측을 근거로 고를 수 있습니다.
Spring 프로젝트 코드 리뷰에서 스트림 관련 코멘트는 거의 정해져 있습니다. "이거 두 번 돌아요", "키 중복이면 터져요", "여기 왜 parallel이에요", "Optional에 get() 부르지 마세요". 코딩테스트에서는 "스트림 쓰면 시간 초과 난다"는 말도 자주 듣습니다. 이 글은 그 하나하나를 직접 실행한 코드와 실제 출력으로 확인합니다. Javadoc 요약이 아니라, 실행 결과가 말해 주는 것을 보여 드립니다.
실행 환경: OpenJDK 26(Temurin 26+35), Apple Silicon Mac(M1 Pro, 8코어, 공용 fork-join 풀 병렬도 7). 짧은 예제는 java 파일.java, 벤치마크는 javac 후 java -Xms2g -Xmx2g로 실행했고 난수는 new Random(7)로 고정했습니다.
람다, 메서드 참조, 함수형 인터페이스
람다는 함수형 인터페이스(추상 메서드가 정확히 하나인 인터페이스)의 인스턴스입니다. 자주 쓰는 모양은 java.util.function에 다 있고, 메서드 참조는 메서드 하나만 부르는 람다의 줄임말입니다.
Function<String, Integer> len = String::length; // T -> R
Predicate<String> empty = String::isEmpty; // T -> boolean
Supplier<List<String>> mk = ArrayList::new; // () -> T
BiFunction<String, String, Boolean> eq = String::equalsIgnoreCase;
UnaryOperator<String> up = s -> s.toUpperCase(); // T -> T
System.out.println(len.apply("tokyo") + " " + empty.negate().test("") + " "
+ mk.get() + " " + eq.apply("Java", "JAVA") + " " + up.andThen(s -> s + "!").apply("hi"));
5 false [] true HI!
String::length는 받는 객체 자신이 인자이고, String::equalsIgnoreCase는 인자 두 개 중 첫 번째가 받는 객체이며, ArrayList::new는 생성자입니다. negate(), andThen() 같은 디폴트 메서드로 새 람다 없이 조합할 수 있습니다.
람다에서 처음 만나는 컴파일 에러는 지역 변수 문제입니다.
List<Integer> prices = List.of(980, 3200, 600);
int total = 0;
prices.forEach(p -> total += p);
Eff.java:7: error: local variables referenced from a lambda expression must be final or effectively final
prices.forEach(p -> total += p);
^
1 error
람다는 대입 후 한 번도 바뀌지 않는(사실상 final) 지역 변수만 읽을 수 있습니다. 해결책은 길이 1짜리 배열이나 AtomicInteger 꼼수가 아니라, 값을 스트림이 계산하게 하는 것입니다. int total = prices.stream().mapToInt(Integer::intValue).sum();(출력 4780). C#에서 오신 분이라면 변수를 참조로 캡처하는 C# 클로저보다 엄격하다는 점이 다릅니다. C# 람다 글과 비교해 보세요.
peek로 눈에 보이게 만든 지연 평가
스트림 파이프라인은 최종 연산(findFirst, collect, count, forEach…)이 결과를 요구할 때까지 아무것도 하지 않습니다. peek로 그 과정을 볼 수 있습니다.
Stream<String> s = Stream.of("fig", "kiwi", "apple", "banana")
.peek(x -> System.out.println(" peek " + x))
.filter(x -> x.length() > 3)
.map(x -> { System.out.println(" map " + x); return x.toUpperCase(); });
System.out.println("pipeline built, nothing printed yet");
Optional<String> first = s.findFirst();
System.out.println("findFirst -> " + first.get());
System.out.println("-- sorted() is a barrier:");
Stream.of("kiwi", "apple", "fig")
.peek(x -> System.out.println(" before sort " + x))
.sorted()
.peek(x -> System.out.println(" after sort " + x))
.findFirst();
pipeline built, nothing printed yet
peek fig
peek kiwi
map kiwi
findFirst -> KIWI
-- sorted() is a barrier:
before sort kiwi
before sort apple
before sort fig
after sort apple
출력에서 세 가지를 읽을 수 있습니다. 파이프라인을 만드는 동안에는 아무것도 출력되지 않았습니다. 요소는 하나씩 위에서 아래로 지나갔습니다. "전부 peek한 다음 전부 filter"가 아니라 fig가 peek되고 떨어진 뒤, kiwi가 끝까지 갔습니다. 그리고 findFirst가 중간에 멈춰서 apple과 banana는 읽히지도 않았습니다. 두 번째 파이프라인은 예외를 보여 줍니다. sorted()는 모든 요소를 봐야 첫 요소를 내보낼 수 있으므로 장벽입니다.
실무에서의 결론은 이것입니다. 꼭 실행돼야 하는 부수 효과를 peek나 map 안에 넣지 마세요. 실행 여부와 횟수는 최종 연산에 달려 있습니다. count()는 소스에서 크기를 알 수 있으면 파이프라인을 건너뛰어도 되고, JDK 26은 실제로 건너뜁니다.
List<String> l = List.of("a", "b", "c");
long n = l.stream().peek(x -> System.out.println("peek " + x)).count();
System.out.println("count=" + n);
long m = l.stream().peek(x -> System.out.println("peek " + x)).filter(x -> !x.isEmpty()).count();
System.out.println("count after filter=" + m);
count=3
peek a
peek b
peek c
count after filter=3
첫 번째 peek는 한 번도 실행되지 않았습니다. filter를 넣으면 크기를 미리 알 수 없으니 두 번째 파이프라인은 실행됐습니다. "로그 찍으려고 넣은 peek가 왜 안 찍히죠?"라는 질문의 답입니다.
컬렉터: groupingBy, counting, joining, toMap
record Order(String city, String item, int yen) {}
List<Order> orders = List.of(
new Order("Osaka", "ramen", 980), new Order("Tokyo", "sushi", 3200),
new Order("Osaka", "takoyaki", 600), new Order("Fukuoka", "ramen", 850),
new Order("Tokyo", "ramen", 1100));
System.out.println(orders.stream()
.collect(Collectors.groupingBy(Order::city, Collectors.counting())));
System.out.println(orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.counting())));
System.out.println(orders.stream()
.collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.summingInt(Order::yen))));
System.out.println(orders.stream().map(Order::item).distinct().sorted()
.collect(Collectors.joining(", ", "[", "]")));
System.out.println("max price per item " + orders.stream()
.collect(Collectors.toMap(Order::item, Order::yen, Integer::max, TreeMap::new)));
{Tokyo=2, Fukuoka=1, Osaka=2}
{Fukuoka=1, Osaka=2, Tokyo=2}
{Fukuoka=850, Osaka=1580, Tokyo=4300}
[ramen, sushi, takoyaki]
max price per item {ramen=1100, sushi=3200, takoyaki=600}
첫 줄을 보세요. groupingBy는 HashMap을 돌려주므로 도시 순서가 정해져 있지 않습니다. 보고서나 문자열을 비교하는 테스트처럼 순서가 중요하면 두 번째 줄처럼 맵 팩터리로 TreeMap::new를 넘기세요.
흔한 실수: 중복 키가 있는 toMap
Map<String, Integer> priceByItem = orders.stream()
.collect(Collectors.toMap(Order::item, Order::yen)); // "ramen"이 세 번 나온다
실제로 일어나는 일:
Exception in thread "main" java.lang.IllegalStateException: Duplicate key ramen (attempted merging values 980 and 850)
at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:137)
at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$0(Collectors.java:184)
at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
at java.base/java.util.stream.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
…
at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:723)
at Collect.main(Collect.java:34)
인자 두 개짜리 toMap은 키가 유일하다고 가정합니다. 테스트 데이터에는 라멘이 하나뿐이어서 통과하고, 운영에서 터지는 전형적인 버그입니다. 다행히 메시지에 키와 두 값이 다 나옵니다. 해결: 중복이 무슨 뜻인지 정하세요. 최댓값이나 마지막 값을 남기려면 병합 함수(Integer::max, (a, b) -> b)를, 전부 남기려면 groupingBy를 씁니다.
흔한 실수: 스트림을 두 번 쓰기
Stream은 컬렉션이 아니라 한 번 쓰고 버리는 파이프라인입니다.
Stream<String> names = Stream.of("ana", "bo", "chie");
System.out.println(names.count());
System.out.println(names.filter(n -> n.length() > 2).count());
3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
at java.base/java.util.stream.AbstractPipeline.<init>(AbstractPipeline.java:201)
…
at java.base/java.util.stream.ReferencePipeline.filter(ReferencePipeline.java:184)
at Twice.main(Twice.java:7)
이미 소비된 스트림에 처음 호출한 filter에서 실패합니다. 흔한 원인은 Stream<T>를 반환하는 메서드와, 그 결과를 두 번 쓰는 호출부입니다. 해결: 소스(List<String> names)를 들고 있다가 매번 names.stream()을 부르거나, 소스를 만드는 비용이 크면 Supplier<Stream<String>>을 넘깁니다. 두 방법 모두 3과 2를 출력했습니다.
for 루프 vs stream vs parallelStream 실측
3강과 같은 측정 함수를 썼습니다. 시간을 재지 않는 워밍업 후 11번 잰 중앙값이고, 결과를 필드에 더해서 JIT이 계산을 버리지 못하게 했습니다. 정석은 JMH지만 이 시리즈는 아무것도 설치하지 않으므로 쓰지 않았습니다. 작업은 "짝수의 제곱합"입니다.
cores=8 commonPool parallelism=7
== A: sum of squares of even values, int[10,000,000]
for loop median 27.1625 ms (min 26.6018, max 27.8053)
IntStream median 33.0102 ms (min 32.3225, max 34.3361)
IntStream.parallel() median 8.9625 ms (min 7.4494, max 10.0775)
== B: same work, List<Integer> of 1,000 elements, 1,000 calls per sample
for loop median 0.6654 ms (min 0.6511, max 0.6747)
stream() median 0.7942 ms (min 0.7686, max 2.4748)
parallelStream() median 6.5678 ms (min 4.3373, max 22.6658)
== C: LinkedList<Integer> of 1,000,000
for loop median 4.5086 ms (min 4.4550, max 4.7740)
stream() median 7.5466 ms (min 7.5023, max 7.8438)
parallelStream() median 4.6535 ms (min 3.8963, max 7.4222)
== D: Stream.iterate(...).limit(2,000,000)
for loop median 0.6605 ms (min 0.6594, max 0.8373)
iterate+limit median 16.1651 ms (min 13.1412, max 18.0171)
iterate+limit parallel median 24.7791 ms (min 14.7319, max 35.6399)
LongStream.range parallel median 0.7683 ms (min 0.3322, max 2.5199)
A, 큰 원시 배열: 병렬 스트림이 루프보다 3배 빨랐습니다. 배열은 공짜로 반씩 쪼갤 수 있고, 박싱이 없고, 1,000만 개면 워커 스레드 7개를 부릴 만한 작업량입니다. 순차 IntStream은 루프보다 20%쯤 느렸습니다. "스트림 쓰면 시간 초과"는 대부분 이 20%가 아니라 박싱이나 잘못된 자료 구조 때문입니다.
B, 병렬이 더 느린 경우: 요소 1,000개짜리 리스트에서는 parallelStream()이 루프보다 약 10배 느렸습니다. 호출할 때마다 리스트를 쪼개고, fork-join 풀에 나눠 주고, 기다리고, 합치는데, 1,000개에서는 그 준비 비용이 작업보다 큽니다. 웹 요청을 처리하는 코드 대부분이 이런 모양입니다. 거대한 컬렉션 하나가 아니라 작은 컬렉션이 아주 많습니다.
C와 D, 예상 밖의 결과: LinkedList는 병렬화가 안 된다고들 하지만 여기서는 병렬 스트림이 루프와 비슷했고 지지는 않았습니다. 진짜 함정은 Stream.iterate(...).limit(n)입니다. 각 요소가 앞 요소에 의존하고 limit이 순서를 지켜야 해서, 병렬 버전이 순차 버전보다 느렸고(24.8ms vs 16.2ms) 둘 다 단순 루프보다 20배 넘게 느렸습니다. 같은 숫자를 쪼갤 수 있는 형태로 표현하는 LongStream.range는 빨랐습니다. 세 번 실행하는 동안 병렬 쪽 숫자가 가장 많이 흔들렸지만(A 5.3~9.0ms, B 6.6~17.9ms, D 15.9~24.8ms) 결론은 바뀌지 않았습니다.
Optional: 좋은 사용과 나쁜 사용
Optional은 "결과가 없을 수 있다"를 나타내는 반환 타입입니다. null을 대신하는 만능 도구가 아닙니다.
record User(String id, String email) {}
static final Map<String, User> DB = Map.of("u1", new User("u1", "a@example.com"), "u2", new User("u2", null));
static Optional<User> find(String id) { return Optional.ofNullable(DB.get(id)); }
static String slowDefault() { System.out.println(" (slowDefault called)"); return "none"; }
System.out.println(find("u1").map(User::email).orElse("none"));
System.out.println(find("u2").map(User::email).orElse("none")); // map이 null을 빈 값으로 바꾼다
System.out.println(find("u9").map(User::email).orElse("none"));
System.out.println("orElse:");
System.out.println(find("u1").map(User::email).orElse(slowDefault()));
System.out.println("orElseGet:");
System.out.println(find("u1").map(User::email).orElseGet(Opt::slowDefault));
a@example.com
none
none
orElse:
(slowDefault called)
a@example.com
orElseGet:
a@example.com
좋은 사용법은 조회 메서드가 Optional을 돌려주고, map으로 이어 가다가, 마지막에 한 번 결정하는 것입니다. map은 매퍼가 돌려준 null도 빈 값으로 흡수합니다(u2는 이메일이 없음). 함정은 orElse(x)입니다. x는 평범한 인자라서 값이 있어도 먼저 계산됩니다. 기본값이 DB 호출이거나 객체를 만든다면 orElseGet을 쓰세요.
나쁜 사용과 그 결과입니다.
try { find("u9").get().email(); }
catch (NoSuchElementException e) { System.out.println("get() on empty -> " + e.getMessage()); }
try { Optional.of(DB.get("u9")); }
catch (NullPointerException e) { System.out.println("Optional.of(null) -> NPE"); }
Optional<User> maybe = null;
try { maybe.isPresent(); }
catch (NullPointerException e) { System.out.println("null Optional -> " + e.getMessage()); }
get() on empty -> No value present
Optional.of(null) -> NPE
null Optional -> Cannot invoke "java.util.Optional.isPresent()" because "maybe" is null
검사 없는 get()은 이름만 다른 NullPointerException입니다. 값이 확실히 있다면 orElseThrow()로 의도를 드러내세요. Optional.of는 null일 수 없는 값용이고, 아니면 ofNullable을 씁니다. 마지막 줄은 Optional 변수 자체가 null인 경우로, 쓰는 의미가 없어집니다. Optional을 필드나 메서드 매개변수로 쓰는 것도 피하고(Serializable이 아니고 할당이 하나 늘어남), 컬렉션을 감싸지 말고 빈 리스트를 돌려주세요. 덧붙여, 친절한 NPE 메시지에 변수 이름 "maybe"가 나오는 것은 -g로 컴파일했을 때뿐이고, 소스 실행(java Opt.java)에서는 "<local1>"로 나왔습니다.
람다 안의 검사 예외
Function.apply에는 throws가 없어서, 검사 예외를 던지는 메서드를 부르는 람다는 컴파일되지 않습니다.
List<String> texts = files.stream()
.map(p -> Files.readString(p))
.toList();
Checked.java:8: error: unreported exception IOException; must be caught or declared to be thrown
.map(p -> Files.readString(p))
^
1 error
표준적인 해결책은 작은 도우미 메서드 안에서 검사 예외를 비검사 예외로 감싸고, 처리할 수 있는 곳에서 꺼내는 것입니다.
static String read(Path p) {
try { return Files.readString(p); }
catch (IOException e) { throw new UncheckedIOException(e); }
}
…
try {
List<String> texts = List.of(Path.of("a.txt"), Path.of("missing.txt")).stream()
.map(Checked2::read)
.toList();
} catch (UncheckedIOException e) {
System.out.println("caught: " + e.getCause());
}
caught: java.nio.file.NoSuchFileException: missing.txt
UncheckedIOException은 바로 이런 경우를 위해 있습니다. 다만 결과를 냉정하게 보세요. 첫 실패에서 파이프라인 전체가 멈춥니다. "읽을 수 있는 건 읽고 나머지는 보고"가 필요하면 파일마다 try/catch를 둔 평범한 for 루프가 어떤 스트림 기교보다 명확합니다.
Stream.toList() vs collect(Collectors.toList())
List<Integer> a = Stream.of(3, 1, 2).collect(Collectors.toList());
a.add(4);
System.out.println("collect(toList()) " + a + " " + a.getClass().getName());
List<Integer> u = Stream.of(3, 1, 2).collect(Collectors.toUnmodifiableList());
System.out.println("toUnmodifiableList " + u.getClass().getName());
System.out.println("toList with null " + Stream.of(1, null, 3).toList());
try { Stream.of(1, null, 3).collect(Collectors.toUnmodifiableList()); }
catch (NullPointerException e) { System.out.println("toUnmodifiableList with null -> " + e); }
List<Integer> b = Stream.of(3, 1, 2).toList();
System.out.println("Stream.toList() " + b + " " + b.getClass().getName());
b.add(4);
collect(toList()) [3, 1, 2, 4] java.util.ArrayList
toUnmodifiableList java.util.ImmutableCollections$ListN
toList with null [1, null, 3]
toUnmodifiableList with null -> java.lang.NullPointerException
Stream.toList() [3, 1, 2] java.util.ImmutableCollections$ListN
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:145)
at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:150)
at ToList.main(ToList.java:17)
IDE가 .collect(Collectors.toList())를 더 짧은 .toList()로 바꾸라고 자주 제안하는데, 이건 동작이 바뀌는 변경입니다. 나중에 결과에 add하는 코드가 있으면 예외가 납니다. Stream.toList()는 수정 불가지만 null을 허용하고, toUnmodifiableList()는 수정 불가에 null도 거부합니다. Collectors.toList()는 JDK 26에서 ArrayList였지만 Javadoc은 수정 가능 여부를 약속하지 않으므로, 수정할 리스트라면 Collectors.toCollection(ArrayList::new)라고 쓰세요.
Gatherer(Java 24): 26에서 확인
Java 24 전에는 창(window), 누적합, "직전보다 오른 값만" 같은 사용자 정의 중간 연산을 하려면 스트림 밖으로 나가거나 Spliterator를 직접 짜야 했습니다. JEP 485로 Stream.gather와 java.util.stream.Gatherers가 Java 24에서 정식 기능이 됐고, 26에서 컴파일과 실행이 됩니다.
List<Integer> temps = List.of(21, 23, 22, 26, 25, 27, 30);
System.out.println(temps.stream().gather(Gatherers.windowFixed(3)).toList());
System.out.println(temps.stream().gather(Gatherers.windowSliding(3))
.map(w -> w.stream().mapToInt(Integer::intValue).average().orElse(0))
.map(a -> String.format("%.1f", a)).toList());
System.out.println(temps.stream().gather(Gatherers.scan(() -> 0, Integer::sum)).toList());
// 사용자 정의 gatherer: 직전 값보다 오른 값만 남긴다
Gatherer<Integer, int[], Integer> rising = Gatherer.ofSequential(
() -> new int[]{Integer.MIN_VALUE},
(state, x, down) -> { boolean up = x > state[0]; state[0] = x; return !up || down.push(x); });
System.out.println(temps.stream().gather(rising).toList());
[[21, 23, 22], [26, 25, 27], [30]]
[22.0, 23.7, 24.3, 26.0, 27.3]
[21, 44, 66, 92, 117, 144, 174]
[21, 23, 26, 27, 30]
같은 파일을 javac --release 23으로 컴파일하면 error: Gatherers is a preview API and is disabled by default.로 실패하고(22와 23에서는 프리뷰), --release 24에서는 컴파일됩니다. 사용자 정의 gatherer의 integrator는 false를 돌려주면 스트림을 일찍 멈춥니다. 요소를 건너뛸 때 스트림이 멈추지 않도록 !up || down.push(x)를 돌려줬습니다.
결정표
| 상황 | 쓸 것 | 근거(위 실측) |
|---|---|---|
| 단순한 filter/map/collect, 가독성 우선 | 순차 stream() |
A, B에서 루프보다 20%쯤 느린 정도 |
| 원시 배열을 도는 핫 루프 | for 루프나 IntStream |
박싱 없음, 루프 27ms vs 스트림 33ms |
| 큰 배열, 독립적인 CPU 작업, 100만 개 이상 | parallel() |
8코어에서 9.0ms vs 27.2ms |
| 작은 컬렉션, 요청마다 도는 코드 | parallelStream() 금지 |
1,000개에서 10배 느림 |
| 숫자 수열 생성 | Stream.iterate().limit() 대신 IntStream.range |
0.77ms vs 16~25ms |
| 키가 겹칠 수 있음 | toMap(k, v, merge)나 groupingBy |
인자 두 개 toMap은 IllegalStateException |
| 결과를 두 번 써야 함 | 소스를 들고 .stream()을 다시 호출 |
스트림은 두 번째 사용에서 예외 |
| 조회 결과가 없을 수 있음 | Optional 반환, 끝은 orElseGet |
orElse는 인자를 무조건 계산 |
| 결과 리스트를 수정함 | collect(toCollection(ArrayList::new)) |
Stream.toList()는 수정 불가 |
| 창, 누적합 | gather(...)(Java 24+) |
파이프라인 안에서 해결 |
| 항목별 오류 처리가 필요한 I/O | 평범한 for + try/catch |
검사 예외는 람다를 넘지 못함 |
한계와 버전 메모
- JMH가 아닙니다. 워밍업과 중앙값, 전체 세 번 실행. 병렬 결과가 가장 많이 흔들렸습니다(B의 한 샘플은 22.7ms). 밀리초가 아니라 비율을 결과로 보세요. 2코어 기계라면 A의 이득은 훨씬 작습니다.
- fork-join 공용 풀은 JVM 안의 모든 병렬 스트림이 함께 씁니다. 웹 서버에서 병렬 스트림은 다른 요청과 경쟁하는데, 프로그램 하나로 잰 이 측정에는 그 영향이 나타나지 않습니다.
- JDK 26에서
--release로 확인한 버전:Stream.toList()는 Java 16(--release 15는cannot find symbol로 실패). Gatherer는 Java 24에서 정식(23에서는 프리뷰 에러). 람다와 스트림은 Java 8,Files.readString은 Java 11, 레코드는 Java 16이며 이것들은 26에서만 실행했습니다. Collectors.teeing(Java 12), 원시 타입 컬렉터, 사용자 정의 gatherer의 병렬 동작은 다루지 않았습니다.- 다른 언어로 같은 개념을 보려면 C# LINQ의 지연 실행, 파이썬 제너레이터의 하나씩 꺼내는 방식이 같은 구조입니다.
직접 돌려 보기
StreamBench.java로 저장한 뒤 javac StreamBench.java && java -Xms2g -Xmx2g StreamBench로 실행합니다.
import java.util.*;
import java.util.function.LongSupplier;
import java.util.stream.*;
public class StreamBench {
static long sink;
static double measure(String name, int warmup, int runs, LongSupplier task) {
for (int i = 0; i < warmup; i++) sink += task.getAsLong();
double[] t = new double[runs];
for (int i = 0; i < runs; i++) {
long s = System.nanoTime();
sink += task.getAsLong();
t[i] = (System.nanoTime() - s) / 1e6;
}
Arrays.sort(t);
System.out.printf("%-30s median %9.4f ms (min %.4f, max %.4f)%n", name, t[runs / 2], t[0], t[runs - 1]);
return t[runs / 2];
}
public static void main(String[] args) {
System.out.println("cores=" + Runtime.getRuntime().availableProcessors()
+ " commonPool parallelism=" + java.util.concurrent.ForkJoinPool.commonPool().getParallelism());
Random rnd = new Random(7);
int[] big = new int[10_000_000];
for (int i = 0; i < big.length; i++) big[i] = rnd.nextInt(1000);
System.out.println("== A: sum of squares of even values, int[10,000,000]");
measure("for loop", 20, 11, () -> { long s = 0; for (int v : big) if ((v & 1) == 0) s += (long) v * v; return s; });
measure("IntStream", 20, 11, () -> Arrays.stream(big).filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum());
measure("IntStream.parallel()", 20, 11, () -> Arrays.stream(big).parallel().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum());
System.out.println("== B: same work, List<Integer> of 1,000 elements, 1,000 calls per sample");
List<Integer> small = new ArrayList<>();
for (int i = 0; i < 1_000; i++) small.add(rnd.nextInt(1000));
measure("for loop", 20, 11, () -> { long t = 0; for (int r = 0; r < 1000; r++) { long s = 0; for (int v : small) if ((v & 1) == 0) s += (long) v * v; t += s; } return t; });
measure("stream()", 20, 11, () -> { long t = 0; for (int r = 0; r < 1000; r++) t += small.stream().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum(); return t; });
measure("parallelStream()", 20, 11, () -> { long t = 0; for (int r = 0; r < 1000; r++) t += small.parallelStream().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum(); return t; });
System.out.println("== C: LinkedList<Integer> of 1,000,000");
List<Integer> linked = new LinkedList<>();
for (int i = 0; i < 1_000_000; i++) linked.add(rnd.nextInt(1000));
measure("for loop", 20, 11, () -> { long s = 0; for (int v : linked) if ((v & 1) == 0) s += (long) v * v; return s; });
measure("stream()", 20, 11, () -> linked.stream().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum());
measure("parallelStream()", 20, 11, () -> linked.parallelStream().filter(v -> (v & 1) == 0).mapToLong(v -> (long) v * v).sum());
System.out.println("== D: Stream.iterate(...).limit(2,000,000)");
measure("for loop", 20, 11, () -> { long s = 0; for (int i = 0; i < 2_000_000; i++) s += (long) i * i; return s; });
measure("iterate+limit", 20, 11, () -> Stream.iterate(0, i -> i + 1).limit(2_000_000).mapToLong(i -> (long) i * i).sum());
measure("iterate+limit parallel", 20, 11, () -> Stream.iterate(0, i -> i + 1).limit(2_000_000).parallel().mapToLong(i -> (long) i * i).sum());
measure("LongStream.range parallel", 20, 11, () -> LongStream.range(0, 2_000_000).parallel().map(i -> i * i).sum());
System.out.println("sink=" + (sink != 0));
}
}
이전: 3강: 컬렉션 성능 · 다음: 5강: 가상 스레드와 동시성
자주 묻는 질문
parallelStream을 쓰면 for 문보다 빨라지나요?
작업량이 충분할 때만입니다. int[10,000,000]의 짝수 제곱합은 for 루프 27.2ms, IntStream.parallel() 9.0ms였습니다(8코어 M1 Pro). 같은 작업을 요소 1,000개짜리 리스트에 1,000번 하면 for 루프 0.67ms, parallelStream 6.57ms로 약 10배 느렸습니다.
Collectors.toMap에서 IllegalStateException: Duplicate key가 나는 이유는?
인자 두 개짜리 toMap은 키가 모두 다르다고 가정합니다. 같은 키가 또 나오면 'Duplicate key ramen (attempted merging values 980 and 850)'처럼 키와 두 값을 보여 주며 예외를 던집니다. 세 번째 인자로 Integer::max 같은 병합 함수를 주거나 groupingBy를 쓰세요.
Stream.toList()와 collect(Collectors.toList())는 무엇이 다른가요?
Stream.toList()(Java 16)는 수정 불가 리스트라서 add하면 UnsupportedOperationException이 납니다. collect(Collectors.toList())는 JDK 26에서 java.util.ArrayList를 돌려줬지만 Javadoc이 보장하지는 않습니다. Stream.toList()는 null을 허용하고 Collectors.toUnmodifiableList()는 허용하지 않습니다.