Stream.forEach가 종단 연산이라는 것을 공부하다보니 여기까지 왔다.
1. Collection.forEach란?
`collection.forEach`는 `Iterable` 인터페이스에서 제공하는 메서드로, 컬렉션의 모든 요소를 순회하면서 하나씩 작업을 수행합니다.
특징
- 내부 반복자(internal iterator)를 사용합니다.
- 순차적으로 요소를 처리합니다.
- 기본적으로 데이터 변경에 적합합니다.
public class Main {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("Alice", "Bob", "Charlie"));
// Collection.forEach 사용
names.forEach(name -> System.out.println("Hello, " + name + "!"));
}
}
2. Stream.forEach란?
Stream.forEach는 Stream API에서 제공하는 종단 연산으로, 스트림의 모든 요소를 하나씩 처리합니다.
특징
- 지연 실행(lazy evaluation): 스트림에서 중간 연산을 포함한 모든 작업은 forEach와 같은 종단 연산이 호출될 때 실행됩니다.
- 병렬 스트림(parallel stream)에서 사용하면 병렬적으로 요소를 처리할 수 있습니다.
- 데이터 변경에는 적합하지 않음: 데이터를 조회하거나 소비할 때 주로 사용합니다.
3. 병렬 처리의 차이
Stream.forEach는 병렬 처리에서 달라진다!
Stream.forEach를 병렬 스트림과 함께 사용할 때, 작업이 병렬적으로 처리됩니다. 이때 순서가 보장되지 않을 수 있습니다.
List<String> names = List.of("A", "B", "C", "D", "E");
// Collection.forEach 사용
names.forEach(name -> System.out.println(Thread.currentThread().getName() + " processing " + name));
System.out.println("------------");
// 병렬 스트림에서 Stream.forEach 사용
names.parallelStream().forEach(name -> System.out.println(Thread.currentThread().getName() + " processing " + name));
}
코드를 여러 번 실행하면 `Collection.forEach()`가 삽입 순서대로 항목을 처리하는 반면, `stream.parallelStream().forEach()`는 실행할 때마다 다른 결과를 생성하는 것을 볼 수 있습니다.
❗️만약 병렬처리를 하면서 순서를 유지하고 싶다면 `forEachOrdered`를 사용하시면 됩니다.
names.parallelStream()
.forEachOrdered(name -> System.out.println(Thread.currentThread().getName() + " processing " + name));
4.컬렉션 수정
많은 컬렉션(ex: ArrayList or HashSet)은 반복하는 동안 구조적으로 수정되어서는 안 됩니다. 반복하는 동안 요소가 제거되거나 추가되면 `ConcurrentModification`예외가 발생합니다.
더욱이 컬렉션은 빠르게 실패하도록 설계되어 있기 때문에 수정이 있으면 즉시 예외가 발생합니다.
마찬가지로, 스트림 파이프라인을 실행하는 동안 요소를 추가하거나 제거하면 `ConcurrentModification`예외가 발생합니다. 그러나 이 예외는 나중에 throw됩니다.
먼저 `Collection.forEach()`먼저 보겠습니다.
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
Consumer<String> removeElement = s -> {
System.out.println(s + " " + list.size());
if (s != null && s.equals("A")) {
list.remove("D");
}
};
list.forEach(removeElement);
}
`Collection.forEach()`는 실패 속도가 빠르므로 반복을 멈추고 다음 요소가 처리되기 전에 예외가 발생합니다 .
그럼 이번에는 위 코드에서 `stream.forEach()`를 사용해보도록 하겠습니다.
list.stream().forEach(removeElement);
forEach가 끝나고 예외를 발생시킵니다.
왜 예외가 나중에 발생할까?
stream().forEach에서 예외가 스트림 처리 이후 발생하는 이유는 다음과 같습니다
- 스트림의 비동기적 처리 가능성:
- 스트림은 기본적으로 병렬 처리가 가능하도록 설계되었습니다. 예를 들어, `parallelStream()`을 사용하면 여러 스레드에서 동시에 작업이 수행될 수 있습니다.
- `synchronized` 키워드가 없더라도 각 스레드는 독립적으로 데이터를 처리할 수 있지만, 공유된 컬렉션에서 구조적 변경이 발생하면 스플리터의 무결성이 깨져 나중에 검증 시 문제가 됩니다.
- Spliterator의 설계:
- 스트림은 `Spliterator`를 사용하여 데이터를 분할하고 처리합니다. 이 과정에서 `Spliterator`는 컬렉션의 상태를 추적합니다.
- 스트림이 종료된 후, 최종적으로 컬렉션의 변경 여부를 확인합니다. 이때 변경 사항이 발견되면 예외가 발생합니다.
- 스트림의 지연 평가:
- 스트림은 결과를 요구받기 전까지 실제로 작업을 수행하지 않습니다.
- 따라서 스트림의 모든 작업이 끝난 후에야 컬렉션 변경 여부를 검증합니다.
핵심 정리
- forEach의 동작:
- List.forEach는 Iterator를 사용하여 컬렉션의 요소를 순차적으로 처리합니다.
- 순회 중 컬렉션이 수정되면 즉시 `ConcurrentModificationException`을 발생시킵니다.
- 컬렉션의 변경 여부는 `modCount`라는 필드를 통해 확인됩니다. `modCount`가 변경되면 즉시 예외가 발생합니다.
- stream().forEach의 동작:
- stream().forEach는 스트림의 각 요소를 처리하며, 내부적으로 `Spliterator`를 사용합니다.
- 스트림이 한 번에 처리된 후에 변경 여부를 확인합니다. 따라서 모든 요소를 처리한 다음에 예외가 발생할 수 있습니다.
- 이 동작은 스트림이 지연 평가(lazy evaluation)로 동작하기 때문에 가능합니다.
- 병렬로 처리할 때 순서가 보장되지 않습니다.
📚 Reference
- https://www.baeldung.com/java-collection-stream-foreach
- https://tecoble.techcourse.co.kr/post/2020-09-30-collection-stream-for-each/
'JAVA > 개념' 카테고리의 다른 글
언제 equals를 재정의 해야할까? (0) | 2024.11.22 |
---|---|
stream.forEach (feat. 종단 연산) (1) | 2024.11.18 |
스트림의 중간 연산, 종단 연산 (0) | 2024.11.18 |
자바 정적 팩토리 메서드 쉽게 이해하기 - 왜 사용하고 어떻게 활용할까? (0) | 2024.10.24 |