Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Item47, 48 반환 타입은 스트림보다 컬렉션, 스트림 병렬화는 주의 !! #24

Open
olrlobt opened this issue Dec 20, 2023 · 0 comments
Assignees

Comments

@olrlobt
Copy link
Contributor

olrlobt commented Dec 20, 2023

section: (7장 아이템 47, 48)


반환 타입으로는 스트림보다 컬렉션이 낫다

왜 컬렉션이 낫다고 하는가?

  • 명확성과 유지보수
  • 재사용성
  • 예측 가능성




리스트 반환 예제

public static List<String> getListOfNames() {
        return List.of("Alice", "Bob", "Charlie");
    }

    public static void main(String[] args) {
        List<String> namesList = getListOfNames();
        // 컬렉션을 사용하는 로직
        namesList.forEach(System.out::println);
        namesList.forEach(System.out::println);
    }

스트림 반환 예제

public static Stream<String> getStreamOfNames() {
        return Stream.of("Alice", "Bob", "Charlie");
    }

public static void main(String[] args) {
        Stream<String> namesStream = getStreamOfNames();
        // 스트림을 사용하는 로직
        namesStream.forEach(System.out::println);
        namesStream.forEach(System.out::println); // Stream은 일회용이다.
}

결과 - IllegalStateException 발생

img_1

스트림이 일회성 데이터 흐름이라는 것을 의미.
즉, 스트림은 데이터를 순회할 때 '한 번만 사용 가능한' 특성을 가진다.

따라서 여러번 반복하는 행위를 할 수 없는데,,




만약 사용자가 반복을 한다는 것을 미리 예견한다면
반복이라는 것을 명확히 할 수 있는 Iterable로 처리하는 것이 좋지만,

public static Iterable<String> getStreamOfNames() {
        return Stream.of("Alice", "Bob", "Charlie").toList();
    }

    public static void main(String[] args) {
        Iterable<String> namesIterable = getStreamOfNames();

        // Iterable을 사용하여 반복
        namesIterable.forEach(System.out::println);
        namesIterable.forEach(System.out::println);
    }

우리는 궁예가 아니기 때문에,
Collection으로 처리 해 주어서 사용자의 선택 폭을 넓혀주는 것이 최선일 것이다.

public static Collection<String> getStreamOfNames() {
        return Stream.of("Alice", "Bob", "Charlie").toList();
    }

    public static void main(String[] args) {
        Collection<String> namesIterable = getStreamOfNames();

        // Collection
        namesIterable.forEach(System.out::println);
        namesIterable.forEach(System.out::println);
    }





스트림 병렬화는 주의해서 사용해라

동시성 프로그래밍 측면에서 자바는 항상 앞서갔고,
Java8부터 parallel() 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다.

이렇게, 동시성 프로그램을 자바로 작성하는 것이 쉬워지고 있지만, 이를 올바르고 빠르게 작성하는 일은
어려운 작업이다.



동시성 프로그래밍을 할때는 Safety(안정성), Liveness(응답 가능) 상태를 유지하기 위해 애써야 하는데,
스트림에서도 마찬가지이다.

parallel 예시)

 public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        numbers.stream() // 순차 스트림 생성
                .map(n -> n * n) // 각 숫자의 제곱 계산
                .forEach(System.out::println); // 결과 출력
    }
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        numbers.parallelStream() // 병렬 스트림 생성
                .map(n -> n * n) // 각 숫자의 제곱 계산
                .forEach(System.out::println); // 결과 출력
    }

//        9
//        25
//        16
//        4
//        1
// 스트림의 각 요소가 병렬 처리되어, 결과 순서가 다를 수 있다.

Stream 병렬처리에서 성능 개선을 기대할 수 없는 경우

  1. Stream.iterate
Stream.iterate(0, n -> n + 1)
      .limit(10)
      .parallel() // 이 경우 병렬 처리가 큰 도움이 되지 않음
      .forEach(System.out::println);

이 전 요소에 의존적이기 때문에, 큰 도움이 되지 않는다.

  1. limit
Stream.of(1, 2, 3, 4, 5)
      .parallel() // 병렬 처리
      .limit(3) // 이 경우 병렬 처리의 효과가 제한적일 수 있음
      .forEach(System.out::println);

병렬처리의 오버헤드가 실제 작업량보다 클 수 있다.

  1. 이외
  • 작업 단위가 너무 작은 경우
  • 공유 자원에 대한 의존성이 높은 경우
  • 입출력(IO) 집약적인 작업
  • 병렬화로 인한 부하 불균형
  • 스트림 연산의 복잡성
  • CPU 코어의 수

