본문 바로가기

나(다)/책

이펙티브 자바 - 11장 : 동시성

반응형

동시성

스레드는 여러 활동을 동시에 수행할 수 있게 해주지만 단일 스레드 프로그래밍보다 동시성 프로그래밍이 어렵고 잘못될 수 있는 일이 늘어나고 문제를 재현하기도 어려워진다.

하지만 오늘날 어디서나 쓰이는 멀티코어 프로세서의 힘을 제대로 활용하려면 반드시 내 것으로 만들어야 하는 기술이다.

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

동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.

synchronized 키워드를 이용하면 매서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다.

  • 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시키고 동기화를 제대로 사용하면 어떤 메서드도 해당 객체가 일관되지 않은 순간을 볼 수 없을 것이다.
  • 동기화는 일관성이 깨진 상태를 볼 수 없게 하는 것과 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최정 결과를 보게 해준다.
  • "성능을 높이려면 원자적 데이터를 읽고 쓸 때는 동기화하지 말아야겠다"는 생각은 위험한 발생이다.
  • 자바 언어 명세는 스레드가 필드를 읽을 때 항상 '수정이 완전히 반영된' 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다.

Thread.stop은 사용하지 말자

  • 다른 스레드를 멈추는 작업은 권장되지 않으며 자바 11에서 Thread.stop(Throwable obj)는 제거되었고 Thread.stop()은 아직 남아있다.

다른 스레드를 멈추는 올바른 방법

스레드는 자신의 bollean 필드를 폴링하면서 해당 값이 true가 되면 멈춘다.

  • false로 초기화해놓고, 다른 스레드에서 이 스레드를 멈추고자 할 때 true로 변경한다.
  • boolean 필드를 읽고 쓰는 작업은 원자적이라 어떤 프로그래머는 이런 필드에 접근할 때 동기화를 제거하기도 한다.
public class Item78 { // 잘못된 코드
    private static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException{
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}
  • 1초 후에 종료되지 않고 영원히 실행되는데 문제는 메인 스레드가 수정한 값을 백그라운드 스레드가 언제쯤에 보게 될지 보증할 수 없다.
public class Item78 { // 동기화
    private static boolean stopRequested;

    private static synchronized void requestStop() {
        stopRequested = true;
    }

    private static synchronized boolean stopRequested() {
        return stopRequested;
    }

    public static void main(String[] args) throws InterruptedException{
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
//            while (!stopRequested)
            while (!stopRequested())
                i++;
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
//        stopRequested = true;
                requestStop();
    }
}
  • 쓰기와 읽기 모두 동기화되지 않으면 동작을 보장하지 않는다.
  • 동기화는 배타적 수행과 스레드 간 통신이라는 두 가지 기능을 수행하는데 위 코드는 통신 목적으로만 사용되었다.
public class Item78 { // volatole 필드를 사용해 스레드가 정상 종료한다.
    private static volatile boolean stopRequested;

    public static void main(String[] args) throws InterruptedException{
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested)
                i++;
        });
        backgroundThread.start();
        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}
  • 반복만에서 매번 동기화하는 비용은 크지 않지만 속도가 빠른 방법으로 volatile한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.
// 잘못된 코드 - 동기화가 필요하다.
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
    return nextSerialNumber++;
}
  • 매번 고유한 값을 반환할 의도로 만들어졌지만 증가 연산자로 인해 문제가 발생한다.
  • 코드상으로는 하나지만 실제로는 nextSerialNumber 필드에 두번 접근하게 되어 먼저 값을 읽고, 1증가한 새로운 값을 저장한다.
  • 두번 째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫 번째 스레드와 똑같은 값을 돌려받게 된다.
  • 프로그램이 잘못된 결과를 게산해내는 이런 오류를 안전 실패라고 한다.
  • synchronized를 붙이면 해결 된다.

java.util.concurrent.atomic 패키지에는 락 없이도(lock-free) 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨 있다.

private static final AtomicLong nextSerialNum = new AtomicLong();

public static long generateSerialNumber() {
    return nextSerialNum.getAndIncrement();
}

가변 데이터는 단일 스레드에서만 쓰도록 하자.

  • 불변 데이터만 공유하거나 아무것도 공유하지 말자.

한 스레드가 데이터를 다 수정한 후 다른 스레드에 공유할 때는 해당 객체에서 공유하는 부분만 동기화해도 된다.

  • 해당 객체를 다시 수정할 일이 생기기 전까지 다른 스레드들은 동기화 없이 자유롭게 값을 읽어갈 수 있고 이런 객체를 사실상 불변이라 하고 다른 스레드에 이런 객체를 건네는 행위를 안전 발행이라고 한다.
  • 클래스 초기화 과정에서 객체를 정적 필드, volatile 필드, final 필드 혹은 보통의 락을 통해 접근하는 필드에 저장해도 된다.

