-
Notifications
You must be signed in to change notification settings - Fork 0
Open
Labels
Description
📌 [아이템 81] wait과 notify보다는 동시성 유틸리티를 애용하라
✨ 핵심 내용
- 코드를 새로 작성하게 되면 wait와 notify를 쓸 이유가 거의(전혀) 없다.
- 유지보수를 위해 이들이 필요하다면 wait은 while 문 내에서 호출하고, notify 보다는 notifyAll을 사용하자. 혹시라도 notify를 사용한다면 응답 불가 상태에 빠지지 않도록 각별히 주의하자. (how?)
Answer By GPT
- wait()는 반드시 while 루프 안에서 사용하여 조건을 반복 확인하세요.
- notify()보다는 notifyAll()을 사용하여 데드락을 방지하세요.
- wait()와 notify()는 동일한 객체의 모니터를 사용해야 합니다.
- notify()를 호출하기 전에 반드시 조건을 변경하세요.
- 가능하면 java.util.concurrent의 고수준 동기화 도구를 사용하세요.
💡 새롭게 알게 된 점
📚 정리
-
wait과 notify는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자
-
java.util.concurrent의 고수준 유틸리티는 실행자 프레임워크, 동시성 컬렉션, 동기화 장치로 나눌 수 있다.- 동시성 컬렉션에서 동시성을 무력화 하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.
- 외부에서 동시성을 무력화 하지 못하므로 여러 메서드를 묶어 원자적으로 호출하는 행위가 불가하다
- 그렇기에 여러 기본 동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메서드 들이 추가되었습니다. 이들은 매우 유용해 java 8 부터 일반 컬렉션 인터페이스에도 디폴트 메서드 형태로 추가되었습니다.
Map.putIfAbset(key, value)메서드의 경우, 키에 매핑된 값이 없을때만 집어넣고, 기존 값이 있었을 경우 기존값, 없었을 경우 null을 반환합니다. 이 메서드 덕에 스레드 안전한 정규화 맵을 쉽게 구현할 수 있습니다.
private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>(); public static String intern(String s) { String previousValue = map.putIfAbset(s,s); return previousValue == null ? s : previousValue; } // ConcurrentHashMap은 get 과 같은 검색 기능에 최적화 돼 있다 필요할 때만 호출하면 더 빠르다. // 아래는 개선된 코드 public static string intern(String s) { String result = map.get(s); if(result == null) { result = map.putIfAbsent(s,s); if (result == null) return s; } return result; } // putIfAbsent는 쓰기 락을 동반하므로 성능에 영향을 줄 수 있다. 그러므로 get 으로 null인 경우에만 코드를 실행한다. //그렇다면 왜 get으로 null이 확인 되었는데도 put을 사용하지 않고 putIfAbsent를 사용하냐? 그것은 get 이후에 다른 스레드가 값을 업데이트 했을 수 있기 때문이다.
-
동시성 애플리케이션에서 동기화 된 컬렉션을 동시성 컬렉션으로 교체하는 것 만으로 성능이 개선된다.
-
컬렉션 인터페이스 중 일부는 작업이 성공적으로 완료될 때 까지 기다리도록 확장됐다.
- Queue를 extend 한 BlockingQueue의 take 메서드는 큐의 첫 원소를 꺼낸다. 이때 큐가 비어 있다면 새로운 원소가 추가될 때 까지 기다린다. 이런 특성덕에 BlockingQueue는 작업 큐 (생산자-소비자 큐)로 쓰기에 적합하다. (pub-sub 구조)
-
CountDownLatch와 Semaphore 가 가장 많이 쓰이고, CyclicBarrier와 Exchange는 그보다 덜 쓰인다. 가장 강력한 동기화 장치는 Phaser다.
// 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) { // 인터럽트가 일어나는 경우 스레드에 인터럽트를 전달하고 run 메서드에서 빠져나온다. Thread.currentThread().interrupt(); } finally { // 타이머에게 작업을 마쳤음을 알린다. done.countDown(); } }); } ready.await(); // ready.countDown()이 concurrency 번 일어 날때까지 await! long startNanos = System.nanoTime(); // 현재 시간 기록 start.countDown(); // start들은 CountDownLatch가 1 이므로 시작 신호를 준다. done.await(); // 다시 done.countDown()이 concurrency 번 일어날 때 까지 await! return System.nanoTime() - startNanos; // 시간 간격을 잴때는 nanoTime을 사용 하는것이 더 정확하고 시스템의 시간보정에 영향 받지 않는다. } // add-on: 전달된 executor 가 concurrency개 이상의 스레드를 생성할 수 있어야 한다. 풀 크기가 모자라는 경우 deadlock이 생길 수 있다.
- 위와 같은 CountDownLatch 3개 코드는 CyclicBarrier(혹은 Phaser) 인스턴스 하나로 대체할 수 있다.
- 새 코드라면 언제나 동시성 유틸리티를 사용해야한다. 레거시를 다룬다면 어쩔 수 없을때가 있다.
// wait 메서드는 스레드가 어떤 조건이 충족되기를 기다리게 할 때 사용한다. 락 객체의 wait 메서드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야 한다. synchronized (obj) { while (!조건충족) { obj.wait(); } } // wait 메서드를 사용할 때는 반드시 대기 반복문(wait loop) 관용구를 사용하라. 반복문 밖에서는 절대로 호출하지 말자. // 대기전에 조건을 검사해 조건이 충족 됐다면 wait을 건너 뛰게 하는 것은 응답 불가 상태를 예방하는 조치. 조건이 충족됐는데 스레드가 notify 메서드를 먼저 호출한 뒤 wait 하게 되면 그 스레드를 다시 깨울 수 있다고 보장할 수 없다. // 대기 후에 조건을 검사해 조건이 충족되지 않았다면 다시 대기하게 하는 것은 안전 실패를 막는 조치. 조건이 충족되지 않았는데 진행된다면 락이 보호하는 불변식을 깨뜨릴 위험이 있다. // 조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황 // 1. 스레드가 notify를 호출한 다음 대기중이던 스레드가 깨어나는 사이에 다른 스레드가 락을 얻어 해당 락이 보호하는 상태 // 2. 조건이 만족되지 않았음에도 다른 스레드가 실수/악의적으로 notify 호출 (공개된 객체를 락으로 사용해 대기하는 클래스는 이런 위험에 노출 / 외부에 노출된 객체의 동기화 된 메서드 안에서 호출하는 wait는 모두 이 문제에 영향) // 3. 깨우는 스레드는 지나치게 관대하다. 대기 중인 스레드 중 일부만 조건이 충족되어도 notifyAll을 호출해 모든 스레드를 깨울 수 있다. // 4. 대기중인 스레드가 (드물게) notify 없이도 깨어난다. 허위각성(spurious wakeup)
- 위와 관련하여 notify와 notifyAll 중 어떤걸 써야하나? (notify는 하나의 스레드, notifyAll은 모든 스레드를 깨운다.)
- while 반복문으로 사용했다면, 언제나 notifyAll을 사용하는게 합리적으로 안전한 조언
- 깨어나야 하는 모든 스레드가 깨어남을 보장하여 항상 정확한 결과를 얻을 수 있다.
- 다른 스레드까지 깨어난 경우 다시 조건 검사를 해 wait 할 것이다.
- 모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족 될 때마다 단 하나의 스레드만 혜택을 받을 수 있다면 notifyAll 대신 notify를 사용해 최적화 할 수도 있다.
- 그럼에도 notifyAll을 사용해야 하는 이유가 있다.
- 외부로 공개된 객체에 대해 실수/악의적 으로 notify를 호출하는 상황에 대비해 wait을 반복문 안에서 호출 했던 것 처럼, notify 대신 notifyAll을 사용하면 관련 없는 스레드가 실수/악의적으로 wait을 호출하는 공격으로부터 보호할 수 있다. 그런 스레드가 notify를 삼켜버린다면 깨어 났어야 할 필요한 스레드들이 영원히 대기하게 될 수 있다.
📢 댓글로 각자의 학습 내용을 공유해주세요!