[JAVA] Collection forEach vs Stream forEach
Java8 에서 Stream이 도입된지는 꽤 오래전 일이다.
개발하면서 문득 궁금해서 검색하다가 정리해보는 글.
Collection.forEach 메서드로 반복할 때와 Stream.forEach 메서드로 반복할 때는 무슨 차이가 있을까?
✔️ 동시성 문제
Collection.forEach의 경우엔 수정을 감지한 즉시 ConcurrentModificationException을 던지며 프로그램을 멈춘다. ConcurrentModificationException이란 한 오브젝트에 대해 허가되지 않은 변경이 동시적으로 이루어질 때 발생한다. 대표적으로 Collection이 반복되는 동안 Collection을 수정할 때 발생한다.아래의 코드는 List의 element가 짝수이면 remove 하는 Consumer를 forEach로 돌린 것이다. 코드와 테스트 결과를 보자.
✓ Collection forEach
@Test
public void test() {
List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));
Consumer<Integer> removeIfEven = num -> {
System.out.println(num);
if (num % 2 == 0) {
nums.remove(num);
}
};
//collection for-each
assertThatThrownBy(() -> nums.forEach(removeIfEven))
.isInstanceOf(ConcurrentModificationException.class);
}
Collection.forEach의 경우에는 fail-fast 이므로 반복을 중지하고 다음 요소가 처리되기 전에 예외를 확인한다.
List의 첫 번째 짝수 2가 지워지자 바로 ConcurrentModificationException이 발생하는 것을 볼 수 있다. 그렇다면 Stream.forEach의 경우엔 어떨까?
✓ Stream() forEach
@Test
public void streamForeachTest() {
List<Integer> nums = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6));
Consumer<Integer> removeIfEven = num -> {
System.out.println(num);
if (num % 2 == 0) {
nums.remove(num);
}
};
// stream API for-each
assertThatThrownBy(() -> nums.stream().forEach(removeIfEven))
.isInstanceOf(NullPointerException.class);
}
Collection.forEach처럼 Collection이 수정되자마자 예외를 던지는 것이 아니라 무조건 리스트를 끝까지 돌고 예외를 던진다. 또 던지는 예외가 ConcurrentModificationException이 아니라 NullPointerException이라는 차이점이 있다.
// Collection Iterable.java
default void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action);
for (T t : this) {
action.accept(t);
}
}
// Stream.java
void forEach(Consumer<? super T> action);
Collection.forEach는 따로 객체를 생성하지 않고 forEach 메서드를 호출한다. forEach 메서드는 Iterable 인터페이스의 default 메서드인데, Collection 인터페이스에서 Iterable 인터페이스를 상속하고 있기에 바로 호출할 수 있는 것이다.
반면에 Stream.forEach는 Collection 인터페이스의 default 메서드 stream()으로 Stream 객체를 생성해야만 forEach를 호출할 수 있다.
위의 예제처럼 단순 반복이 목적이라면 Stream.forEach는 stream()으로 생성된 Stream 객체가 버려지는 오버헤드가 있기에, filter, map 등의 Stream 기능들과 함께 사용할 때만 Stream.forEach를 사용하고 나머지의 경우엔 Collection.forEach를 쓰는 것이 좋아 보인다.
@Override
public void forEach(Consumer<? super E> consumer) {
synchronized (mutex) {c.forEach(consumer);}
}
@Override
public Spliterator<E> spliterator() {
return c.spliterator(); // Must be manually synched by user!
}
Collection.forEach는 일반적으로 해당 컬렉션의 Iterator를 사용하고 Stream.forEach는 해당 컬렉션의 spliterator를 사용한다. Collections.java에서 보면 아래의 코드처럼 Collection.forEach에는 synchronized 키워드가 붙어있고 Stream.forEach를 위해 필요한 spliterator 메서드는 안붙어있는 것을 확인할 수 있다.
결론적으로 Collection.forEach는 락이 걸려있기에 멀티쓰레드에서 더 안전하다.
반면에 Stream.forEach는 반복 도중에 다른 쓰레드에 의해 수정될 수 있고, 무조건 요소의 끝까지 반복을 돌게 된다.
이 과정에서 일관성 없는 동작이 발생하고 예상치 못한 에러가 발생할 확률이 높다.
✔️ 결론
결국 반복을 위해 존재하는 Collection.forEach와 Stream.forEach의 차이는 정말 미묘하다.
단지 Stream.forEach는 Stream의 컨셉에 맞게 병렬 프로그래밍(parallelStream)에 특화된 반복을 위해 있는 것뿐이다.
일반적인 반복의 경우엔 thread-safe 한 Collection.forEach를 쓰는게 좋아보인다.
참고:
https://tecoble.techcourse.co.kr/post/2020-09-30-collection-stream-for-each/