맞는데 왜 틀릴까..?

Java/Effective Java

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

안도일 2023. 8. 20. 21:05

자바 5에서 도입된 고수준의 동시성 유틸리티가 이전의 wait와 notify로 하드코딩해야 했던 전형적인 일들을 대신 처리해 준다.

wait와 notify는 올바르게 사용하기 아주 까다로우니 고수준 동시성 유틸리티를 사용하자.

 

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

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

 

동시성 컬렉션

 

List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다.

높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다.

따라서 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.

 

 

상태 의존적 메서드

 

동시성 컬렉션에서 동시성을 무력화하지 못하므로 여러 메서드를 원자적으로 묶어 호출하는 일은 불가능하다.

따라서 여러 기본 동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메서드들이 추가되었다.

자바 8에서는 일반 컬렉션 인터페이스에도 디폴트 메서드 형태로 추가되었다.

 

 

ConcurrentMap으로 구현한 동시성 정규화 맵

 

 

  • ConccurentMap은 동시성이 뛰어나며 속도도 매우 빠르다. (저자의 컴퓨터에서 기존 String.intern 보다 6배 빠르게 실행되었다. )
  • Map의 putIfAbset(key, value) 메서드는 주어진 키에 매핑된 값이 아직 없을 때만 새 값을 넣는다.
  • 만약 기존 값이 있었다면 그 값을 반환하고, 없었다면 null을 반환한다
  • 이 putIfAbset 메서드 덕에 스레드 안전한 정규화 맵을 쉽게 구현할 수 있다.

 

원자적 동작

원자적 동작은 여러 개의 동작을 하나의 단일 연산으로 실행되도록 하는 것을 의미한다. 이는 다수의 스레드나 프로세스가 동시에 접근하여 변경할 때 데이터 일관성을 보장하기 위한 개념이다.
즉, 여러 개의 연속적인 동작이 있을 때, 이들을 모두 성공적으로 수행하거나 모두 수행하지 않는 것을 의미한다. 이렇게 원자적으로 묶인 동작은 다른 스레드들에 의해 중간에 중단되거나 변경되지 않는 것이 보장된다. 

 

 

Collections.synchronizedMap (동기화한 컬렉션) 보다는 ConcurrentHashMap (동시성 컬렉션)을 사용하는 게 훨씬 좋다.

동기화된 맵을 동시성 맵으로 교체하는 것만으로 동시성 애플리케이션의 성능은 극적으로 개선된다.

 

 

BlokingQueue

 

컬렉션 인터페이스 중 일부는 작업이 성공적으로 완료될 때까지 기다리도록 확장되었다.

  • Queue를 확장한 BlockingQueue에 추가된 메서드 중 take는 큐의 첫 원소를 꺼낸다.
  • 만약 큐가 비었다면 새로운 원소가 추가될 때까지 기다린다.

 

이러한 특성 덕에 BlockingQueue는 작업 큐로 쓰기에 적합하다.

  • 작업 큐 : 하나 이상의 생산자 스레드가 작업을 큐에 추가하고, 하나 이상의 소비자 스레드가 큐에 있는 작업을 꺼내 처리하는 형태다.
  • ThreadPoolExecutor를 포함한 대부분의 실행자 서비스 구현체에서 BlockingQueue를 사용한다.

 

 

 

동기화 장치

 

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여, 서로 작업을 조율할 수 있게 해 준다.

 

  • CountDownLatch와 Semaphore : 가장 자주 쓰이는 동기화 장치
  • CyclicBarrier와 Exchanger :  위 동기화 장치보다 덜 쓰인다
  • Phaser : 가장 강력한 동기화 장치

 

 

래치 : 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다.

CountDownLatch의 유일한 생성자는 int를 받으며, 이 값이 래치의 countDown 메서드를 몇 번 호출해야 대기 중인 스레드를 깨우는지 결정한다.

 

 

CountDownLatch 예시 - 어떤 동작을 동시에 시작해 모두 완료하기까지의 시간을 측정

 

 

  • 동시성 수준(concurrency) 매개변수 : 동작을 몇 개나 동시에 수항핼 수 있는지를 뜻함
  • 모든 작업자 스레드가 동작을 수행할 준비를 마치면 타이머 스레드가 시작 방아쇠를 당겨 작업자 스레드들이 일을 시작하게 한다.
  • 마지막 작업자 스레드가 동작을 마치자마자 타이머 스레드는 시계를 멈춘다.

 

ready 래치

  • 작업자 스레드들이 준비가 완료됐음을 타이머 스레드에 통지한다. 
  • 통지를 끝낸 작업자 스레드들은 두 번째 래치인 start가 열리기를 기다린다.

 

start 래치

  • 마지막 작업자 스레드가 ready.countDown을 호출하면 타이머 스레드가 시작 시각을 기록하고 start.countDown을 호출하여 기다리고 있던 작업자 스레드들을 깨운다.
  • 그 직후 타이머 스레드는 세 번째 래치인 done이 열리기를 기다린다.

 

done 래치

  • 마지막 남은 작업자 스레드가 동작을 마치고 done.countDown을 호출하면 열린다.
  • 타이머 스레드는 done 래치가 열리자마자 깨어나 종료 시각을 기록한다.

 

 

time 메서드에 넘겨진 실행자(executor)는 concurrency 매개변수로 지정한 동시성 수준만큼의 스레드를 생성할 수 있어야 한다.

  • 그렇지 않으면 메서드는 끝나지 않을 것이고 이런 상태를 스레드 기아 교착상태(thread starvation deadlock)라 한다.

 

시간 간격을 잴 때는 항상 System.currentTimeMillis가 아닌 System.nanoTime을 사용하자.

  • 더 정확하고 정밀하며 시스템의 실시간 시계의 시간 보정에 영향받지 않는다.

 

wait

 

새로운 코드라면 언제나 wait와 notify가 아닌 동시성 유틸리티를 써야 하지만 레거시 코드를 다뤄야 할 때도 있다.

 

wait 메서드 : 어떤 조건이 충족되기를 기다리게 할 때 사용

락 객체의 wait 메서드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야 한다.

 

wait를 사용하는 표준 방식

 

synchronized (obj) {
    while (조건이 충족되지 않았다) {
        obj.wait(); // 락을 놓고, 깨어나면 다시 잡는다.
    }

    ... // 조건이 충족됐을 때의 동작을 수행한다.
}

 

wait 메서드를 사용할 때는 반드시 대기 반복문 관용구를 사용하자. 반복문 밖에서는 절대로 호출하지 말자.

 

 

 

결론

 

  • wait와 notify를 직접 사용하는 것을 동시성 '어셈블리 언어'로 프로그래밍하는 것에 비유할 수 있다. - 하지 말란 소리임
  • java.util.conccurrent는 고수준 언어에 비유할 수 있다. - 이거 쓰란소리임
  • 진짜 만약 레거시 코드를 유지보수해야 한다면 wait는 항상 표준 관용구에 따라 while문 안에서 호출하자.