한 객체가 일관된 상태를 가지고 생성되고(아이템17), 동시성 컬렉션(아이템81)

여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화 해야 한다.
동기화하지 않으면 한 스레드가 수행한 변경을 다른 스레드가 보지 못할 수도 있다.
공유되는 가변 데이터를 동기화하는데 실패하면 응답 불가 상태에 빠지거나 안전 실패로 이어질 수 있다. 이는 디버깅 난이도가 가장 높은 문제에 속한다.
간헐적이거나 특정 타이밍에만 발생할 수도 있고, VM에 따라 현상이 달라지기도 한다. 배타적 실행은 필요 없고 스레드끼리의 통신만 필요하다면 volatile 한정자만으로 동기화할 수 있다. 다만 올바로 사용하기가 까다롭다.


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

과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 심지어 예측할 수도 없는 동작을 낳기도 한다.

응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다.

  • 동기화 된 영역 안에서 재정의할 수 있는 메서드는 호출하면 안 되며, 클러이언트가 넘겨준 함수 객체를 호출해서도 안 된다.
  • 동기화된 영역을 포함한 클래스 고나점에서는 이런 메서드는 모두 바깥 세성에서 온 외계인이다.
  • 외계인 메서드가 하는 일에 따라 동기화된 영역은 예외를 일으키거나, 교착 상태에 빠지거나, 데이터를 훼손할 수도 있다.
set.addObserver(new SetObserver<>() {
    public void added(ObservableSet<Integer> s, Integer e) {
        System.out.println(e);
        if ( e == 23)
            s.removeObserver(this);
    }
});
  • 정작 지산이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다.
set.addObserver(new SetObserver<>() {
    public void added(ObservableSet<Integer> s, Integer e) {
        System.out.println(e);
        if ( e == 23) {
            ExecutorService exec = Execitors.newSingleThreadExecutor();
            try {
                exec.submit(() -> s.removeObserver(this)).get();
            } catch (ExecutionException | InterruptedException ex) {
                throw new AssertionError(ex);
            } finally {
                exec.shutdown();
            }
    }
}
});
  • 위 코드에서는 예외는 나지 않지만 교착상태에 빠진다.
  • 백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 메인 스레드가 이미 락을 쥐고 있기 때문에 락을 얻을 수 없다.
  • 이와 동시에 메인 스레드는 백그라운드 스레드가 관찰자를 제거하기만을 기다리는 중이다.

자바 언어의 락은 재진입을 허용하므로 교착상태에 빠지지는 않는다.

  • 예외를 발생시킨 첫 번째 예에서라면 외계인 메서드를 호출하는 스레드는 이미 락을 쥐고 있으므로 다음번 락 획득도 성공한다.
  • 락이 보호하는 데이터에 대해 개념적으로 관련이 없는 다른 작업이 진행 중인데도 가능하여 이로 인해 참혹한 결과를 빚을 수도 있다.
  • 문제의 주 원인은 락이 제 구실을 하지 못했기 때문인데 재진입 가능 락은 객체 지향 멀티스레드 프로그램을 쉽게 구현할 수 있도록 해주지만, 응답 불가(교착상태)가 될 상황을 안전 실패(데이터 훼손)로 변모시킬 수도 있다.

외계인 메서드를 해결하는 방법

  • 외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 쉽게 해결할 수 있고 이를 열린 호출이라 한다.
    • 외계인 메서드는 얼마나 오래 실행될지 알 수 없는데, 동기화 영역에서 호출하면 그동안 다른 스레드는 보호된 자원을 사용하지 못하도 대기해야 하는데 열린 호출을 하면 실패 방지 효과외에도 동시성 효율을 크게 개선한다.
  • 자바의 동시성 컬렉션 라이브러리의 CopyOnWriteArrayList가 정확히 이 목적으로 특별히 설계 되었다.
    • 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현되었다.
    • 내부의 배열은 절대 수정되지 않으니 순회할 때 락이 필요 없어 매우 빠르다.
    • 다른 용도로 쓰이면 끔찍히 느리지만 수정할 일은 드물고 순회만 빈번히 일어나는 관찰자 리스트 용도로는 최적이다.
  • 기본 규칙은 동기화 영역에서는 가능한 한 일을 적게 하는 것이다.

성능 측면에서의 비용

  • 멀티 코어가 일반화된 오늘날에는 과도한 동기화가 초래하는 진짜 비용은 락을 얻는데 드는 CPU 시간이 아니라 경쟁하느라 낭비하는 시간, 즉 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용이다.
  • 가상 머신의 코드 최적화를 제한한다는 점도 과도한 동기화의 또 다른 숨은 비용이다.

가변 클래스를 작성하려면 두 가지 중 하나를 따르자

  1. 동기화를 전혀 하지 말고, 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자.
  2. 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자. 단, 외부에서 객체 전체에 락을 거는 것보다 동시성을 우러등히 개선할 수 있을 때만 두 번째 방법을 선택해야 한다.

