[Effective Java] wait과 notify보다는 동시성 유틸리티를 애용하라

들어가며

동시성 컬렉션은 컬렉션 표준 인터페이스에 동시성을 부여한 컬렉션이다. 내부에서 동기화를 진행하기 때문에 동시성 컬렉션의 동시성 무효화는 불가능하며 외부에서 락을 사용하면 추가적으로 느려지게 된다. 동시성 컬렉션에서 동시성을 무력화하지 못하므로 여러 메소드를 원자적으로 묶어 호출하는 일 역시 불가능하다. 그렇기 때문에 여러 기본 동작을 하나의 원자적 동작으로 묶는 상태 의존적 수정 메소드들이 추가되었다. map의 putIfAbsent가 그 예이다. 다음 코드를 보자.

1
2
3
4
5
6
private static ConcurrentMap<String, String> map = new ConcurrentHashMap<>();

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

위 코드에서 putIfAbsent 덕분에 thread-safe한 정규화 맵을 구현할 수 있게 되었다. ConcurrentMap의 구현체가 HashMap이라는 특성에 기반하여 성능을 더 개선하면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
public static String intern(String s) {
String result = map.get(s); // 1
if(result == null) { // 2
result = map.putIfAbsent(s, s);
if(result == null) // 3
result = s;
}
}
return result;
}

위 코드를 단계별로 분석하면 다음과 같다.

  1. HashMap의 특성상 검색이 빠르기 때문에 지금 삽입할 수 있는 상태인지를 먼저 검사한다.(성능 개선)
  2. 삽입할 수 있는 상태라면 putIfAbsent 메소드로 삽입을 한다.(동시성 안전)
  3. null 검사를 추가적으로 수행하는 이유는 2번 블럭에서 동시에 여러 스레드에서 putIfAbsent를 호출하는 경우 하나의 스레드(삽입한 스레드)만이 result가 null이 되기 때문에 값을 교체해준다.

동시성 컬렉션이 나온 뒤로 동기화한 컬렉션은 쓰이지 않게 되었다.

컬렉션 인터페이스중 일부는 작업이 성공적으로 완료될 때 까지 기다리도록 확장되었다. BlockingQueue가 그 예다. BlockingQueue는 Producer-Consumer Queue로 쓰기에 적합하다.

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여 서로 작업을 조율할 수 있게 해준다. 가장 자주 쓰이는 장치는 CountDownLatch와 Semaphore다. CyclicBarrier와 Exchanger는 그보다 덜 쓰이고 가장 강력한 동기화 장치는 Phaser다.

카운트 다운 래치는 일회성 장벽으로 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때 까지 기다린다. 주로 여러 스레드의 작업을 동시에 시작할 필요가 있을 때 사용한다. 매개변수로 동시성 수준을 받기 때문에 당연히 지정한 동시성 수준만큼 스레드를 생성하지 않으면 기아 교착상태에 빠지게 된다.

새로운 코드는 wait과 notify같은 저수준 제어보단 동시성 유틸리티를 써야한다. 하지만 저수준 제어를 사용해야할 때도 있다. 그럴때는 다음과 같이 사용하면 된다.

1
2
3
4
5
6
synchronized(obj) {
while(조건)
obj.wait();

// do something
}

wait 메소드를 사용할 때는 반드시 대기 반복문 관용구를 사용해야 한다. 반복문 밖에서는 절대로 호출해서는 안된다. 이는 응답 불가상태를 예방하는 조치이다. 대기 반복문을 사용하지 않는다면 생기는 문제를 두가지 상황을 가정해볼 수 있다.

첫째로 이미 조건이 충족된 상태에서 nofityAll을 호출하고 wait에 빠지는 상태다. 이 상태에서는 조건이 충족되었음에도 wait에 빠져 해당 스레드가 영원히 깨어나지 못할 수 있다.

두번째는 조건이 충족된 상태로 이미 대기중에 있다가 notify를 받고 일어나는 도중에 다른 스레드가 해당 조건을 다시 불가능으로 만들어버리는 경우다. 이는 안전 실패를 막는 조치다.

다음은 조건이 만족되지 않아도 스레드가 깨어날 수 있는 몇가지 상황이다.

  1. 스레드가 notify를 호출한 다음 대기중이던 스레드가 깨어나는 사이에 다른 스레드가 락을 얻어 그 락이 보호하는 상태를 변경한다.
  2. 조건이 만족되지 않았음에도 다른 스레드가 실수로 혹은 악의적으로 notify를 호출한다. 공개된 객체를 락으로 사용해 대기하는 클래스는 이런 위험에 노출된다. 외부에 노출된 객체의 동기화된 메소드 안에서 호출하는 wait는 모두 이 문제에 영향을 받는다.
  3. 깨우는 스레드는 지나치게 관대해서 대기중인 스레드 중 일부만 조건이 충족되어도 notifyAll을 호출해 모든 스레드를 깨울 수도 있다.
  4. 대기 중인 스레드가 드물에 notify 없이도 깨어나는 경우가 있다. 허위 각성(spurious wakeup)이라는 현상이다.

notify와 notifyAll중 무엇을 선택하는지에 대한 문제도 있다. 대부분 상황에서 반복문 관용구를 사용한 뒤에 notifyAll을 사용하는 편이 권장된다. notifyAll을 사용하게 된다면 악의적으로 wait을 호출하는 공격으로부터 보호할 수 있고 또한 스레드가 중요한 notify를 삼겨버린다면 꼭 일어났어야할 스레드들이 영원히 대기할 수 도 있기 때문이다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/23/Languages/Effective%20JAVA/item81/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.