요약

클래스 직렬화 형태를 기본으로 할지, 커스텀으로 할지 신중하게 결정해라.

기본 직렬화 형태는 객체를 직렬화한 결과가 해당 객체의 논리적 표현에 부합할 때(논리적 구조 = 물리적 구조)만 사용하고, 그렇지 않으면 커스텀 직렬화 형태를 고안하라.

직렬화 형태에 포함된 필드도 릴리즈 후 1) 마음대로 제거할 수 없고 2) 추후 직렬화 호환성 유지를 위해 코드의 복잡성과 성능에 부정적인 영향을 미친다. 따라서 직렬화 필드도 신중하게 선택하도록 하자.

 

기본 직렬화

객체의 물리적 표현과 논리적 내용이 같은 경우에 기본 직렬화 형태를 사용해도 된다. 기본 직렬화 형태가 적합하다고 결정했다고 하더라도, 불변식 보장과 보안을 위해 readObject 메서드 제공을 권장한다.

기본 직렬화 사용 가능 예시

public class Name implements Serializable {
    /**
     * 성. null이 아니어야 함
     * @serial
     */
    private final String lastName;

    /**
     * 이름. null이 아니어야 함
     * @serial
     */
    private final String firstName;

    /**
     * 중간이름. 중간이름이 없다면 null
     * @serial
     */
    private final String middleName;

	...
}

성명은 논리적으로 이름, 성, 중간이름이라는 3개의 문자열로 구성되며, 인스턴스 필드들은 이 논리적 구성요소를 정확히 반영했기에 기본 직렬화를 사용해도 무방하다.

  • private 필드임에도 주석이 포함된 이유
    • 위 코드의 lastName, firstName, middleName 필드는 클래스 직렬화 형태에 포함되는 공개 API에 포함되고, 공개 API는 모두 문서화 해야하기 때문이다
  • @serial 태그
    • 주석에 있는 이 태그는 private 필드의 설명을 API 문서에 포함하라고 자바독에 알려주는 역할을 한다
    • @serial 태그로 기술한 내용은 API 문서에서 직렬화 형태를 설명하는 특별한 페이지에 기록된다

 

기본 직렬화 형태에 적합하지 않은 예시 - StringList

public class StringList {
    private int size = 0;
    private Entry head = null;

    private static class Entry implements Serializable {
        String data;
        Entry next;
        Entry previous;
    }

    // ...
}
  • 논리적 : 일련의 문자열을 표현
  • 물리적 : 문자열들을 이중 연결 리스트로 연결
  • 이 클래스에 기본 직렬화 형태를 사용하면 각 노드의 양방향 연결 정보를 포함해 모든 엔트리를 철두철미하게 기록한다

 

발생 가능한 문제 4가지

이 코드처럼 물리적 표현과 논리적 표현의 차이가 클 때 기본 직렬화를 형태를 사용시 발생하는 문제는 다음과 같다.

  1. 공개 API가 현재의 내부 표현 방식에 영구히 묶인다
    1. private 클래스인 StringList.Entry가 공개 API가 되는데, 다음 릴리즈에서 내부 표현 방식을 바꾸더라도 StringList 클래스는 여전히 연결 리스트로 표현된 입력도 처리할 수 있어야 한다.
    2. 즉, 연결 리스트를 더는 사용하지 않더라도 관련 코드를 절대 제거할 수 없고, 과거 버전에 종속된 코드를 작성할 수밖에 없게 된다.
  2. 너무 많은 공간을 차지할 수 있다
    1. 위의 직렬화 형태는 내부 구현 정보인 연결 리스트의 모든 엔트리와 연결 정보까지 포함했기에 직렬화와는 관련없는 정보를 저장하는데 저장 공간을 사용하게 된다.
  3. 시간이 너무 많이 걸릴 수 있다
    1. 직렬화 로직은 그래프를 직접 순회하며 객체 그래프의 위상에 관한 정보를 얻는다. 그렇기에 많은 시간이 들 수 있다.
  4. 스택 오버플로우를 일으킬 수 있다
    1. 기본 직렬화 과정은 객체 그래프를 재귀 순회하는데, 이 작업은 중간 정도 크기의 객체 그래프에서도 자칫 스택 오버플로우를 일으킬 수 있다.

StringList 보완

위의 문제를 해결하기 위한 방법으로 다음과 같은 방식을 사용하면 된다.

  • 리스트가 포함한 문자열의 개수를 적고
  • 그 뒤로 문자열들을 나열한다

→ StringList의 물리적인 상세 표현은 배제한 채 논리적인 구성만 담는 것이다.

public class StringList implements Serializable {
    // 모든 필드가 transient 되었다. 
    private transient int size = 0;
    private transient Entry head = null;
    private int num = 0;

    // 이제는 직렬화되지 않는다.
    private static class Entry {
        String data;
        Entry next;
        Entry previous;
    }

    public final void add(String s) {}

    /**
     * 이 {@code StringList} 인스턴스를 직렬화한다.
     *
     * @serialData 이 리스트의 크기(포함된 문자열의 개수)를 기록한 후 ({@code int}),
     * 이어서 모든 원소를(각각은 {@code String}) 순서대로 기록한다.
     */
    private void writeObject(ObjectOutputStream s) throws IOException {
        s.defaultWriteObject(); // not transient 필드 직렬화
        s.writeInt(size); // 리스트 크기 s에 write

        // 모든 원소를 올바른 순서로 기록한다.
        for (Entry e = head; e != null; e = e.next) {
            s.writeObject(e.data);
        }
    }

    private void readObject(ObjectInputStream s) throws IOException, ClassNotFoundException {
        s.defaultReadObject();
        int numElements = s.readInt();

        // 모든 원소를 읽어 이 리스트에 삽입한다.
        for (int i = 0; i < numElements; i++) {
            add((String) s.readObject());
        }
    }
    // ...
}
  • transient(일시적) 한정자 : 해당 인스턴스 필드가 기본 직렬화 형태에 포함되지 않는다는 표시
  • readObject, writeObject : 직렬화 형태를 처리
  • writeObject 주석
    • private 메서드는 직렬화 형태에 포함되는 공개 API에 속하며, 공개 API는 모두 문서화 해야한다.
  • @SerialData 태그
    • 자바독 유틸리티에게 이 내용을 직렬화 형태 페이지에 추가하도록 요청하는 역할

transient 한정자

기본 직렬화를 수용하든, 하지 않든 defaultWriteObject 메서드를 호출하면 transient로 선언하지 않은 모든 인스턴스 필드가 직렬화된다. 따라서 해당 객체의 논리적 상태와 무관한 필드라고 확신할 때만 transient 한정자를 생략하고, transient 사용이 가능하다면 다 transient를 사용하도록 하자.

  • transient 사용 예시
    • 다른 필드에서 유도되는 필드(ex. 캐시된 해시값)
    • JVM 실행시 마다 값이 달라지는 필드(ex. 네이티브 자료구조를 가리키는 long 필드)
    • 커스텀 직렬화 형태 사용시, 위의 StringList처럼 대부분 혹은 모든 인스턴스 필드를 transient로 선언해야 한다.
  • 기본 직렬화에서 transient 필드 사용시
    • 해당 필드를 역직렬화 할 때, 기본값으로 초기화된다.
      • 객체 참조 필드 : null
      • 숫자 기본 타입 필드(int, long) : 0
      • boolean : false
    • 기본값으로 초기화하면 안되는 경우
      1. readObject()에서 defaultReadObject를 호출
      2. 해당 필드를 원하는 값으로 복원 or 해당 값을 처음 사용할 때 초기화

defaultWriteObject, defaultReadObject

StringList의 모든 필드가 transient라고 해도, writeObject와 readObject는 각각 가장 먼저 defaultWriteObject, defaultReadObject를 호출한다. 이 호출은 무조건 선행되어야 하는 작업이다(직렬화 명세에서 요구하는 내용이다). 그 이유는 향후 릴리즈에서 transient가 아닌 인스턴스 필드가 추가되더라도, 상위/하위 모두 호환될 수 있기 때문이다.

 

 

해시테이블

위의 StringList의 기본 직렬화 형태는 유연성과 성능이 떨어질 뿐이지 객체를 직렬화한 후 역직렬화할 경우 원래 객체의 불변식까지 포함해 제대로 복원해낸다는 것을 보장할 수 있다. 하지만 그 불변식이 세부 구현에 따라 달라지는 객체에서는 이 정확성마저 깨질 수 있는데, 그 예시가 해시테이블이다.

해시테이블은 물리적으로는 키-값 엔트리들을 담은 해시 버킷을 차례로 나열한 형태이다. 어떤 엔트리를 어떤 버킷에 담을지는 키에서 구한 해시코드가 결정하는데, 해시코드 계산 방식은 구현에 따라 달라질 수 있다.

→ 해시테이블에 기본 직렬화를 사용하면 심각한 버그로 이어질 수 있다.

 

 

직렬버전 UID

어떤 직렬화 형태를 택하든, 직렬화 가능 클래스 모두에 직렬 버전 UID를 명시적으로 부여하자.

  • 이점
    • 직렬버전 UID가 일으키는 잠재적 호환성 문제가 사라짐
    • 약간의 성능 향상
      • 런타임에 UID를 생성하느라 복잡한 연산을 수행하기 때문
  • 선언
  • private static final long serialVersionUID = <무작위로 고른 long 값>;
  • 직렬버전 UID 후보값
    • 임의의 long 값(클래스 일련번호를 생성해주는 serialver 유틸리티, 아무값)
    • 꼭 고유값이 아니어도 된다

만약 직렬 버전 UID가 없는 기존 클래스를 구버전으로 직렬화된 인스턴스와 호환성을 유지한 채 수정하고 싶다면, 구 버전에서 사용한 자동 생성값을 그대로 사용해야 한다.

기존 버전 클래스와의 호환성을 끊고 싶다면, 단순히 직렬 버전 UID의 값을 변경해주면 된다. 이렇게 하면 기존 버전의 직렬화된 인스턴스를 역직렬화할 때, InvalidClassException이 던져질 것이다.

→ 구버전으로 직렬화된 인스턴스들과의 호환성을 끊으려는 경우를 제외하고는 직렬 버전 UID를 절대 수정하지 말자.

요약

프로그램의 성능을 운영체제의 스레드 스케줄러에 의존하면 특정 운영체제의 의존도가 높아지기에 견고성과 이식성이 떨어지는 프로그램을 만들 가능성이 높다.

따라서 되도록이면 스레드 스케줄러에 독립적인 프로그램을 개발하도록 하자!

 

OS에 덜 의존적인 프로그램 작성하는 법

 

1. 견고하고 빠릿하고 이식성 좋은 프로그램

  1. 실행 가능한 스레드의 평균 개수프로세서 수보다 지나치게 많아지지 않도록 하자
    1. 스레드 스케줄러가 고민할 거리가 줄어들기 때문
  2. 실행 준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들자.
    1. 이런 구조라면 스레드 스케줄링 정책이 아주 상이한 시스템에서도 동작이 크게 달라지지 않기 때문
    2. 전체 스레드, 실행 가능한 스레드, 대기 중인 스레드를 구분해야 한다.

 

2. 실행 가능한 스레드 수 적게 유지하는 기법

  1. 스레드가 작업 완료 후 다음 작업까지는 대기해야한다
    1. 스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안된다
  2. 스레드는 절대 바쁜 대기(busy waiting) 상태가 되면 안된다
    1. 공유 객체의 상태가 바뀔 때까지 쉬지 않고 검사해서는 안된다는 의미
    2. 스레드가 필요도 없이 실행 가능한 상태인 시스템의 예시
    3. 바쁜 대기의 취약점
      1. 스레드 스케줄러의 변덕에 취약
      2. 프로세서에 큰 부담을 제공해 유용한 작업이 실행될 기회를 박탈
    4. 바쁜 대기 예시
      1. 스레드 1000개를 만들어 자바의 CountDownLatch와 비교할 때 약 10배의 성능 차이가 발생했다.
public class SlowCountDownLatch {
    private int count;

    public SlowCountDownLatch(int count) {
        if (count < 0) {
            throw new IllegalArgumentException(count + " < 0");
        }
        this.count = count;
    }

    public void await() {
        while (true) {
            synchronized (this) {
                if (count == 0) {
                    return;
                }
            }
        }
    }

