티스토리 뷰

  • 아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라
  • 아이템 79. 과도한 동기화는 피하라
  • 아이템 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라
  • 아이템 81. wait와 notify보다는 동시성 유틸리티를 애용하라
  • 아이템 82. 스레드 안전성 수준을 문서화하라
  • 아이템 83. 지연 초기화는 신중히 사용하라
  • 아이템 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라

 

아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라

  • 쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다.
  • volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.
  • 프로그램이 잘못된 결과를 계산해내는 오류를 안정 실패(safety failure)라고 한다.
  • synchronized 한정자는 동시에 호출해도 서로 간섭하지 않으며 이전 호출이 변경한 값을 읽게 된다는 뜻이다. 메서드에 synchronized를 붙였다면 필드에서는 volatile을 제거해야 한다.
  • volatile은 동기화의 두 효과 중 통신 쪽만 지원하지만 java.util.concurrent.atomic 패키지는 원자성(배타적 실행)까지 지원한다.
  • 가장 좋은 방법은 애초에 가변 데이터를 공유하지 않는 것이다. 다시 말해 가변 데이터는 단일 스레드에서만 쓰도록 하자.

 

아이템 79. 과도한 동기화는 피하라

  • 과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 심지어 예측할 수 없는 동작을 낳기도 한다.
  • 응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다. 예를 들어 동기화된 영역 안에서는 재정의할 수 있는 메서드는 호출하면 안 되며, 클라이언트가 넘겨준 함수 객체를 호출해서도 안 된다.
  • 동기화 영역 바깥에서 호출되는 외계인 메서드를 열린 호출(open call)이라 한다. 열린 호출은 실패 방지 효과 외에도 동시성 효율을 크게 개선해준다.
  • 기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는 것이다. 락을 얻고, 공유 데이터를 검사하고, 필요하면 수정하고, 락을 놓는다.
  • 과도한 동기화가 초래하는 진짜 비용은 락을 얻는 데 드는 CPU 시간이 아니다. 바로 경쟁하느라 낭비하는 시간, 즉 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용이다. 가상머신의 코드 최적화를 제한한다는 점도 과도한 동기화의 또 다른 숨은 비용이다.
  • 가변 클래스를 작성하려거든 다음 두 선택지 중 하나를 따르자. 첫 번째, 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자. 두 번째, 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자.
  • 클래스를 내부에서 동기화하기로 했다면, 락 분할(lock splitting), 락 스트라이핑(lock striping), 비차단 동시성 제어(nonblocking concurrency control) 등 다양한 기법을 동원해 동시성을 높여줄 수 있다.
  • 여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기해야 한다.

 

아이템 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라

  • java.util.concurrent 패키지는 실행자 프레임워크(Executor Framework)라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.
ExecutorService exec = Executors.newSingleThreadExecutor(); // 작업 큐 생성
exec.execute(runnable); // 실행할 태스크를 넘김
exec.shutdown(); // 실행자 종료
  • 큐를 둘 이상의 스레드가 처리하게 하고 싶다면 간단히 다른 정적 팩터리를 이용하여 다른 종류의 실행자 서비스(스레드 풀)를 생성하면 된다. 필요한 실행자 대부분은 java.util.concurrent.Executors의 정적 팩터리를 이용해 생성할 수 있을 것이다.
  • 평범하지 않은 실행자를 원한다면 ThreadPoolExecutor 클래스를 직접 사용해도 된다. 이 클래스로는 스레드 풀 동작을 결정하는 거의 모든 속성을 설정할 수 있다.
  • 작은 프로그램이나 가벼운 서버라면 Executors.newCachedThreadPool이 일반적으로 좋은 선택일 것이다.
  • 무거운 프로덕션 서버에서는 스레드 개수를 고정한 Executors.newFixedThreadPool을 선택하거나 완전히 통제할 수 있는 ThreadPoolExecutor를 직접 사용하는 편이 훨씬 낫다.
  • 실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다. 작업 단위를 나타내는 핵심 추상 개념이 태스크다. 태스크에는 두 가지가 있다. 바로 Runnable과 그 사촌인 Callable이다(Callable은 Runnable과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다).
  • 태스크를 수행하는 일반적인 메커니즘이 바로 실행자 서비스다.
실행자 서비스의 주요 기능
특정 태스크가 완료되기를 기다린다(get). 
태스크 모음 중 아무것 하나(invokeAny) 혹은 모든 태스크(invokeAll)가 완료되기를 기다린다. 
실행자 서비스가 종료하기를 기다린다(awaitTermination). 
완료된 태스크들의 결과를 차례로 받는다(ExecutorCompletionService). 
태스크를 특정 시간에 혹은 주기적으로 실행하게 한다(ScheduledThreadPoolExecutor).

 