java.util은(이제 구식이 된 Vector와 Hashtable을 제외하고) 첫 번째 방식을 취했고, java.util.concurrent는 두 번째 방식을 취했다.

자바도 초창기에는 이 지침을 따르지 않았는데

  1. StringBuffer 인스턴스는 거의 항상 단일 스레드에서 쓰였음에도 내부적으로 동기화를 수행했고 StringBuilder가 등장하여 동기화하지 않는 StringBuffer처럼 사용된다.
  2. 스레드 안전한 의사 난수 발생기인 java.util.Random은 동기화하지 않는 버전인 java.util.concurrent.ThreadLocalRandom으로 대체되었다.

선택 하기 어렵다면 동기화하지 말고, 대신 문서에 "스레드 안전하지 않다"고 명기하자.

클래스 내부에서 동기화하기로 했다면, 락 분학, 락 스트라이핑, 비차단 동시성 제어 등 다양한 기법을 동원해 동시성을 높여줄 수 있다.

여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기화해야 한다.(비결정적 행동도 용인하는 클래스라면 상관없다) 그런데 클라이언트가 여러 스레드로 복제돼 구동되는 상황이라면 다른 클라이언트에서 이 메서드를 호출하는 걸 막을 수 없으니 외부에서 동기화할 방법이 없다. 결과적으로 이 정적 필드가 심지어 private라도 서로 관련 없는 스레드들이 동시에 읽고 수정할 수 있게 된다. 사실상 전역 변수와 같아진다는 뜻이다.

함수 객체(아이템24), Biconsumer를 그대로 사용했더라도 별 무리는 없었을 것이다.(아이템44), 람다는 자기 자신을 참조할 수단이 없다.(아이템42), 실행자 서비스(ExecutorService, 아이템80), 스레드 안전한 클래스(아이템82), java.util,concurent(아이템81)

교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자. 일반화해 이야기하면 동기화 영역 안에서의 작업은 최소한으로 줄이자. 가변 클래스를 설계할 때는 스스로 동기화해야 할지 고민하자. 멀티코어 세상인 지금은 과도한 동기화를 피하는 게 과거 어느 때보다 중요하다. 합당한 이유가 있을 때만 내부에서 동기화하고, 동기화했는지 여부를 문서에 명확히 밝히자.


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

java.util.concurrent 패키지가 등장했고 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.

ExecutorService exec = Executors.newSingleThreadExecutor(); // 작업 큐를 생성하는 방법
exec.execute(runnable); // 실행자에 실행할 테스트를 넘기는 방법
exec.shutdown(); // 우하하게 종료시키는 방법
  • 특정 태스크가 완료되기를 기다린다(get 메서드)
  • 태스크 모음 중 아무것 하나(invokeAny 메서드) 혹은 모든 태스크(invokeAll 메서드)가 완료되기를 기다린다.
  • 실행자 서비스가 종료하기를 기다린다(awaitTermination 메서드)
  • 완료된 태스크들의 결과를 차례로 받는다(ExecutorCompletionService 이용)
  • 태스크를 특정 시간에 혹은 주기적으로 실행하게 한다.(ScheduledThreadPoolExecutor 이용)

용도와 특징

  • 작은 프로그램이나 가벼운 서버에서는 Executors.newCashedThreadPool이 일반적으로 좋은 선택이다.
    • 무거운 서버에서는 좋지 못하는데 요청받은 태스크들을 큐에 쌓지 않고 즉시 스레드에 위임돼 실행 된다.
    • 가용한 스레드가 없다면 새로 생성하여 CPU 이용률이 100%에 치닫는 상황이 생길 수 있다.
  • 무거운 서버에서는 스레드 개수를 고정한 Executors.newFixedThreadPool을 선택하거나 와전히 통제할 수 있는 ThreadPoolExecutor를 직접 사용하는 편이 훨씬 낫다.
  • 작업 큐를 손수 만드는 일은 삼가야 하고, 스레드를 직접 다루는 것도 일반적으로 삼가야 한다.
    • 스레드를 직접 다루면 Thread가 작업 단위와 수행 메커니즘 역할을 모두 수행하게 되는데 실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다.
  • 작업 단위를 나타내는 핵심 추상 개념이 태스크이고 두 가지가 있다.
    • Runnable과 Callable(Runnable과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다)이다.
  • 태스크를 수행하는 일반적인 메커니즘이 바로 실행자 서비스로 태스크 수행을 실행자 서비스에 맡기면 우너하는 태스크 수행 정책을 선택할 수 있고, 생각이 바뀌면 언제든 변경할 수 있다. 핵심은 (컬렉션 프레임워크가 데이터 모음을 담당하듯) 실행자 프레임워크가 작업 수행을 담당해준다는 것이다.
  • 자바 7이 되면서 실행자 프레임워크는 포크-조인 태스크를 지원하도록 확장되었다. 포크-조인 태스크는 포크-조인 풀이라는 특별한 실행자 서비스가 실행해준다.
    • 포크-조인 태스크, 즉 ForkJoinTask의 인스턴스는 작은 하위 태스크로 나뉠 수 있고, ForkJoinPool을 구성하는 스레드들이 이 태스크들을 처리하며, 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수도 있다.
    • 이렇게 하여 모든 스레드가 바쁘게 움직여 CPU를 최대한 활용하면서 높은 처리량과 낮은 지연시간을 달성한다.
    • 이러한 포크-종니 태스크를 직접 작성하고 튜닝하기런 어려운 일이지만, 포크-조인 풀을 이용해 만든 병렬 스트림을 이용하면 적은 노력으로 그 이점을 얻을 수 있다.

병렬 스트림(아이템48)

작은 프로그램이나 가벼운 서버에서는 Executors.newCashedThreadPool이 일반적으로 좋은 선택이다. 무거운 서버에서는 좋지 못하는데 요청받은 태스크들을 큐에 쌓지 않고 즉시 스레드에 위임돼 실행 된다. 가용한 스레드가 없다면 새로 생성하여 CPU 이용률이 100%에 치닫는 상황이 생길 수 있다.
무거운 서버에서는 스레드 개수를 고정한 Executors.newFixedThreadPool을 선택하거나 와전히 통제할 수 있는 ThreadPoolExecutor를 직접 사용하는 편이 훨씬 낫다.


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

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

  • java.util.concurrent의 고수준 유틸리티는 세 범주로 나눌 수 있다.
  1. 실행자 프레임워크(Executors)
  2. 동시성 컬렉션(concurrent collection)
  3. 동기화 장치(synchronizer)

실행자는 아이템80에서 가볍게 살펴보았고 동시성 컬렉션과 동기화 장치는 이번 아이템에서 살펴본다.

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

  • 동시성 컬렉션은 List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다.
  • 높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다.
  • 동시성 컬렉션에서 동시성을 무력화하지 못하므로 여러 메서드를 원자적으로 묶어 호출하는 일 역시 불가능하여 여러 기본 동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메서드들이 추가되었다.
    • 이 메서드들은 아주 유용해서 자바 8에서는 일반 컬렉션 인터페이스에도 디폴트 메서드형태로 추가되었다.
  • 예를 들어 Map의 putIfAbsent(key, value) 메서드는 주어진 키에 매핑된 값이 아직 없을 때만 새 값을 집어 넣고 기존 값이 있었다면 그 값을 반환하고 없었다면 null을 반환한다.
      // ConcurrentMap으로 구현한 동시성 정규화 맵 - 최적은 아니다.
      private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>();
    
      public static String intern(String s) {
          String previousValue = map.puIfAbsent(s, s);
          return previousValue == null ? s : previousValue;
      }
    
      // get을 먼저 호출하여 필요할 때만 putIfAbsent를 호출하면 더 빠르다.
    
      public static String intern(String s) {
          String result = map.get(s);
          if (result == null) {
              result = map.puIfAbsent(s, s);
              if(result == null) 
                  result = s;
          }
      return result;
      }
  • Collections.synchronizedMap보다는 ConcurrentHashMap을 사용하는게 훨씬 좋다.
    • 동기화된 맵을 동시성 맵으로 교체하는 것만으로 동시성 애플리케이션의 성능은 극적으로 개선된다.
  • 컬렉션 인터페이스 중 일부는 작업이 성공적으로 완료될 때까지 기다리도록(즉, 차단되도록) 확장되었다.
    • 예를 들러 Queue를 확장한 BlockingQueue에 추가 된 메서드 중 take는 큐의 첫 원소를 꺼내는데 만약 큐가 비어 있다면 새로운 원소가 추가 될 때까지 기다린다.
      • 이런 특성 덕이 BlockingQueue는 작업 큐(생산자-소비자 큐)로 쓰기에 적합하다.
      • ThreadPoolExecutor를 포함한 대부분의 실행자 서비스 구현체에서 BlockingQueue를 사용한다.

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