Stream 병렬 처리에 적합한 경우

  1. 집계연산 (min, max, count, sum ... )

이러한 연산들은 데이터 집합의 각 요소를 독립적으로 처리할 수 있으며, 각 부분의 결과를 결합하는 것이 비교적 간단하다.

  1. 조건 검사 연산 (anyMatch, allMathch ... )

anyMatch와 allMatch 같은 연산은 조건을 만족하는 요소를 찾는 즉시 처리를 중단할 수 있다. 병렬 처리에서 이는 더 빠른 결과 도출로 이어질 수 있다.

  1. 병렬 처리에 적합한 스트림 소스

스트림 API를 사용할 때 병렬 처리의 효과가 가장 좋은 소스는 데이터를 효율적으로 분할하고 빠르게 접근할 수 있는 구조를 가진 경우

  • 배열 , ArrayList

배열을 사용하므로, 인덱스를 통한 빠른 무작위 접근이 가능하다.
데이터를 효과적으로 분할하여 병렬 처리를 수행하기 쉽다.

  • HashMap, HashSet

해시 테이블은 분할하기 쉽고, 각 버킷에 대한 처리를 병렬로 수행할 수 있다.
그러나, HashMap은 데이터의 순서가 정의되어 있지 않기 때문에 순서에 의존하는 연산에는 적합하지 않다.

  • ConcurrentHashMap

스레드 안전성을 보장하면서도, 데이터의 효율적인 병렬 처리가 가능하다.
내부적으로 세분화된 락을 사용하여 동시성을 높이고 있다.

  • 숫자 범위 (int 범위, long 범위)

특정 숫자 범위는 균일하게 분할하기 쉽고, 각 범위에 대한 연산은 독립적으로 수행될 수 있다.

Spliterator

Spliterator는 Java 8에서 소개된 인터페이스로, '분할할 수 있는 반복자(Splittable Iterator)'의 약어.
이 인터페이스는 Iterable 요소들을 효율적으로 분할하여 병렬 처리를 지원하기 위해 설계되었다.

고효율 Spliterator 예제 구현

import java.util.ArrayList;
import java.util.List;
import java.util.Spliterator;
import java.util.function.Consumer;

public class EfficientSpliterator implements Spliterator<Integer> {

    private final List<Integer> list;
    private int start;
    private int end;

    // EfficientSpliterator 생성자
    public EfficientSpliterator(List<Integer> list, int start, int end) {
        this.list = list;
        this.start = start;
        this.end = end;
    }

    // Spliterator의 현재 요소를 소비하고 다음 요소로 이동
    @Override
    public boolean tryAdvance(Consumer<? super Integer> action) {
        if (start < end) {
            action.accept(list.get(start));
            start++;
            return true;
        }
        return false;
    }

    // Spliterator를 분할하여 병렬 처리를 용이하게 함
    @Override
    public Spliterator<Integer> trySplit() {
        int mid = (end - start) / 2 + start;
        if (mid <= start) {
            return null;
        }
        EfficientSpliterator split = new EfficientSpliterator(list, start, mid);
        start = mid;
        return split;
    }

    // 남은 요소의 추정 개수를 반환
    @Override
    public long estimateSize() {
        return end - start;
    }

    // Spliterator의 특성을 설정
    @Override
    public int characteristics() {
        return ORDERED | SIZED | SUBSIZED | NONNULL | IMMUTABLE;
    }

    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>();
        for (int i = 0; i < 1000; i++) {
            numbers.add(i);
        }

        EfficientSpliterator spliterator = new EfficientSpliterator(numbers, 0, numbers.size());
        spliterator.trySplit().forEachRemaining(System.out::println);
        spliterator.forEachRemaining(System.out::println);
    }
}

주요 포인트 설명

tryAdvance:
tryAdvance 메소드는 Spliterator의 현재 요소를 처리하고, 인덱스를 다음 요소로 이동한다.

trySplit:
trySplit 메소드는 Spliterator를 분할하는 데 사용된다. 이 메소드는 현재 Spliterator의 일부를 새로운 Spliterator로 분리하여 반환합니다. 이 방식은 병렬 처리에 매우 중요하다.

estimateSize:
현재 Spliterator에 남아있는 요소의 추정 개수를 반환한다.

characteristics:
Spliterator의 특성을 나타내는데, 이 예제에서는 ORDERED, SIZED, SUBSIZED, NONNULL, IMMUTABLE을 사용했다.

결론

스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한다.
다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 가치있는지 확인해야한다.


reference

@olrlobt olrlobt self-assigned this Dec 20, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants