peaches-book-study / effective-java

이펙티브 자바 3/E
0 stars 2 forks source link

Item 81. wait과 notify보다는 동시성 유틸리티를 애용하라 #70

Open youngkimi opened 1 month ago

youngkimi commented 1 month ago

Chapter : 11. 동시성

Item : 81. wait과 notify보다는 동시성 유틸리티를 애용하라

Assignee : youngkimi


🍑 서론

이 책의 초판 item 50에서는 wait과 notify를 올바르게 사용하는 방법을 안내한다. 해당 조언은 현재도 유효하며, 후술하겠지만 현재는 중요도가 예전같지 않다. Java 5에서 도입된 고수준의 동시성 유틸리티가 이전에 wait, notify로 하드코딩해야 했던 부분을 많이 처리해주기 때문이다. wait과 notify는 올바르게 사용하기 아주 까다로우니 고수준 동시성 유틸리티를 사용하자

java.util.concurrent의 고수준 유틸리티는 세 범주로 나눌 수 있다.

  1. 실행자 프레임워크
  2. 동시성 컬렉션 (concurrent collection)
  3. 동기화 장치 (synchronizer)

실행자 프레임워크는 item 80에서 가볍게 살펴보았고, 동시성 컬렉션과 동기화 장치를 살펴보자.

🍑 본론

동시성 컬렉션은 List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다. 높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다(item 79). 따라서 동시성 컬렉션에서 동시성을 무력화하는 것은 불가능하고, 외부에서 락을 추가로 사용하면 속도가 오히려 느려진다.

동시성 컬렉션에서 동시성을 무력화하는 것은 불가능하기 때문에 여러 메서드를 원자적으로 묶어 호출하는 것 또한 불가능하다. 그래서 여러 기본 동작을 하나의 원자적 동작으로 묶는 상태 의존적 수정 메서드들이 추가되었다.

이 메서드들은 아주 유용해서 Java 8에서는 일반 컬렉션 인터페이스에도 디폴트 메서드(item 21) 형태로 추가되었다.

Map의 putIfAbsent(key, value)는 주어진 키에 매핑된 값이 존재하지 않을때에만 새로운 값을 매핑한다. 기존 값이 있었다면 해당 값을 반환하고 없었다면 null을 반환한다. 이 메서드 덕분에 안전환 정규화 맵을 쉽게 구현할 수 있다. 다음은 String.intern의 동작을 흉내 내어 구현한 메서드이다.

String.intern이란?

JVM이 관리하는 문자열 풀에서 해당 문자열을 조회하여 존재하는 경우에는 해당 문자열 반환, 존재하지 않으면 저장 후 해당 문자열을 반환하는 메서드. 해당 문자열과 같은 동일한 인스턴스 하나만을 사용하기 위함이다. 참고로 native 메서드이다. 자세한 구현은 아래 참조. 
private static final ConcurrentMap<String, String> map = 
    new ConcurrentHashMap<>();

public static String intern(String s) {
    String previousValue = map.putIfAbsent(s, s);
    return previousValue == null ? s : previousValue;
}

ConcurrentMap으로 구현한 동시성 정규화 맵. 흉내낸 것에 불과하다. 최적이 아니다.

최적화해보자. ConcurrentHashMapget 같은 검색 기능에 최적화되었다. 따라서 get을 먼저 호출하여 필요할 때만 putIfAbsent를 호출하면 더 빠르다.

private static final ConcurrentMap<String, String> map = 
    new ConcurrentHashMap<>();

public static String intern(String s) {
    String result = map.get(s);

    if (result == null) {
        result = map.putIfAbsent(s, s);
        if (result == null) {
            result = s;
        }
    }

    return result;
}

더 빠르다! 굳이 put 대신 putIfAbsent를 사용하는 이유는 race condition을 방지하기 위함이다.

이건 현재 native로 구현된 String.intern보다 빠르다. (물론 String.intern에는 오래 실행되는 프로그램에 대해 메모리 누수를 방지하는 기술이 추가로 들어가 있다.) 동시성 컬렉션은 동시화한 컬렉션을 낡은 유산으로 만들었다. 대표적인 예로, Collections.synchronizedMap 보다는 ConcurrentHashMap을 사용하는 것이 더 좋다. 동기화된 맵을 동시성 맵으로 교체하는 것만으로도 동시성 어플리케이션의 성능은 극적으로 개선된다.

컬렉션 인터페이스 중 일부는 작업이 성공적으로 완료될 때까지 차단되도록 확장되었다. Queue를 확장한 BlockingQueue에 추가된 메서드 중 take는 큐의 첫 원소를 꺼낸다. 만약 큐가 비었다면, 새로운 원소가 추가될 때까지 기다린다. 이러한 특성으로 작업 큐로 쓰기에 적절하다. 짐작하다시피 ThreadPoolExecutor를 포함한 대부분의 실행자 서비스 구현체에서 이 BlockingQueue를 사용한다.

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여 서로 작업을 조율할 수 있게 해준다. 자주 쓰이는 동기화 장치는 CountDownLatchSemaphore이다. 가장 강력한 동기화 장치는 Phaser이다.