자주 쓰이는 동기화 장치는 CountDownLatch와 Semaphore이고 CycleBarrier와 Exchanger는 그보다 덜 씨이고 가장 강력한 동기화 장치는 Phaser다.

  • CountDownLatch(카운트다운 래치)는 일회성 장벽으로, 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다.
    • 유일한 생성자는 int 값을 받으며, 이 값이 countDown 메서드를 몇번 호출해야 대기 중인 스레드들을 깨우는지를 결정한다.
      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;
      
        }
    • 주의 해야 할 점은 time 메서드에 넘겨진 실행자(executor)는 concurrentcy매개 변수로 지정한 동시성 수준만큼의 스레드를 생성할 수 있어야 하며 그렇지 않다면 교착상태에 빠지게 된다.

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

  • System.nanoTime은 더 정확하고 정밀하며 시스템의 실시간 시계의 시간 보정에 영향 받지 않는다.
  • 작업에 충분한 시간이 걸리지 않는다면 정확한 시간을 측정할 수 없는데 꼭 해야 한다면 jmh같은 특수한 프레임워크를 사용해야 한다.

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

  • wait 호출 전후로 조건이 만족하는지를 검사하는 역할을 한다.
  • 대기 전에 조건을 감시하여 조건이 이미 충족되었다면 wait를 건너뛰게 한것은 응답 불가 상태를 예방하는 조치이다.
  • 만약 조건이 이미 충족되었는데 스레드가 notify(혹은 notifyAll) 메서드를 먼저 호출한 후 대기 상태로 빠지면, 그 스레드를 다시 깨울 수 있다고 보장할 수 없다.
  • 대기 후에 조건을 검사하여 조건이 충족되지 않았다면 다시 대기하게 하는 것은 안전 실패를 막는 조치이다. 만약 조건이 충족되지 않았는데 스레드가 동작을 이어가면 락이 보호하는 불변식을 깨뜨릴 위험이 있다. 조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황이 몇 가지 있다.
    • 스레드가 norify를 호출한 다음 다음 대기중이던 스레드가 깨어나는 사이에 다른 스레드가 락을 얻어 그 락이 보호하는 상태를 변경한다.
    • 조건이 만족되지 않았음에도 다른 스레드가 실수로 혹은 악의적으로 notify를 호출한다. 공개된 객체를 락으로 사용해 대기하는 클래스는 이런 위험에 노출된다. 외부에 노출된 객체의 동기화된 메서드 안에서 호출하는 wait는 모두 이문제에 영향을 받는다.
    • 캐우는 스레드는 지나치게 돤대해서, 대기중인 스레드 중 일부만 조건이 충족되어도 notifyAll을 호출해 모든 스레드를 깨울 수도 있다.
    • 대기 중인 스레드가 (드물게) notify 없이도 깨어나느 경우가 있는데 허위 각성이라는 현상이다.
  • 이와 관련하여 nofify와 notifyAll 중 무엇을 선택하느냐의 문제도 있는데 일반적으로 언제나 notifyAll을 사용하란는게 합리적이고 안전한 조언이다.
    • 깨어나야 하는 모든 스레드가 깨어남을 보장하니 항상 정확한 결과를 얻을 것이다.
    • 다른 스레드까지 깨어날 수도 있지만, 그것은 여러분 프로그램의 정확성에는 영향을 주지 않을 것이다.
    • 꺠어난 스레드들은 기다리던 조건이 충족되었는지 확인하여 충족되지 않았다면 다시 대기할 것이다.
  • 모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족될 때마다 단 하나의 스레드만 혜택을 받을 수 있다면 nofifyAll 대신 nofify를 사용해 최적화할 수 있다.
    • 하지만 이상의 전제 조건들이 만족될지라도 notify 대신 notifyAll을 사용해야 하는 이유가 있는데 외부로 공개된 객체에 대해 실수로 혹은 악의적으로 notify를 호출하는 상황에 대비하기 위해 wait를 반복문 안에서 호출했듯 notify 대신 notifyAll을 사용하면 관련 없는 스레드가 실수로 혹은 악의적으로 wait를 호출하는 공격으로부터 보호할 수 있다. 그런 스레드가 중요한 notify를 삼겨버린다면 꼭 깨어났어야 할 스레드들이 영원히 대기하게 될 수 있다.

내부 동시성(아이템79), 디폴트메서드(아이템21), 실행자 서비스(아이템80)

wait와 notify를 직접 사용하는 것은 동시성 '어셈블리 언어'로 프로그래밍하는 것에 비유할 수 있다. 반면 java.util.concurrent는 고수준 언어에 비유할 수 있다. 코드를 새로 작성한다면 wait와 notify를 쓸 이유가 거의(어쩌면 전혀) 없다. 이들을 사용하는 레거시 코드를 유지보수해야 한다면 wait는 항상 표준 관용구에 따라 while문 안에서 호출하도록 하자. 일반적으로 notify보다는 notifyAll을 사용하야 한다. 혹시라도 notify를 사용한다면 응답 불가 상태에 빠지지 않도록 각별히 주의하자.


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

한 메서드를 여러 스레드가 동시에 호출할 때 그 메서드가 어떻게 동작하느냐는 해당 클래스와 이를 사용하는 클라이언트 사이의 중요한 계약과 같다.

