핵심 요약
- 당연히 wait와 notify가 아닌 동시성 유틸리티를 사용하라.
- 레거시 코드를 다룰 경우, wait는 항상 표준 관용구에 따라 while 문 안에서 호출하라.
- 일반적으로 notify보다는 notifyAll을 사용해야 한다.
- notify을 사용할 경우 응답 불가 상태에 빠지지 않도록 주의하자.
동시성 유틸리티
: 자바 5 이후
- wait와 notify로 하드코딩해야 했던 전형적인 일들을 대신 해준다.
java.util.concurrent의 고수준 유틸리티는 세 범주로 나눌 수 있다.
1. 실행자 프레임워크(item 80)
2. 동시성 컬렉션(concurrent collection)
3. 동기화 장치(synchronizer)
동시성 컬렉션(concurrent collection)
- 표준 컬렉션 인터페이스인 List, Queue, Map과 같은 자료구조에 동시성을 추가한 고성능 컬렉션이다.
- 멀티스레드 환경에서 안전하게 작동하는 스레드 안전한 클래스이다.
- 상태 의존적 수정 메소드가 추가돼 여러 동작을 하나의 원자적 동작으로 묶어주었다.
- 이전에는 여러 메소드를 원자적으로 묶어 호출하는 것이 불가능하여, 여전히 일부 동시성 문제가 있었다.
- 자바 8에서는 이러한 메소드가 일반 컬렉션 인터페이스에도 디폴트 메소드 형태로 추가되었다
- ex. Map의 putIfAbsent(key, value) 메소드
- 주어진 키에 매핑된 값이 아직 없을 때 새 값을 넣고 null을 반환하며, 기존 값이 있다면 그 값을 반환한다.
- 이러한 메소드로 인해 스레드 안전한 정규화 맵을 쉽게 구현할 수 있다.
예시 1 - ConcurrentMap
- String pool에서 literal이 존재하는지 확인하고, 존재한다면 그 문자열을 반환, 존재하지 않는다면 String pool에 문자열을 등록하고 그 문자열을 반환한다.
// ConcurrentMap으로 구현한 동시성 정규화 맵 - 최적은 아니다.
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;
}
- ConcurrentHashMap은 동시성을 보장하니 내부적으로 synchronized를 사용한다.
- 그러나 get 같은 검색 기능에는 동기화를 사용하지 않도록 최적화되어 있다.
// ConcurrentMap으로 구현한 동시성 정규화 맵 - 더 빠르다!
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;
}
- get을 먼저 호출한 후 필요할 경우 putIfAbsent를 호출하도록 보완하였다.
- get을 호출한 후 동시성 문제로 putIfAbsent를 이용하여야 한다.
- Collections.synchronizedMap의 내부 메소드를 보면 모든 메소드에 뮤텍스 필드를 동기화해야 함을 알 수 있다.
- 심지어 Map 구현체의 메소드를 호출하는 것 ..
- ConcurrentHashMap은 동기화를 최대한 줄여 최적화를 이루어냈기 때문에 성능적으로도 우수하다.
[Effective-Java] Item 81. wait와 notify보다는 동시성 유틸리티를 애용하라
갖고 있던 고유 락을 해제하고, 스레드를 잠들게 하는 wait와 잠들어 있던 스레드 중 임의로 하나를 골라 깨우는 notify는 synchronized 블록이나 메소드에서 호출되어야하고, 올바르게 사용하기 까다
ktaes.tistory.com
예시 2 - BlockingQueue
- Queue를 확장한 인터페이스로, 작업이 성공적으로 완료될 때까지 기다리도록 확장되었다.
- take 메소드는 큐의 첫 번째 원소를 꺼내오는데, 만약 큐가 비어있다면 새로운 원소가 추가될 때까지 기다린다.
- 따라서 작업 큐(생산자-소비자 큐)로 쓰기에 적합하다.
- 대부분의 실행자 서비스 구현체(ex. ThreadPoolExecutor)가 활용한다.
동기화 장치(synchronizer)
- 동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여 서로 작업을 조율할 수 있게 해준다.
- ex. CountDownLatch, Semaphore, CyclicBarrier, Exchanger, Phaser
Java concurrent 패키지의 동기화 장치
들어가며... 스터디를 진행하면서 스터디원 중 한명이 멀티 스레드 작업을 하면서 테스트 코드 작성에 어려움을 겪었다고 했다. 그 때 CountDownLatch의 도움을 받아 테스트 코드를 작성하였다고 했
javabom.tistory.com
CountDownLatch
- 하나 이상의 스레드가 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다.
- 생성자에서는 int 값을 받으며, 이 값은 countDown() 메소드를 몇 번 호출해야 대기 중인 스레드를 깨우는지 결정한다.
예시 3 - CountDownLatch
: 어떤 동작들을 동시에 시작해 모두 완료하기까지 시간을 재는 간단한 프레임워크
// 동시 실행 시간을 재는 간단한 프레임워크
public class ConcurrentTimer {
private ConcurrentTimer() { } // 인스턴스 생성 불가
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;
}
}
코드 설명
더보기
- ready 래치
- 작업자 스레드들이 준비 완료된 상태임을 타이머 스레드에 알릴 때 사용한다.
- start 래치
- 작업자 스레드들이 실제 작업을 시작할 때까지 기다리기 위한 래치이다.
- done 래치
- 모든 작업자 스레드가 작업을 완료한 후에 타이머 스레드에 알릴 떄 사용한다.
- 동작 방식
- 각 작업자 스레드가 ready 래치를 열고 작업 준비를 마친다.
- 마지막 작업자 스레드가 ready 래치를 열면, 타이머 스레드가 시작 시간을 기록하고 모든 작업자 스레드가 작업을 시작할 수 있도록 start 래치를 연다.
- 각 작업자 스레드는 start 래치가 열릴 때까지 대기한 후 실제 작업을 수행한다.
- 작업을 모두 마친 작업자 스레드는 done 래치를 열고 작업을 완료했음을 타이머 스레드에게 알린다.
- 타이머 스레드는 done 래치가 열릴 때까지 대기한 후 종료 시간을 기록한다.
- start와 done 래치가 모두 열리면, 타이머 스레드는 시작 시간과 종료 시간을 비교하여 작업이 완료되는데 걸린 시간을 반환한다.
주의할 점
- ConcurrentTimer의 time 메서드에 전달되는 Executor는 concurrency 매개변수로 지정한 동시성 수준만큼의 스레드를 생성할 수 있어야 한다.
- 그렇지 않으면 time 메서드는 끝나지 않는다. 이는 ready 카운트다운이 충분히 되지 않아 작업자 스레드들이 시작되지 못하고 대기하는 상황을 말한다.
- 위의 경우를 스레드 기아 교착상태라고 한다. 여러 프로세스 또는 스레드가 공통 자원에 접근하려고 경쟁하다가 자원을 할당받지 못해 영원히 대기하는 상태다.
- 작업자 스레드에서 InterruptedException을 캐치하면, 그 스레드는 interrupt() 메소드를 호출하여 인터럽트를 복구하고 작업을 중단한다.
- 이를 통해 실행자(executor)는 스레드의 인터럽트 상태를 확인하고 적절한 처리를 할 수 있다.
- 시간 간격을 측정할 때는 System.currentTimeMillis() 대신 System.nanoTime()을 사용하는 것이 좋다.
- nanoTime()은 보다 정밀하고 정확한 시간 측정을 제공하며, 시스템의 실시간 시계의 변경에 영향을 받지 않는다.
- 만약 정확한 성능 측정이 필요하다면, 특수한 프레임워크인 JMH(Java Microbenchmarking Harness)를 사용하면 된다.
wait와 notify를 사용해야 할 경우
- 동시성 유틸리티를 사용하는 게 옳지만, 어쩔 수 없이 레거시 코드를 다뤄야 할 때도 있다.
// wait 메소드를 사용하는 표준 방식
synchronized (obj) {
while (<조건 미충족>) {
obj.wait(); // 락을 놓고, 깨어나면 다시 잡기
}
... // 조건 충족시 동작 수행
}
wait 메소드를 사용할 땐 대기 반복문(wait loop) 관용구를 사용하고 반복문 밖에서는 절대 호출하지 말자.
- 반복문은 wait 호출 전후로 조건이 만족하는지 검사하는 역할을 한다.
- 대기 전 조건을 검사해 조건이 이미 충족되었다면 wait을 건너뛰게 하는 것은 응답 불가 상태를 예방하는 조치다.
- 만약 조건이 충족되었는데 다른 스레드가 notify 혹은 notifyAll 메소드를 먼저 호출한 후 대기 상태로 빠지면 그 스레드는 다시 깨울 수 있다고 보장할 수 없다.
- 대기 후에 조건을 검사해 조건이 충족되지 않았다면 다시 대기하는 것은 안전 실패를 막는 조치다.
- 만약 조건이 충족되지 않았는데 스레드가 동작을 이어가면 락이 보호하는 불변식을 깰 위험이 있다.
- 조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황의 예시를 살펴보자.
- 스레드가 notify를 호출한 다음 대기 중이던 스레드가 깨어나는 사이에 다른 스레드가 락을 얻어 동기화 블럭 안의 상태를 변화시킬 수 있다.
- 조건이 만족되지 않았는데 다른 스레드가 실수 혹은 악의적으로 notify를 호출할 수 있다. 공개된 객체를 락으로 사용해 대기하는 클래스는 이런 위험에 노출되고, 외부에 노출된 객체의 동기화된 메소드 안에서 호출하는 wait는 모두 이 문제에 영향을 받는다.
- 깨우는 스레드의 관대함에, 대기 중인 스레드 중 일부만 조건이 충족되도 notifyAll을 호출해 모든 스레드를 깨울 수 있다.
- 대기중인 스레드가 notify 없이 깨어날 수 있는데 허위 각성(spurious wakeup) 현상이다.
notify vs notifyAll
- 일반적으로 notify보다는 norifyAll을 사용해야 한다.
- 이유 1. 대기 중인 스레드가 조건을 충족하든 말든, 모든 스레드가 깨어나서 조건 검사를 하게 된다.
- 이유 2. 공개된 객체를 락으로 사용하는 경우 다른 스레드에서 실수 혹은 악의적으로 wait을 호출할 수 있다.
- notify를 사용하면 이런 스레득 깨울 대상이 아닌 다른 스레드를 꺠우게 되어 문제가 발생할 수 있다.
- notifyAll을 사용하면 조건 검사를 통해 올바른 동작을 보장할 수 있다.
- 여러 스레드가 같은 조건을 기다리고, 조건이 충족될 때마다 단 하나의 스레드만 특정 작업을 수행해야 하는 경우
- notify를 사용해야 성능이 최적화될 수 있으나, 이유 2와 같이 notifyAll을 사용하여 데드락을 방지하는 것이 좋다.
'Language > Java' 카테고리의 다른 글
[effective java] 아이템 83. 지연 초기화는 신중히 사용하라. (0) | 2023.08.13 |
---|---|
[effective java] 아이템 82. 스레드 안전성 수준을 문서화하라. (0) | 2023.08.13 |
[effective java] 아이템 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라. (0) | 2023.08.13 |
[effective java] 아이템 79. 과도한 동기화는 피하라. (0) | 2023.08.13 |
[effective java] 아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라. (0) | 2023.08.13 |