아이템 81. wait와 notify보다는 동시성 유틸리티를 애용하라

  • java.util.concurrent의 고수준 유틸리티는 실행자 프레임워크, 동시성 컬렉션(concurrent collection), 동기화 장치(synchronizer) 총 세 범주로 나눌 수 있다.
  • 동시성 컬렉션은 List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다. 높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다. 따라서 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.
  • 동시성 컬렉션에서 동시성을 무력화하지 못하므로 여러 메서드를 원자적으로 묶어 호출하는 일 역시 불가능하다. 그래서 여러 기본 동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메서드들이 추가되었다.
  • ConcurrentHashMap은 동시성이 뛰어나며 속도도 무척 빠르다. 이제는 Collections.synchronizedMap보다는 ConcurrentHashMap을 사용하는 게 훨씬 좋다.
  • BlockingQueue는 큐가 비었다면 새로운 원소가 추가될 때까지 기다리는 특성이 있으므로 작업 큐(생산자-소비자 큐)로 쓰기에 적합하다.
  • 동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여, 서로 작업을 조율할 수 있게 해준다. 가장 자주 쓰이는 동기화 장치는 CountDownLatch와 Semaphore다. CyclicBarrier와 Exchanger는 그보다 덜 쓰인다. 그리고 가장 강력한 동기화 장치는 바로 Phaser다.
  • 시간 간격을 잴 때는 항상 System.currentTimeMillis가 아닌 System.nanoTime을 사용하자. System.nanoTime은 더 정확하고 정밀하며 시스템의 실시간 시계의 시간 보정에 영향받지 않는다.

 

아이템 82. 스레드 안전성 수준을 문서화하라

  • 멀티스레드 환경에서도 API를 안전하게 사용하게 하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야 한다.
구분 예시 스레드 안전성 애너테이션
불변(immutable) String, Long, BigInteger @Immutable
무조건적 스레드 안전(unconditionally thread-safe) AtomicLong, ConcurrentHashMap @ThreadSafe
조건부 스레드 안전(conditionally thread-safe) Collections.synchronized @ThreadSafe
스레드 안전하지 않음(not thread-safe) ArrayList, HashMap @NotThreadSafe
스레드 적대적(thread-hostile) - -
  • 조건부 스레드 안전한 클래스는 주의해서 문서화해야 한다. 어떤 순서로 호출할 때 외부 동기화가 필요한지, 그리고 그 순서로 호출하려면 어떤 락 혹은 (드물게) 락들을 얻어야 하는지 알려줘야 한다.
  • 반환 타입만으로는 명확히 알 수 없는 정적 팩터리라면 자신이 반환하는 객체의 스레드 안전성을 반드시 문서화해야 한다.
  • 서비스 거부 공격을 막으려면 synchronized 메서드 대신 비공개 락 객체를 사용해야 한다. (락 필드는 항상 final로 선언하라.)
  • 비공개 락 객체 관용구는 무조건적 스레드 안전 클래스에서만 사용할 수 있다.


아이템 83. 지연 초기화는 신중히 사용하라

  • 필요할 때까지는 하지 말라. 지연 초기화는 양날의 검이다. 클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만 그 대신 지연 초기화하는 필드에 접근하는 비용은 커진다.
  • 대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.
  • 지연 초기화가 초기화 순환성(initialization circularity)을 깨뜨릴 것 같으면 synchronized를 단 접근자를 사용하자.
  • 성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스(lazy initialization holder class) 관용구를 사용하자.
  • 성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중검사(double-check) 관용구를 사용하라. 필드가 초기화된 이후로는 동기화하지 않으므로 해당 필드는 반드시 volatile로 선언해야 한다.
  • 이따금 반복해서 초기화해도 상관없는 인스턴스 필드를 지연 초기화해야 하는 경우, 이중검사에서 두 번째 검사를 생략하는 단일검사(single-check) 관용구를 사용할 수 있다.


아이템 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라

  • 정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다.
  • 견고하고 빠릿하고 이식성 좋은 프로그램을 작성하는 가장 좋은 방법은 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다.
  • 실행 준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들자.
  • 스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안 된다.
  • 스레드는 절대 바쁜 대기(busy waiting) 상태가 되면 안 된다. 공유 객체의 상태가 바뀔 때까지 쉬지 않고 검사해서는 안 된다는 뜻이다.
  • 특정 스레드가 다른 스레드들과 비교해 CPU 시간을 충분히 얻지 못해서 간신히 돌아가는 프로그램을 보더라도 Thread.yield를 써서 문제를 고쳐보려는 유혹을 떨쳐내자.

 

댓글