API문서에서 아무런 언급도 없으면 그 클래스 사용자는 나름의 가정을 해야만 하고 그 가정이 틀리면 클라이언트 프로그램은 동기화를 충분히 하지 못하거나 지나치게 한 상태일 것이며 두 경우 모두 심각한 오류로 이어질 수 있다.

메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐 API에 속하지 않는다.

  • API 문서에 synchronized 한정자가 보이는 메서드는 스레드 안전하다는 이야기는 틀린 이야기인데 자바독이 기본 옵션에서 생성한 API 문서에는 synchronized 한정자가 포함되지 않는다.
  • synchronized 유무로 스레드 안전성을 알 수 있다는 주장은 '스레드 안정성은 모 아니며 도'라는 오해에 뿌리를 둔 것이다

멀티스레드 환경에서도 API를 안전하게 사용하게 하려면 클래스가 지우너하는 스레드 안전성 수준을 정확히 명시해야 한다.

  • 스레드 안전성에도 수준이 나뉘고 다음과 같은 경우가 일반적인 경우를 포괄한다.
    • 불변(immutable) : 이 클래스의 인스턴스는 마치 상수와 같아서 외부 동기화도 필요 없다. String, Long, BigInteger가 대표적이다.
    • 무조건적 스레드 안전(unconditionally thread-safe) : 이 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화 없이 동시에 사용해도 안전하다. AtomicLong, ConcurrentHashMap이 여기에 속한다.
    • 조건부 스레드 안전(conditionally thread-safe) : 무조건적 스레드 안전과 같으나, 일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다. Collections.synchronized 래퍼 메서드가 반환한 컬렉션들이 여기 속한다. (이 컬렉션들이 반환한 반복자는 외부에서 동기화해야 한다.)
    • 스레드 안전하지 않음(not thread-safe) : 이 클래스의 인스턴스는 수정될 수 있다. 동시에 사용하려면 각각의(혹은 일련의) 메서드 호출을 클라이언트가 선택한 외부 동기화 메커니즘으로 감싸야 한다. ArrayListm HashMap 같은 기본 컬렉션이 여기 속한다.
    • 스레드 적대적(thread-hostile) : 이 클래스는 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티 스레드 호나경에서 안전하지 않다. 이 수준의 클래스는 일반적으로 정적 데이터를 암 동기화 없이 수정한다. 이런 클래스를 고의로 만드는 사람은 없겠지만, 동시성을 고려하지 않고 작성하다 보면 우연히 만들어질 수 있다. 스레드 적대적으로 밝혀진 클래스나 메서드는 일반적으로 문제를 고쳐 재배포하거나 사용 자제 API로 지정한다.

조건부 스레드 안전한 클래스는 주의해서 문서화해야 한다.

  • 어떤 순서로 호출할 때 외부 동기화가 필요한지, 그리고 그 순서로 호출하려면 어떤 락 혹은 (드물게) 락들을 얻어야 하는지 알려줘야 한다.
  • 일반적으로 인스턴스 자체를 락으로 얻지만 예외도 있다.
  • 예를 들어 Collections.synchronizedMap의 API문서에는 다음과 같이 써 있다.
      // synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 
      // 반드시 그 맵을 락으로 사용해 수동으로 동기화 하라.
    
      Map<K, V> m = Collections.synchronizedMap(new HashMap<>());
      Set<K> s = m.keySet(); // 동기화 블록 밖에 있어도 된다.
    
      synchronized(m) { // s가 아닌 m을 사용해 동기화해야 한다!
          for (K key : s) 
              key.f();
      }
      // 이대로 따르지 않으면 동작을 예츨할 수 없다.

클래스의 스레드 안전성은 보통 클래스의 문서화 주석에 기재하지만, 독특한 특성의 메서드라면 해당 메서드의 주석에 기재하도록 하자.

  • 열거 타입은 굳이 불변이라고 쓰지 않아도 된다.
  • 반환 타입만으로는 명확히 알 수 없는 정적 팩터리라면 자신이 반환하는 객체의 스레드 안전성을 반드시 문서화해야 한다.
  • 클래스가 외부에서 사용할 수 있는 락을 제공하면 클라리언트에서 일련의 메서드 호출을 원자적으로 수행할 수 있다.
    • 하지만 유연성에는 대가가 따르는데 내부에서 처리하는 고성능 동시성 제어 메커니즘과 혼용할 수 없게 되는 것이다. ConcurrentHashMap 같은 동시성 컬렉션과 함께 사용하지 못한다.
    • 클라이언트가 공개된 락을 오래 쥐고 놓지 않는 서비스 거부 공격을 수행할 수도 있다.
    • 서비스 거부 공격을 막으려면 synchronized 메서드(이 역시 공개된 락이나 마찬가지다) 대신 비공개 락 객체를 사용해야 한다.
      // 비공개 락 객체 관용구 - 서비스 거부 공격을 막아준다.
      private final Object lock = new Object();
      public void foo() {
        synchronized(lock) {
            ...
        }
      }
    • 비공개 락 객체는 클래스 바깥에서는 볼 수 없으니 클라이언트가 그 객체의 동기화에 관여할 수 없다. 락 객체를 동기화 대상 객체 안으로 캡슐화 한것이다.