    public synchronized void countDown() {
        if (count != 0) {
            count--;
        }
    }
}

 

3. Thread.yield 사용을 지양하자

특정 스레드가 다른 스레드들과 비교해 CPU 시간을 충분히 얻지 못해서 간신히 돌아가는 프로그램을 보더라도 Thread.yield는 사용하지 말자.

  • Thread.yield 메서드
    • 양보 : 현재 실행 대기 중인 동등한 우선순위 이상의 다른 스레드에게 실행기회를 제공한다
    • 즉, 실행 중인 스레드를 **실행 대기 상태(Runnable)로 변경**하는 메서드
    • 스레드 실행되었는데 스레드의 실행이 잠시동안 무의미한 경우가 발생하는데, 이때 CPU 자원의 낭비가 발생할 수 있다
  • 이유
    • 문제를 개선할 수는 있지만, 이식성 면에서 좋은 선택이 아니다
    • 테스트를 할 수단도 없다
OS1 
우선순위 B(1) > A(2)

thread A 우선순위 2-> 실행
thread B 우선순위 1(더 높은 우선순위)-> 나 급해. 
문제 해결 : thread A -> (yield) -> thread B 실행 but 급한 불만 끈 상태, 본질적인 문제 해결 x
thread A 실행된 이유 o. 실행이 완료가 되어야 하는데, 갑자기 대기 상태.

OS2
우선순위 A > B -> 의도대로 동작하지 않음!
의도 : B > A
  •  대안
    • 애플리케이션 구조를 바꿔 동시에 실행 가능한 스레드 수가 적어지도록 조치하자

 

4. 스레드 우선순위 사용을 지양하자

스레드 몇 개의 우선순위를 조율해서 애플리케이션의 반응 속도를 높이는 것도 타당할 수 있지만, 이것이 적합한 상황은 드물고 이식성이 떨어진다.

이미 잘 동작하는 프로그램의 서비스 품질 향상을 위해 드물게 사용될 수는 있지만, 간신히 동작하는 프로그램을 고치는 용도로 사용해서는 절대 안된다.

요약

모든 클래스가 자신의 스레드 안전성 정보를 명확히 문서화해야 한다.

  • 클라이언트에게 스레드 안전성 정보 전달하는 방법
    1. 주석으로 명확히 설명
    2. 스레드 안전성 애너테이션(ex. @ThreadSafe)
  • 무조건적 스레드 안전 클래스 작성시 synchronized 메서드가 아닌 비공개 락 객체를 사용하자
    • 클라이언트/하위 클래스에서 동기화 메커니즘 방해를 예방할 수 있기 때문이다.
  • 조건부 스레드 안전 클래스 사용시 호출 메서드 순서에 따른 외부 동기화 및 락 종류를 알려줘야 한다

 

개요

한 메서드를 여러 스레드가 동시에 호출할 때, 그 메서드가 어떻게 동작하는지를 알리는 것은 클래스와 클라이언트 사이의 중요한 계약과 같다. 이를 하지 않으면 클라이언트는 나름의 가정을 해야하고, 그 가정이 틀린다면 프로그램을 충분히 동기화 하지 못하거나 지나치게 동기화를 한 상태가 될 것이다. 이는 심각한 오류로 이어질 수 있다.

synchronized 한정자는 문서화와 관련이 없다

이유는 다음과 같다.

  1. 자바독이 기본 옵션에서 생성한 API 문서에는 synchronized 한정자가 포함되지 않는다
    1. 메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐, API에 속하지 않기 때문이다. 따라서 이것만으로는 그 메서드가 스레드 안전하다고 믿기 어렵다
  2. 스레드 안전성에도 수준이 나뉜다
    1. 스레드 안전성은 모 아니면 도가 아니라 다양한 수준을 가지고 있기 때문이다

 

단계별 스레드 안전성

멀티스레드 환경에서도 API를 안전하게 사용하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야한다. 단계별 스레드 안전성은 다음과 같고, 스레드 안전성의 내림차순 순서로 정렬했다.

1. 불변(immutable)

불변 클래스 인스턴스는 마치 상수와 같아서 외부 동기화가 필요없다.

  • 예시
    • String, Long, BigInteger