CountDownLatch

CountDownLatch는 일회성 장벽(Latch; 걸쇠)으로, 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다. CountDownLatch의 유일한 생성자는 int 값을 받으며, 이 값이 래치의 countDown메서드를 몇 번 호출해야 대기 중인 스레드를 깨우는지 결정한다. 이걸 활용하면 유용한 기능을 쉽게 구현할 수 있다.

예를 들어, 어떤 동작들을 동시에 시작해 완료하기까지의 시간을 재는 프레임워크를 구축한다고 해보자. 이 프레임워크는 메서드 하나로 구성되며, 이 메서드는 동작들을 실행할 실행자와 동작을 몇 개나 수행할 수 있는지를 뜻하는 동시성 수준(concurrency)을 매개 변수로 받는다. 타이머 스레드가 시계를 누르기 전 모든 작업자 스레드들이 준비를 마치게 한다. 이후 타이머 스레드가 시계를 누르고 작업자 스레드는 각자 작업을 시작한다. 마지막 작업자 스레드가 동작을 마치자마자 타이머 스레드는 시계를 멈춘다. waitnotify를 사용하면 몹시 코드가 지저분하지만, CountDownLatch를 사용하면 직관적으로 구현할 수 있다.

public static long time(Executor executor, int concurrency,
                            Runnable action) throws InterruptedException {
    CountDownLatch ready = new CountDownLatch(concurrency);
    CountDownLatch start = new CountDownLatch(1);
    CountDownLatch done = new CountDownLatch(concurrency);

    for (int i = 0; i < concurrency; i++) {
        executor.execute(() -> {
            // 타이머에게 준비를 마쳤음을 알린다.
            ready.countDown();
            try {
                // 모든 작업자 스레드가 준비될 때까지 기다린다.
                start.await();
                action.run();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                // 타이머에게 작업을 마쳤음을 알린다.
                done.countDown();
            }
        });
    }

    ready.await(); // 모든 작업자가 준비될 때까지 기다린다.
    long startNanos = System.nanoTime();
    start.countDown(); // 작업자들을 깨운다.
    done.await(); // 모든 작업자가 일을 끝마치기를 기다린다.
    return System.nanoTime() - startNanos;
}

참 직관적이다.

사실 의미는 다음과 같다.

  1. ready 래치는 작업자 스레드들이 준비가 완료되면 값을 하나씩 깎는다(ready.countDown).
  2. 0이 되면 모든 작업자 스레드가 준비 완료 되었음을 알 수 있다(ready.await).
  3. 각 준비된 작업자 스레드는 start가 열리기를 기다린다(start.await).
  4. 시작 타이머를 누른다.
  5. start 값을 깎아 0으로 만든다(start.countDown). 시작한다.
  6. 작업이 끝나기를 기다린다(done.await). 각 작업을 완료하면 done 래치를 하나씩 깎는다(done.countDown).
  7. 종료 타이머를 누른다.

참고로 time 메서드에 넘겨진 실행자(executor)는 concurrency 매개변수로 지정한 동시성 수준 만큼의 스레드를 생성할 수 있어야 한다. 그렇지 못하면 스레드 기아 교착상태에 빠질 것이다.

시간을 잴 때는 System.currentTimeMillis가 아닌, System.nanoTime을 사용하자. 이게 더 정확하고, 정밀하고, 시스템의 시간 보정의 영향을 받지 않는다. 참고로 정밀한 시간 측정은 매우 어려워서, 1초 이하의 정밀한 시간 측정이라면 jmh같은 특수 프레임워크를 사용해라.

사실 위에서 사용한 세 개의 CountDownLatch는 하나의 CyclicBarrier(또는 Phaser) 인스턴스로 대체할 수 있다. 이렇게 하면 코드는 더 명료하겠지만 이해하기는 더 어려울 것이다.

새로운 코드라면 wait, notify가 아닌 동시성 유틸리티를 써라. 하지만 레거시 코드를 다룰 수도 있으므로 wait, notify에 대해 이해해야 한다.

wait은 스레드가 어떤 조건이 충족되기를 기다릴 때 사용한다. 락 객체의 wait 메서드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야 한다. 표준 방식은 다음과 같다.

synchronized (obj) {
    while (<충족 조건>)
        obj.wait(); // 락을 놓고 깨어나면 다시 잡는다. 

    // 조건이 충족된 후의 동작. 
}

wait 메서드 사용시에는 반드시 대기 반복문 관용구를 사용해라. 반복문 밖에서는 절대 호출하지 말자. 이 반복문은 wait 호출 전후로 조건이 만족하는지 검사하는 역할을 한다.

🍑 결론


Referenced by