락 필드는 항상 final로 선언하자.

  • 이는 우연히라도 락 객체가 교체되는 일을 예방해준다.
  • 일반적인 감시 락이든 java.util.concurrent.locks 패키지에서 가져온 락이든 마찬가지다.

비공개 락 객체 관용구는 상속용으로 설계한 클래스에 특히 잘 맞는다.

  • 비공개 락 객체 관용구는 무조건적 스레드 안전 클래스에서만 사용할 수 있다.
  • 조건부 스레드 안전 클래스에서는 특정 호출 순서에 필요한 락이 무엇인지를 클라이언트에게 알려줘야 하므로 이 관용구를 사용할 수 없다.
  • 상속용 클래스에서 자신의 인스턴스를 락으로 사용한다면, 하위 클래스는 아주 쉽게, 그리고 의도치 않게 기반 클래스의 동작을 방해할 수 있다.(그 반대도 마찬가지다).
  • 같은 락을 다른 목적으로 사용하게 되어 하위 클래스와 기반 클래스는 '서로가 서로를 훼방놓는' 상태에 빠진다. 단지 가능성에 그치지 않고 실제로 Thread 클래스에서 나타나곤 하는 문제이다.

동기화(아이템78), 지나친(아이템79), BigInteger(아이템7), 상속용으로 설계한 클래스(아이템19)

모든 클래스가 자신의 스레드 안전성 정보를 명확히 문서화해야 한다.
정확한 언어로 명확히 설명하거나 스레드 안전성 애너테이션을 사용할 수 있다. synchronized 한정자는 문서화와 아무런 관련이 없다. 조건부 스레드 안전 클래스는 메서드를 어떤 순서로 호출할 때 외부 동기화가 요구되고, 그 때 어떤 락을 얻어야 하는지도 알려줘야 한다. 무조건적 스레드 안전 클래스를 작성할 때는 synchronized 메서드가 아닌 비공개 락 객체를 사용하자. 이렇게 해야 클라이언트나 하위 클래스에서 동기화 메커니즘을 깨드리는 걸 예방할 수 있고, 필요하다면 다음에 더 정교한 동시성을 제어 메커니즘으로 재구현할 여지가 생긴다.


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

지연 초기화는 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법이다. 값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않기에 주로 최적화용도로 쓰이지만, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다.

다른 모든 최적화와 마찬가지로 지연 초기화도 최선의 조언은 "필요할 때까지는 하지말라"이다.

  • 클래스 혹은 인스턴스 생성 시 초기화 비용은 줄지만 지연 초기화는 필드에 접근하는 비용은 커진다.
  • 지연 초기화하려는 필드 중 초기화가 이뤄지는 비율에 따라, 실제 초기화에 드는 비용에 따라, 초기화된 각 필드를 얼마나 빈번히 호출하느냐에 따라 지연 초기화가 성능이 느려지게 할 수도 있다.

대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.

// 1. 인스턴스 필드를 초기화하는 일반적인 기법
private final FieldType field = computeFieldValue();

// 2. 인스턴스 필드의 지연 초기화 - synchronized 접근자 방식
// 지연 초기화가 초기화 순환성을 깨뜨릴 것 같으면 synchronized를 단 접근자를 사용하자.
private FieldType field;
private synchronized FieldType getField() {
    if (field == null) 
            field = computeFieldValue();
    return field;
}

// 3. 정적 필드용 지연 초기화 홀더 클래스 관용구
// 성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스(lazy initialization holder class) 관용구를 사용하자
private static class FieldHolder {
    static final FieldType field = computeFieldValue();
}
private static FieldType getField() { return FieldHolder.field; }
// FieldHolder.field가 처음 읽히면서, 비로소 FieldHolder 클래스 초기화를 촉발한다.
// getField메서드가 필드에 접근하면서 동기화를 전혀 하지 않으니 성능이 느려질 거리가 전혀 없다.

성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중 검사(double-check) 관용구를 사용하라.

  • 초기화된 필드에 접근할 때의 동기화 비용을 없애준다.
  • 필드 값을 두 번 검사하는 방식으로, 한 번은 동기화 없이 검사하고 (필드가 아직 초기화되지 않았다면) 두 번째는 동기화하여 검사한다.
  • 두 번째 검사에서도 필드가 초기화되지 않았을 때만 필드를 초기화한다.
  • 필드가 초기화된 후로는 동기화하지 않으므로 해당 필드는 반드시 volatile로 선언해야 한다.