2. 무조건적 스레드 안전(unconditionally thread-safe)

이 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화 없이 동시에 사용해도 안전하다.

  • 예시
    • AtomicLong, ConcurrentHashMap

3. 조건부 스레드 안전(conditionally thread-safe)

무조건적 스레드 안전과 같으나, 일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다.

  • 예시
    • Collections.synchronized 래퍼 메서드가 반환한 컬렉션들
      • 이 컬렉션들이 반환한 반복자는 외부에서 동기화해야 한다
  • 해당 수준에서 클라이언트에게 명시적으로 알려줘야하는 정보
    1. 메서드 호출 순서에 따라 요구되는 외부 동기화 종류
    2. 그때 얻어야 하는 락 종류
  • Collections.synchronizedMap
  • // synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 // 반드시 그 맵을 락으로 사용해 수동으로 동기화 하라 * <pre> * Map m = Collections.synchronizedMap(new HashMap()); * ... * Set s = m.keySet(); // 동기화 블록 밖에 있어도 된다. * ... * synchronized (m) { // s가 아닌 m을 사용해 동기화해야 한다! * Iterator i = s.iterator(); // 동기화 블럭에 있어야 한다. * while (i.hasNext()) * foo(i.next()); * } * </pre> // 이대로 따르지 않으면 동작을 예측할 수 없다

4. 스레드 안전하지 않음(not thread-safe)

스레드 안전하지 않은 클래스의 인스턴스는 수정될 수 있고, 동시에 사용하려면 각각의 메서드 호출을 클라이언트가 선택한 외부 동기화 메커니즘으로 감싸야 한다.

  • 예시
    • ArrayList, HashMap 같은 기본 컬렉션

5. 스레드 적대적(thread-hostile)

이 클래스의 인스턴스는 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다. 이 수준의 클래스는 일반적으로 정적 데이터를 아무 동기화 없이 수정한다.

  • 참고
    • 스레드 적대적으로 밝혀진 클래스나 메서드는 일반적으로 문제를 고쳐 재배포하거나 사용 자제(deprecated) API로 지정한다

 

문서화 규칙

  • 클래스의 스레드 안전성은 보통 클래스의 문서화 주석에 기재하자
  • 독특한 특성의 메서드라면 해당 메서드의 주석에 기재하자
  • 열거 타입은 굳이 불변이라고 쓰지 않아도 된다
  • 반환 타입만으로는 명확히 알 수 없는 정적 팩터리라면 자신이 반환하는 객체의 스레드 안전성을 반드시 문서화하자(예시: Collections.synchronizedMap)

 

공개 락

클래스가 외부에서 사용할 수 있는 락을 제공할 때의 장점과 단점은 다음과 같다.

공개 락 중에 하나가 synchronized 붙인것

장점

  • 클라이언트에서 일련의 메서드 호출을 원자적으로 수행 가능

단점

  • 내부에서 처리하는 고성능 동시성 제어 메커니즘과 혼용할 수 없음
    • ConcurrentHashMap 같은 동시성 컬렉션과는 함께 사용하지 못함
  • 서비스 공격(denial-of-service attack)이 발생 가능
    • 서비스 거부 공격을 막으려면 synchronized 메서드 대신 비공개 락 객체를 사용해야 한다.

 

비공개 락

  • 비공개 락 객체 관용구
    • 비공개 락 객체는 클래스 바깥에서는 볼 수 없으니 클라이언트가 그 객체의 동기화에 관여할 수 없어 서비스 거부 공격을 막아줄 수 있다
  • private final Object lock = new Object(); public void foo() { synchronized(lock) { ... } }

설명

  1. 무조건적 스레드 안전 클래스에서만 사용 가능
    1. synchronized 메서드 보다는 비공개 락 객체를 사용하자
  2. 조건부 스레드 안전 클래스에서는 사용 불가능
    1. 해당 클래스에서는 특정 호출 순서에 필요한 락이 무엇인지를 클라이언트에게 알려줘야 하기 때문이다
  3. 락 필드는 final로
    1. 락 필드의 변경 가능성을 최소화해 락이 교체되었을 때 발생할 문제를 미리 예방할 수 있다
  4. 상속용 클래스
    1. 상속용 클래스에서 자신의 인스턴스를 락으로 사용한다면, 하위 클래스는 쉽게 또 의도치 않게 기반 클래스의 동작을 방해할 수 있다. (반대도 가능)
    2. 따라서 이 경우에도 비공개 락을 사용해 하위 클래스에서 동기화 메커니즘을 깨뜨리는 걸 예방할 수 있다

