ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 7장 - 람다와 스트림
    📕 book/이펙티브 자바 2022. 11. 7. 18:27

    자바 8에서 함수형 인터페이스, 람다, 메서드 참조라는 개념이 추가되면서 함수 객체를 더 쉽게 만들 수 있게 되었다.

    이와 함께 스트림 API까지 추가되어 데이터 원소의 시퀀스 처리를 라이브러리 차원에서 지원하기 시작했다.

    📌 익명 클래스보다는 람다를 사용하라


    JDK 1.1. 이전

    예전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스(드물게는 추상클래스)를 사용했다.

    이런 인터페이스의 인스턴스를 함수 객체(function object)라고 한다.

    JDK 1.1

    JDK 1.1이 등장하면서 함수 객체를 만드는 주요한 수단은 익명 클래스가 되었다.

    문자열을 길이순으로 정렬하기 위한 비교 함수로 익명 클래스를 사용

    Collections.sort(words, new Comparator<String>() {
        public int compare(String s1, String s2) {
            return Integer.compare(s1.length(), s2.length());
        }
    });

    Java 8

    자바 8에서 부터 추상 메서드 하나짜리 인터페이스는 함수형 인터페이스라고 부른다.

    이 인터페이스들의 인스턴스를 람다식(lambda expression, 짧게 람다)을 사용해 만들 수 있게 되었다.

    • 람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다.
    Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
    

    여기서 람다, 매개변수(s1,s2), 반환값의 타입은 코드에서 언급 ❌

    → 우리 대신 컴파일러가 문맥을 살펴 타입을 추론해준다.

     

    타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하자.

     

    상수별 클래스 몸체와 데이터를 사용한 열거 타입

    public enum Operation {
        PLUS("+", {
          public double apply(double x, double y) {return x + y; }
        },
        MINUS("-", {
          public double apply(double x, double y) {return x - y; }
        },
        TIMES("*", {
          public double apply(double x, double y) {return x * y; }
        },
        DIVIDE("/", {
          public double apply(double x, double y) {return x / y; }
        };
    
        private final String symbol;
    
        Operation(String symbol) { this.symbol = symbol; }
    
        @Override
        public String toString() {
            return symbol;
        }
    
        public abstract double apply(double x, double y);
    }

     

    함수 객체(람다)를 인스턴스 필드에 저장해 상수별 동작을 구현한 열거 타입

    public enum Operation {
        PLUS("+", (x, y) -> x + y),
        MINUS("-", (x, y) -> x - y),
        TIMES("*", (x, y) -> x * y),
        DIVIDE("/", (x, y) -> x / y);
    
        private final String symbol;
        private final DoubleBinaryOperator operator;
    
        Operation(String symbol, DoubleBinaryOperator operator) {
            this.symbol = symbol;
            this.operator = operator;
        }
    
        @Override
        public String toString() {
            return symbol;
        }
    
        public double apply(double x, double y) {
            return operator.applyAsDouble(x, y);
        }
    }
    • DoubleBinaryOperator : double 타입 인수 2개를 받아 double 타입 결과를 돌려준다.

     

    고려할 점

    • 람다는 이름이 없고 문서화도 못한다.
      • 따라서, 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.
      • 람다는 한 줄일 때 가장 좋고 세 줄 안에 끝내는 게 좋다.
    • 열거 타입 생성자에 넘겨지는 인수들의 타입도 컴파일타임에 추론된다.
      • 따라서 열거 타입 생성자 안의 람다는 열거 타입의 인스턴스 멤버에 접근할 수 없다.
      • 열거 타입에서 상수별 동작을 몇 줄로 구현하기 어렵거나 인스턴스 필드나 메서드를 사용해야만 하는 상황이라면 상수별 클래스 몸체를 사용해야 한다.
    • 람다는 함수형 인터페이스에서만 쓰인다.
    • 람다는 자신을 참조할 수 없다.
      • 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스를 사용해야 한다.
    • 람다는 직렬화 형태가 구현별로 다를 수 있다. (익명 클래스처럼)
      • 따라서 람다를 직렬화하는 일은 극히 삼가야 한다
      • 직렬화해야만 하는 함수 객체가 있다면 private 정적 중첩 클래스의 인스턴스를 사용

    📌 람다보다는 메서드 참조를 사용하라


    람다가 익명 클래스보다 나은 점 중 가장 큰 특징은 간결함이다.

    자바에는 람다보다도 더 간결하게 만드는 방법이 있다 ⇒ 메서드 참조(method reference)

    // 람다식 사용
    map.merge(key, 1, (count, incr) -> count + incr);
    
    // 메서드 참조 사용
    map.merge(key, 1, Integer::sum);
    • Integer 클래스는 람다와 기능이 같은 정적 메서드 sum을 제공
    • 따라서 람다 대신 이 메서드의 참조를 전달하면 똑같은 결과를 더 보기 좋게 얻을 수 있다.

    매개변수 수가 늘어날수록 메서드 참조로 제거할 수 있는 코드양 도 늘어난다.

    하지만 어떤 람다에서는 매개변수의 이름 자체가 프로그래머에게 좋은 가이드가 되기도 한다.

    • 이런 람다는 길이는 더 길지만 메서드 참조보다 읽기 쉽고 유지보수도 쉬울 수 있다.

    람다로 할 수 없는 일이라면 메서드 참조로도 할 수 없다.

    • 예외 : 제네릭 함수 타입 구현
      • 제네릭 함수 타입은 메서드 참조 표현식으로는 구현할 수 있지만, 람다식으로는 불가능하다.
    • 그렇더라도 메서드 참조를 사용하는 편이 보통은 더 짧고 간결하므로, 람다로 구현했을 때 너무 길거나 복잡하다면 메서드 참조가 좋은 대안이 되어준다.

     

    람다로 작성할 코드를 새로운 메서드에 담은 다음, 람다 대신 그 메서드 참조를 사용

    • 메서드 참조에는 기능을 잘 드러내는 이름을 지어줄 수 있고 친절한 설명을 문서로 남길 수도 있다.

     

    메서드 참조의 유형

    1. 정적 메서드를 가리키는 메서드 참조 (가장 흔한 유형)
    2. 인스턴스 메서드를 참조하는 유형
      • 수신 객체(참조 대상 인스턴스)를 특정하는 한정적 인스턴스 메서드 참조
        • 함수 객체가 받는 인수와 참조되는 메서드가 받는 인수가 같다
      • 수신 객체를 특정하지 않는 비한정적 인스턴스 메서드 참조
        • 함수 객체를 적용하는 시점에 수신 객체를 알려준다.
    3. 클래스 생성자를 가리키는 메서드 참조
    4. 배열 생성자를 가리키는 메서드 참조
    메서드 참조 유형 람다
    정적 Integer::parseInt str → Integer.parseInt(str)
    한정적(인스턴스) Instant.now()::isAfter Instant then = Instant.now();
    t → then.isAfter(t)
    비한정적(인스턴스) String::toLowerCase str → str.toLowerCase()
    클래스 생성자 TreeMap<K,V>::new () → new TreeMap<K,V>()
    배열 생성자 int[]::new len → new int[len]

    📌 표준 함수형 인터페이스를 사용하라


    자바가 람다를 지원하면서 API를 작성하는 모범사례도 크게 바뀌었다.

    • 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 것으로 대체
    • 이때, 함수형 매개변수 타입을 올바르게 선택해야 한다.

     

    필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용

    @FunctionalInterface
    public interface EldestEntryRemovalFunction<K, V> {
        boolean remove(Map<K, V> map, Map.Entry<K, V> eldest);
    }

    이 인터페이스도 잘 동작하지만, 굳이 사용할 이유가 없다.

    • 자바 표준 라이브러리에 이미 같은 모양의 인터페이스가 준비되어 있기 때문이다.
    • java.util.function 패키지를 보면 다양한 용도의 표준 함수형 인터페이스가 담겨 있다.

    표준 함수형 인터페이스의 장점

    1. API가 다루는 개념의 수가 줄어들어 익히기 더 쉬워진다.
    2. 표준 함수형 인터페이스들은 유용한 디폴트 메서드를 많이 제공해 다른 코드와의 상호운용성도 크게 좋아진다.

    java.util.function 패키지

    java.util.function 패키지에는 총 43개의 인터페이스가 담겨 있다.

    기본 인터페이스 6개만 기억하면 나머지를 충분히 유추해 낼 수 있다.

    • 모두 참조 타입용이다.
    인터페이스 함수 시그니처
    UnaryOperator<T> T apply(T t) String::toLowerCase
    BinaryOperator<T> T apply(T t1 , T t2) BigInteger::add
    Predicate<T> boolean test(T t) Collection::isEmpty
    Function<T> R apply(T t) Arrays::asList
    Supplier<T> T get() Instant::now
    Consumer<T> void accept(T t) System.out::println
    • Operator 인터페이스
      • 인수가 1개인 UnaryOperator와 2개인 BinaryOperator로 나뉘며, 반환값과 인수의 타입이 같은 함수를 뜻한다.
    • Predicate 인터페이스
      • 인수 하나를 받아 boolean을 반환하는 함수를 뜻한다.
    • Function 인터페이스
      • 인수와 반환 타입이 다른 함수를 뜻한다.
    • Supplier 인터페이스
      • 인수를 받지 않고 값을 반환(혹은 제공)하는 함수를 뜻한다.
    • Consumer 인터페이스
      • 인수를 하나 받고 반환값은 없는, 인수를 소비하는 함수를 뜻한다.

     

    표준 함수형 인터페이스 대부분은 기본 타입만 지원한다.

    • 기본 함수형 인터페이스에 박싱된 기본 타입을 넣어 사용하지 말자.

    코드를 직접 작성해야 할때 ❔

    이 중 하나이상을 만족하면 전용 함수형 인터페이스를 구현해야 하는 건지 고민해야 한다.

    1. 자주 쓰이며, 이름 자체가 용도를 명확히 설명해준다
    2. 반드시 따라야 하는 규약이 있다
    3. 유용한 디폴트 메서드를 제공할 수 있다.

    전용 함수형 인터페이스를 작성하기로 했다면, 자신이 작성하는게 다른 것도 아닌 ‘인터페이스’임을 명시해야 한다.

    @FunctionalInterface

    이 에너테이션을 사용하는 이유는 프로그래머의 의도를 명시하는 것으로 크게 3가지 목적이 있다.

    1. 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려준다.
    2. 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해준다.
    3. 유지 보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.

    직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하라!!

    함수형 인터페이스를 API에서 사용할 때의 주의점

    • 서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안된다.
      • 클라이언트에게 불필요한 모호함만 안겨준다.
      • 피하는 가장 쉬운 방법 : 서로 다른 함수형 인터페이스를 같은 위치의 인수로 사용하는 다중정의를 피하자.

     

    📌 스트림은 주의해서 사용하라


    스트림 API는 다량의 데이터 처리 작업을 돕고자 자바 8에 추가되었다.

    이 API가 제공하는 추상 개념 중 핵심은 두 가지이다.

    • 스트림(Stream)데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
    • 스트림 파이프라인(stream pipeline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.

    스트림의 원소들은 어디로부터든 올 수 있다.

    • 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기, 혹은 다른 스트림
    • 스트림안의 데이터 원소들은 객체 참조나 기본 타입 값이다
      • 기본 타입 값으로는 int, long, double 이렇게 3가지를 지원

    스트림 파이프라인

    스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝난다.

    • 그 사이 하나 이상의 중간 연산이 있을 수 있다.
    • 각 중간 연산은 스트림을 어떠한 방식으로 변환 한다.

    중간 연산

    • 모두 한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변환 전 스트림의 원소타입과 같을 수도 있고 다를 수도 있다.

    종단 연산

    • 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가한다.

    스트림 파이프라인은 지연 평가(lazy evaluation) 된다.

    • 평가는 종단 연산이 호출될 때 이뤄진다.
    • 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
    • 지연 평가는 무한 스트림을 다룰 수 있게 해주는 열쇠이다.

    ⇒ 절대 종단 연산을 빼먹는 일이 없도록 하자

     

    스트림 API는 메서드 연쇄를 지원하는 플루언트 API(fluent API)다.

    • 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다.
    • 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.

    스트림 파이프라인은 순차적으로 수행된다.

    • 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 되나, 효과를 볼 수 있는 상황은 많지 않다.

    스트림 API는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있다.

     

    스트림 과용

    스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어 진다.

    // 스트림을 과하게 사용
    public class Anagrams {
            public static void main(String[] args) throws IOException {
                Path dictionary = Paths.get(args[0]);
                int minGroupSize = Integer.parseInt(args[1]);
                try (Stream<String> words = Files.lines(dictionary)) { 
                    words.collect(
                        groupingBy(word -> word.chars().sorted()
                                  .collect(StringBuilder::new,
                                     (sb, c) -> sb.append((char) c),
                                      StringBuilder::append).toString()))
                    .values().stream()
                    .filter(group -> group.size() >= minGroupSize)
                    .map(group -> group.size() + ": " + group)
                    .forEach(System.out::println);
                }
            }
        }

    char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.

    • char 값을 출력하기 위해서는 출력시 형변환을 명시적으로 해줘야 한다.

     

    스트림 올바르게 사용

    스트림으로 바꾸는 게 가능하더라도 코드 가독성과 유지보수 측면에서 손해볼 수 있기 때문에 주의해야 한다.

    기존 코드는 스트림을 사용하도록 리팩터링 하되 새 코드가 더 나아 보일때만 반영하자

    • 스트림과 반복문을 적절히 조합하는게 최선이다.

     

    스트림이 안성맞춤인 일

    • 원소들의 시퀀스를 일관되게 변환
    • 원소들의 시퀀스를 필터링
    • 원소들의 시퀀스를 하나의 연산을 사용해 결합
      • 더하기, 연결하기, 최솟값 구하기 등
    • 원소들의 시퀀스를 컬렉션에 모은다
    • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

    이중 하나를 수행하는 로직이라면 스트림을 적용하기에 좋은 후보이다.

     

    스트림으로 처리하기 어려운 일

    대표적인 예 : 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서 값들에 동시에 접근하기 어려운 경우

    원인 : 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이다!!

     

    원래 값과 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하는 우회 방법도 있지만, 만족스러운 해법은 아니다. 특히 매핑 객체가 필요한 단계가 여러 곳이면 더 그렇다.

    • 이런 방식은 코드 양도 많고 지저분하여 스트림을 쓰는 주목적에서 벗어난다.

     

    📌 스트림에서는 부작용 없는 함수를 사용하라


    스트림은 그저 또 하나의 API가 아닌, 함수형 프로그래밍에 기초한 패러다임이다.

    스트림 패러다임의 핵심은 계산을 일련의 변환(transformation)으로 재구성하는 부분이다.

    • 이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.
    • 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)가 없어야 한다.

    순수 함수

    • 오직 입력만이 결과에 영향을 주는 함수
    • 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.

    스트림 패러다임을 이해하지 못한채 API만 사용한 경우 - 절대 따라하지 말것 ❌

    Map<String, Long> freq = new HashMap<>();
    try(Stream<String> words = new Scanner(file).tokens()) {
       words.forEach(word -> {
            freq.merge(word.toLowerCase(), 1L, Long::sum);
       });
    }
    • 이는 스트림 코드를 가장한 반복적 코드다.
    • 스트림 API의 이점을 살리지 못하여 같은 기능의 반복적 코드보다 (조금 더) 길고, 읽기 어렵고, 유지보수에도 좋지 않다.

    위 코드를 올바르게 수정 (스트림을 제대로 활용)

    Map<String, Long> freq;
    try (Stream<String> words = new Scanner(file).tokens()) {
        freq = words.collect(groupingBy(String::toLowerCase, counting()));
    }
    • 이전 코드와 같은 일을 하지만, 스트림 API를 제대로 사용했고, 짧고 명확하다.

    forEach 연산은 종단 연산 중 기능이 가장 적고 가장 ‘덜’ 스트림답다.

    forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자

    Collector

    수집기(collector)는 스트림을 사용하려면 꼭 배워야 하는 개념이다.

    • 수집기가 생성하는 객체는 일반적으로 컬렉션이며, 그래서 “collector”라는 이름을 쓴다.
    • 수집기를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다.

    수집기는 총 3가지 이다.

    1. toList() - 리스트
    2. toSet() - 집합
    3. toCollection(collectionFactory) - 프로그래머가 지정한 컬렉션 타입 반환

    Collectors의 나머지 36개 메서드

    이 중 대부분은 스트림을 맵으로 취합하는 기능으로, 진짜 컬렉션에 취합하는 것보다 훨씬 복잡하다.

     

    가장 간단한 맵 수집기 : toMap(keyMapper, valueMapper)

    • 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다.
    • 스트림의 각 원소가 고유한 키에 매핑되어 있을 때 적합하다.
    // toMap 수집기를 사용해 문자열을 열거 타입 상수에 매핑
    private static final Map<String, Operation> stringToEnum =
        Stream.of(values()).collect(
            toMap(Obejct::toString, e->e));

    인수 3개를 받는 toMap

    • 어떤 키와 그 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들때 유용하다.
    // 다양한 음악가의 앨범들을 담은 스트림을 가지고,
    // 음악가와 그 음악가의 베스트 앨범을 연관 짓는다.
    // 각 키와 해당 키의 특정 원소를 연관 짓는 맵을 생성하는 수집기
    Map<Artist, Album> topHits = albums.collect(
        toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));
    • 인수가 3개인 toMap은 충돌이 나면 마지막 값을 취하는(last-write-wins) 수집기를 만들 때도 유용하다.

    인수가 4개인 toMap

    • 네 번째 인수로 맵 팩터리를 받는다.
      • EnumMap이나 TreeMap처럼 원하는 특정 맵 구현체를 직접 지정할 수 있다.

    groupingBy

    • 입력으로 분류 함수를 받고 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.
    • 다중 정의되어 있다.

    첫 번째 가장 간단한 버전 : 분류함수 하나를 인수로 받아 맵을 반환

    words.collect(groupingBy(word -> alphabetize(word)))
    

    두 번째 버전 : 분류함수와 함께 다운스트림 수집기를 명시하면, groupingBy가 반환하는 수집기가 리스트 이외의 값을 갖는 맵을 생성할 수 있다.

    • 스트림 수집기의 역할 : 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 일
    • 가장 간단한 방법 : toSet()을 넘기는 것
      • groupingBy 는 원소들의 리스트가 아닌 집합을 값으로 갖는 맵을 만든다.

    세 번째 버전: 앞선 매개변수에 추가로 맵 팩터리를 추가로 를 지정하면, 맵과 그안에 담긴 컬렉션의 타입을 모두 지정할 수 있다

     

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


    Stream 과 Iterable

    원소 시퀀스를 반환할 때는 당연히 스트림을 사용해야한다.

    하지만, 스트림은 반복(iteration)을 지원하지 않는 문제가 있다.

    • Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함할 뿐아니라, Iterable 인터페이스가 정의한 방식대로 동작한다.
    • 그럼에도 for-each로 스트림을 반복할 수 없다.
      • Stream이 Iterable을 확장(extend)하지 않았기 때문

    어댑터 메서드를 사용하면 상황이 나아진다.

    • 자바는 이런 메서드를 지원하지 않지만 쉽게 만들어 낼 수 있다.
    // Stream<E>을 Iterable<E>로 중개해주는 어댑터
    public static<E> Iterable<E> iterableOf(Stream<E> stream) {
        return steam::iterator;
    }

    어댑터를 사용하면 어던 스트림도 for-each 문으로 반복할 수 있다.

    for(ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) {
      // 프로세스를 처리
    }

    API가 Iterable만 반환하면, 이를 스트림 파이프라인에서 처리하기 위한 어댑터를 구현하여 해결

    // Iterable<E>를 Stream<E>로 중개해주는 어댑터
    public static<E> Stream<E> streamOf(Iterable<E> iterable) {
        return StreamSupport.stream(iterable.spliterator(), false);
    }

    객체 시퀀스를 반환하는 메서드를 작성하는데, 이 메서드가 오직 스트림 파이프라인에서만 쓰일 걸 안다면 스트림을 반환

    반환된 객체들이 반복문에서만 쓰일 걸 안다면 Iterable을 반환

     

    공개 API를 작성할 때는 스트림 파이프라인을 사용하는 사람과 반복문에서 쓰려는 사람 모두를 배려해야 한다.

     

    Collection 인터페이스

    원소 시퀸스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.

    • Collection 인터페이스는 Iterable의 하위 타입이고, stream 메서드도 제공하니 반복과 스트림을 동시에 지원하기 때문

    Arrays 역시 Arrays.asList 와 Stream.of 메서드로 손쉽게 반복과 스트림을 지원할 수 있다.

    반환하는 시퀀스 크기가 메모리에 올려도 안전할만큼 작으면 표준 컬렉션 구현체를 반환하는 것이 최선이다.

    • 이러한 이유로 덩치 큰 시퀀스를 메모리에 올리면 안된다.
      • 이런 경우에 전용 컬렉션을 구현하는 방안을 검토

    AbstractCollection을 활용해 컬렉션 구현체를 작성할 때는 Iterable용 메서드 외에 2개만 더 구현하면 된다.

    • contains 와 size
    • contains 와 size를 구현하는 게 불가능할 때는 컬렉션보다는 스트림이나 Iterable을 반환하는 편이 낫다.

    결론

    원소 시퀀스를 반환하는 메서드를 작성시 Iterable 과 Stream 두 방식을 모두 작성

    하지만 가능하면 Iterable의 하위타입이면서 stream을 지원하는 Collection의 하위타입을 반환하자

    반환할 컬렉션의 원소 개수가 적다면 ArrayList와 같은 표준 컬렉션 구현체를 반환

    시퀀스 크기가 크다면 전용 컬렉션을 구현하는 것을 고민하라

    📌 스트림 병렬화는 주의해서 적용하라


    자바는 동시성 프로그래밍 측면에서 항상 앞서갔다.

    처음 릴리스된 1996년부터 스레드, 동기화, wait/notify를 지원했다.

    자바 5부터 동시성 컬렉션은 java.util.concurrent 라이브러리와 실행자(Executor) 프레임워크를 지원했다.

    자바 7부터 고성능 병렬 분해(parallel decom-position) 프레임워크인 포크-조인(fork-join) 패키지를 추가했다.

    자바 8부터는 parallel 메서드만 한 번 호출하면 파이프라인을 ****병렬 실행할 수 있는 스트림을 지원했다.

     

    자바로 동시성 프로그래밍을 작성하기가 점점 쉬워지고는 있지만, 이를 올바르고 빠르게 작성하는 일은 여전히 어려운 작업이다.

    동시성 프로그래밍을 할 때는 안전성(safety)와 응답 가능(liveness) 상태를 유지하기 위해 애써야 하는데, 병렬 스트림 파이프라인 프로그래밍에서도 다를 바가 없다.

    // 스트림을 사용해 처음 20개의 메르센 소수를 생성하는 프로그램
    
    public static void main(String[] args) {
       primes().map(prime -> TWO.pow(prime.intValue()).subtract(ONE))
            .filter(mersenne -> mersenne.isProbablePrime(50))
            .limit(20)
            .forEach(System.out::println);
    }
        
    static Stream<BigInteger> primes() {
       return Stream.iterate(TWO, BigInteger::nextProbablePrime);
    }
    

    속도를 높이기 위해 스트림 파이프라인의 parallel()을 호출해보자

    primes().map(prime -> TWO.pow(prime.intValue()).subtract(ONE))
        .parallel()
        .filter(mersenne -> mersenne.isProbablePrime(50))
        .limit(20)
        .forEach(System.out::println);
    • 하지만, 위 코드는 아무것도 출력하지 않고 CPU는 90%나 잡아 먹는 상태가 무한히 계속된다.
    • 원인 : 스트림 라이브러리가 이 파이프라인을 병렬화하는 방법을 찾아내지 못했기 때문이다.

    환경이 아무리 좋더라도 데이터 소스가 Stream.iterater거나 중간 연산으로 limit를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.

     

    파이프라인 병렬화는 limit을 다룰 때 CPU 코어가 남는다면 원소를 몇 개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다고 가정한다.

     

    ⇒ 스트림 파이프라인을 마구잡이로 병렬화하면 안된다. 성능이 오히려 끔찍하게 나빠질 수 있다.

     

    대체로 스트림 소스가 ArrayListHashMapHashSetConcurrentHashMap의 인스턴스거나 배열int 범위long 범위일 때 병렬화의 효과가 가장 좋다.

    • 이 자료구조들은 모두 원소를 순차적으로 실행할 때의 참조 지역성이 뛰어나다.
    • 이웃한 원소의 참조들이 메모리에 연속해서 저장되어 있다는 뜻이다.

    참조 지역성

    • 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다ㅏ.
    • 참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 시간을 멍하니 보낸다.
    • 참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다.
      • 기본 타입 배열에는 데이터 자체가 메모리에 연속해서 저장된다.

    스트림 파이프라인의 종단 연산의 동작 방식 역시 병렬 수행 효율에 영향을 준다.

    • 종단 연산에서 수행하는 작업량이 파이프라인 전체 작업에서 상당한 비중을 차지하면서 순차적인 연산이라면 파이프라인 병렬 수행의 효과는 제한될 수 밖에 없다.

    스트림을 잘못 병렬화하면 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있다.

    스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한다.

    • 다른 최적화와 마찬가지로 변경 전 후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.

     

    참조 자료

    http://www.yes24.com/Product/Goods/65551284

    '📕 book > 이펙티브 자바' 카테고리의 다른 글

    6장 - 열거타입과 애너테이션  (0) 2022.11.04
    5장 - 제네릭  (0) 2022.11.01
    4장 - 클래스와 인터페이스  (0) 2022.11.01
    3장 - 모든 객체의 공통 메서드  (0) 2022.10.26
    2장 - 객체 생성과 파괴  (0) 2022.10.25

    댓글

Designed by Tistory.