스레드를 다루는 메서드 중 wait()과 notify()가 있으며 두개 모두 Object의 메서드다
wait()은 스레드가 일시정지 상태로 돌아가도록 한다.
notify()는 일시정지 상태인 스레드 중 하나를 실행대기 상태로 만든다.
notifyAll()은 일시정지 상태인 스레드를 모두 실행대기 상태로 만들어준다.
하지만 자바5부터 도입된 동시성 유틸리티가 이 작업들을 대신 해주기 때문에 사용할 일이 거의 없다.
concurrent 패키지
java.util.concurrent
패키지는 고수준의 동시성 유틸리티를 제공한다. 크게 세가지로 분류되며 실행자 프레임워크, 동시성 컬렉션, 동기화 장치로 나눌 수 있다. 이번 장에서는 동시성 컬렉션, 장치를 살펴본다 !
동시성 컬렉션
- 동시성 컬렉션은
List
,Queue,
Map
같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다.
- 높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다.
- 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.
동시성 컬렉션에서 동시성을 무력화하지 못하기 때문에 여러 메서드를 원자적으로 묶어 호출하는 일 역시 불가능하다. 그래서 여러 기본 동작을 하나의 원자적 동작으로 묶는 “상태 의존적 수정” 메서드들이 추가되었다.
동시성 컬렉션 - Map
HashMap
- 동시성 고려 안함
HashTable
- 동시성 고려를 하지만 메서드 단위이기 때문에 느릴 수 있다.

ConcurrentHashMap
- 동시성고려, 메서드를 통으로 동기화처리한 HashTable보다 빠르다.

[세개의 스레드를 가진 실행자를 통해 1000번의 멀티스레드 요청을 시도해보자.]

HashTable
과ConcurrentHashMap
은 동시성을 보장하기 때문에 1000을 항상 보장하지만HashMap
같은 경우는 스레드세이프하지 않기 때문에 size가 1000이 넘는 경우가 발생할 수 있다.- 내부를 보면 key는 1-1000까지 동일하지만 실제
Map
에key
를put
하기 전에size
를 증가시키기 때문에 이런 현상이 발생한다.
상태 의존적 수정 메서드
- 상태 의존적 수정 메서드들은 아주 유용해서 자바 9부터 일반 컬렉션 인터페이스에도 디폴트 메서드 형태로 추가되었다.
- 예로 Map의
putIfAbsent(key, value)
메서드가 있다. 이 메서드는 Map의 디폴트 메서드로 주어진 키에 매핑된 값이 아직 없을 때만 새 값을 집어넣는다. 그리고 기존 값이 있었다면 그 값을 반환하고 없었다면 null을 반환한다. - putIfAbsent는 get() → null check → put을 하나의 원자적 동작으로 묶은 메서드이다.
- 아래의 예시는 String.intern 메서드를 아래와 같이 흉내낼 수 있다.
- intern은 String 풀에 해당 문자열이 존재하면 반환하고 없다면 등록 후 반환하는 메서드이다.
private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>(); // CucurrentMap으로 구현한 동시성 정규화 맵 - 최적은 아님 ! public static String intern(String s) { String previousValue = map.putIfAbsent(s, s); return previousValue == null ? s : previousValue; } // ConcurrentHashMap은 get같은 검색 기능에 최적화되어 있다. 따라서 get을 먼저 호출하여 // 필요할 경우에만 putIfAbsent를 호출하도록 더 빠르게 개선시킬 수 있다. 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; }
[ConcurrentHashMap]
ConcurrentHashMap
은 동시성이 뛰어나며 속도도 매우 빠르다. Collections.synchronizedMap
보다는 ConcurrentHashMap
을 사용하는게 훨씬 좋다.동시성 컬렉션 - Queue
Blocking
되도록 구현된 컬렉션이 있다.BlockingQueue
인터페이스의 take
는 가져올 원소가 없다면 기다리도록 구현되어 있는데 아래 코드는 어떤 곳에서도 원소를 넣어주고 있지 않기 때문이다. 즉 생산자가 없다.

동기화 장치(synchronizer)
- 동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여, 서로 작업을 조율할 수 있도록 해준다.
- 가장 자주 쓰이는 동기화 장치로
CountDownLatch
와Semaphore
가 있으며CyclicBarrier
와Exchanger
도 있지만 덜 사용된다. 그리고 가장 강력한 동기화 장치로Phaser
가 있다.
CountDownLatch
- 카운트다운 래치는 일회성 장벽으로, 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다.
- 카운트다운 래치의 유일한 생성자는
int
값을 받으며 이 값이 래치의countDown
메서드를 몇번 호출해야 대기중인 스레드들을 깨우는지를 결정한다.