요약

java.util.concurrent 패키지를 활용해 실행자 프레임워크를 사용하자

 

1. java.util.concurrent 패키지

이 패키지는 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다. 이 프레임워크를 사용하면 간단한 코드로 작업 큐 관련 작업을 수행할 수 있다.

실행자 코드

// 작업 큐 생성
ExecutorService exec = Executors.newSingleThreadExecutor();

// 실행자에게 태스크 넘기기
exec.execute(runnable);

// 실행자 종료
exec.shutdown();

실행자 서비스의 주요 기능들

  • get() : 특정 태스크가 완료되기를 기다린다
  • invokeAny() : 태스크 모음 중 아무것 하나가 완료가 되기를 기다린다
  • invokeAll() : 태스크 모음 중 모든 태스크가 완료되기를 기다린다
  • awaitTermination() : 실행자 서비스가 종료하기를 기다린다
  • ExecutorCompletionService : 완료된 태스크들의 결과를 차례로 받는다
  • ScheduledThreadPoolExecutor : 태스크를 특정 시간 혹은 주기적으로 실행하게 한다

java.util.concurrent.Executors

위 패키지의 정적 팩터리들을 이용하면 필요한 대부분의 실행자를 생성할 수 있다. 이 정적 팩터리로 실행자 서비스(스레드 풀)를 생성하면 스레드 개수도 유동적으로 설정할 수 있고, 큐를 둘 이상의 스레드 처리할 수 있게 설정할 수 있다.

서버 크기에 맞는 스레드 풀

CachedThreadPool(Executors.newCachedThreadPool)에서는 요청 받은 태스크들이 큐에 쌓이지 않고 즉시 스레드에 위임돼 실행한다. 만약 가용한 스레드가 없다면 새로 하나 생성한다. 그렇기에 이 스레드 풀은 작은 프로그램이나 가벼운 서버에서만 사용하는 것이 좋다. 무거운 프로덕션 서버에서는 사용하면 안된다. 만약 무거운 프로덕션 서버에서 이 스레드 풀을 사용한다면 CPU 사용률이 100%으로 치닫고, 새로운 태스크가 도착하는 족족 또 다른 스레드를 생성해 상황을 더욱 악화시킬 수 있기 때문이다.

따라서 무거운 프로덕션 서버에서는 스레드 개수를 고정한 Executors.newFixedThreadPool을 선택하거나 완전히 통제할 수 있는 ThreadPoolExecutor를 직접 사용하는 편이 훨씬 낫다.

 

2. 태스크

실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다. 이때 작업 단위를 나타내는 핵심 추상 개념이 태스크이다.

Runnable, Callable

태스크에는 Runnable과 Callable이 있다.

실행자 서비스가 이런 태스크를 수행하는 일반적인 메커니즘이다. 실행자 프레임워크가 작업 수행을 담당해준다.

 

3. 포크-조인 태스크

자바 7부터 실행자 프레임워크는 포크-조인 태스크를 지원하도록 확장되었다.

  • 실행 과정
    • 포크-조인 태스크는 작은 하위 태스크로 나뉠 수 있다
    • 포크-조인 풀이라는 특별한 실행자 서비스가 포크-조인 태스크를 실행
    • 포크조인 풀을 구성하는 스레드들이 이 태스크를 처리
      • 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수 있다.

→ 모든 스레드가 바쁘게 움직여 CPU를 최대한 활용해 높은 처리량과 낮은 지연시간을 달성한다.

병렬 스트림

이런 포크-조인 태스크를 직접 작성하고 튜닝하는 것은 어렵기에 자바의 병렬 스트림을 사용하면 손쉽게 이점을 얻을 수 있다. (포크-조인에 적합한 형태의 작업일 때)

+ Recent posts