// 인스턴스 필드 지연 초기화용 이중검사 관용구
private volatile FieldType field;
private FieldType getField() {
    FieldType result = field;
    if (result != null) { // 첫 번째 검사 (락 사용 안함)
        return result;
    synchronized(this) {
        if (field == null) // 두 번째 검사(락 사용)
            field = computeFieldValue();
        return field;
    }
}
  • result 지역 변수는 필드사 이미 초기화된 상황(일반적인 상황은 아님)에서는 그 필드를 딱 한번만 읽도록 보장하는 역할을 한다.
  • 반드시 필요하지는 않지만 성능을 높요주고, 주수준 동시성 프로그래밍에 표준적으로 적용되는 더 우아한 방법이다.
  • 이중 검사에는 언급해둘 만한 변중으로 두 가지가 있다.
    • 반복해서 초기화해도 상관없는 인스턴스 필드를 지연 초기화해야 할 때가 있는데 이럴 경우 이중 검사에서 두 번째 검사를 생략할 수 있고 단일 검사 관용구가 된다.
    • 모든 스레드가 필드의 값을 다시 계산해도 상관없고 필드의 타입이 long과 double을 제외한 다른 기본 타입이라면, 단일 검사의 필드 선언에서 volatile 한정자를 없애도 되고 짜릿한 단일검사 관용구라 불리며 어떤 환경에서는 필드 접근 속도를 높여주지만, 초기화가 스레드당 최대 한 번 더 이뤄질 수 있고 아주 이례적인 기법으로 보통은 거의 쓰이지 않는다.

이번 아이템에서 이야기한 모든 초기화 기법은 기본 타입 필드와 객체 참조 필드 모두에 적용할 수 있다.

"필요할 때까지는 하지말라"(아이템67), 심각한 버그(아이템78), final(아이템17), 동기화 비용(아이템79), volatile(아이템78)

대부분의 필드는 지연시키지 말고 곧바로 초기화해야 한다. 성능 때문에 혹은 위험한 초기화 순환을 막기 위해 꼭 지연 초기화를 써야 한다면 올바른 지연 초기화 기법을 사용하자. 인스턴스 필드에는 이중 검사 관용구를, 정적 필드에는 지연 초기화 홀더 클래스 관용구를 사용하자. 반복해 초기화해도 괜찮은 인스턴스 필드에는 단일검사 관용구도 고려 대상이다.


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

정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다.

  • 정상적인 운영체제라면 이 작업을 공정하게 수행하지만 구체적인 스케줄링 정책은 운영체제마다 다를 수 있기 때문에 잘 작성된 프로그램이라면 이 정책에 좌지우지 돼서는 안된다.
  • 견고하고 빠릿하고 이식성 좋은 프로그램을 작성하는 가장 좋은 방법은 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다.

스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안 된다.

  • 실행 가능한 스레드 수를 적게 유지하는 주요 기법은 각 스레드가 무언가 유용한 작업을 완료한 후에는 다음 일거리가 생길 때까지 대기하도록 하는 것이다.
  • 실행자 프레임워크를 예를 들면 스레드 풀 크기를 적절히 설정하고 작업은 짧게 유지하면 되지만 너무 짧으면 작업을 분배하는 부담이 오히려 성능을 떨어뜨릴 수도 있다.
  • 스레드는 절대 바쁜 대기 상태가 되면 안 되는데 공유 객체의 상태가 바뀔 때까지 쉬지 않고 검사해서는 안 된다는 의미이다.
public class Item84 {
    private int count;

    public Item84(int count) {
        if (count < 0)
            throw new IllegalArgumentException(count + " < 0");
        this.count = count;
    }

    public void await() {
        while (true) {
            synchronized (this) {
                if (count == 0)
                    return ;
            }
        }
    }

    public synchronized void countDown() {
        if (count != 0)
            count--;
    }

    public static void main(String[] args) {

    }
}
  • Thread.yield를 써서 문제를 고쳐보려는 유혹을 떨쳐내자.
  • Thread.yield는 테스트할 수도 없다.

스레드 우선순위는 자바에서 이식성이 가장 나쁜 특성에 속한다.

  • 스레드 몇 개의 우선 순위를 조율해서 애플리케이션의 반응 속도를 높이는 것도 일견 타당할 수 있으나, 정말 그래야 할 상황은 드물고 이식성이 떨어진다.

실행자 프레임워크(아이템80)

프로그램의 동작을 스레드 스케줄러에 기대지 말자. 견고성과 이식성을 모두 해치는 행위다. 같은 이유로, Thread.yield와 스레드 우선순위에 의존해서도 안 된다. 이 기능들은 스레드 스케줄러에 제공하는 힌트일 뿐이다. 스레드 우선순위는 이미 잘 동작하는 프로그램의 서비스 품질을 높이기 위해 드물게 쓰일 수는 있지만, 간신히 동작하는 프로그램을 '고치는 용도'로 사용해서는 절대 안된다.


반응형