// 어떤 동작들을 동시에 시작하여 모두 완료하기까지 시간을 재는 간단한 프레임워크를 구축한다고 해보자. public class CountDownLatchTest { /** * * @param executor 동작들을 실행할 실행자 * @param concurrency 동작을 몇 개나 동시에 수행할 수 있는지인 동시성 수준 * @param action 진행할 동작 Runnable * @return 동작들이 모두 완료하기까지 걸린 시간 * @throws InterruptedException */ public static long time(Executor executor, int concurrency, Runnable action) throws InterruptedException { CountDownLatch ready = new CountDownLatch(3); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(3); 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; } public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(5); try { long result = time(executorService, 3, () -> System.out.println("작업시작")); System.out.println(result); } catch (Exception e) { e.printStackTrace(); } finally { executorService.shutdown(); } } }
코드설명
위의 프레임워크는
System.out.println(”작업시작”)
라는 작업을 세개의 스레드가 동시에 수행하게 된다.ready
카운트다운 래치가 countDown
을 세번 하고 나면(세개의 스레드가 거치고 나면) for문 아래의 await
을 지나칠 수 있고 그때 startNanos
를 측정한다. 세개의 스레드가 작업을 수행할 준비가 된 시점인 것이다.세개의 이하의 스레드는
start.await();
에서 기다리고 있는데, 시작 시점을 측정한 뒤 start.coundDown
을 호출하면 System.out.println(”작업시작”)
작업을 수행할 수 있다.모든 작업을 끝내면
Finally
블럭에서 done.countDown
을 호출하고 역시나 세개의 스레드가 모두 끝나고 나면 done.await()
을 지나쳐 최종 소모 시간을 출력한다.주의사항으로 스레드 풀에 스레드가 동시성 수준 이상 만큼 존재해야 한다 즉 위의 코드는 적어도 3개 이상의 스레드를 생성할 수 있도록 해야한다. 그렇지 않으면 await 상태에서 깨워줄 스레드가 존재하지 않기 때문에 영원히 끝나지 않게된다.
wait과 notify
wait
을 사용하는 표준 방법은 아래와 같다.wait
메서드를 사용할 때는 반드시 반복문 안에서 사용하도록 해야한다. 반복문으로 실행가능 조건을 검사함으로써 불필요한wait
호출을 막을 수 있다.- 만약 반복문 밖에
wait
이 존재한다면wait
한 스레드를 언제 다시notify
할지 보장할 수 없다. - 대기 후에 조건을 검사하여 조건이 충족되지 않았다면 다시 대기하게 하는 것은 안전실패를 방지하는 조치다. 만약 조건이 충족되지 않았는데 스레드가 동작을 이어가면 락이 보호하는 불변식을 깨뜨릴 위험이 있다.
wait
이후에 깨어나는 사이 다른 스레드가 락을 얻고 동기화 블럭 안의 상태를 변경할 수도 있음- 조건이 만족되지 않았는데 악의적인 스레드가
notifyAll
을 호출할 수도 있음 - 대기중인 스레드가
notify
없이도 깨어나는 경우가 드물게 있을 수 있음
synchronized (obj) { while(조건이 충족되지 않았다) { obj.wait(); // (락을 놓고, 깨어나면 다시 잡는다.) } ... // 조건이 충족됐을 때의 동작을 수행한다. }
notify
와notifyAll
두개 중 어떤 것을 사용할지에 대한 문제도 존재하지만notifyAll
을 사용하는 것이 더 합리적이고 안전하다.- 깨어나야 하는 모든 스레드가 깨어남을 보장하기 때문에 항상 정확한 결과를 얻을 것이다.
- 다른 스레드까지 깨어날 수 있지만 우리의 프로그램 정확성에는 영향을 주지 않을 것이다. 깨어난 스레드들은 기다리던 조건이 충족되었는지 확인하여 충족되지 않았다면 다시 대기상태로 돌아갈 것이기 때문이다.
결론
- 코드를 새로 작성한다면
wait
과notify
를 쓸 이유가 거의(어쩌면 전혀) 없다.
- 만약 이들을 사용하는 레거시 코드를 유지보수를 해야한다면
wait
은 항상 표준 관용구에 따라while
(반복문)안에서 호출하도록 하자.
- 일반적으로
notify
보다는notifyAll
을 사용해야 한다. 혹시라도notify
를 사용한다면 응답 불가 상태에 빠지지 않도록 각별히 주의하자.