📌 11장 : 동시성

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

  • synchronized
    • 해당 메서드나 블록을 한 번에 한 스레드씩 수행하도록 보장
    • 일반적인 동기화
      • 한 객체가 일관된 상태를 가지고 생성, 객체에 접근하는 메서드는 그 객체에 lock 을 건다.
      • lock 을 건 메서드는 객체의 상태를 확인하고 필요하면 수정한다.
    • 동기화의 중요한 기능
      • 일관성이 깨진 상태를 볼 수 없게 해주고
      • 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.
  • 동기화는 배타적 실행뿐만 아니라, 스레드 사이의 안정적인 통신에 필요하다.
  • 다른 스레드를 멈추는 올바른 방법
    • 첫 번째 스레드는 자신의 boolean 필드를 폴링하면서 값이 true가 되면 멈춘다.
    • 필드를 false로 초기화하고, 다른 스레드에서 이 스레드를 멈추려고 할 때 true 로 변경
  • 적절한 스레드 종료 방법
public class StopThread {
    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()) 
                    i++;
            }); 
            backgroundThread.start();
            
            TimeUnit.SECONDS.sleep(1);
            requestStop();
    } 
}
  • 쓰기 메서드(requestStop), 읽기 메서드(stopRequested) 모두 동기화 해야 한다.
  • 둘 다 동기화되지 않으면, 동작을 보장하지 않는다.
  • volatile
    • 배타적 수행과는 상관없이 항상 가장 최근에 기록된 값을 읽게 해준다.
    • 반복문에서 동기화 하는 비용을 더 빠르게 하는 방법
private static volatile boolean stopRequested;
  • 주의해야 하는 상황
    • 증가 연산자는 코드상으로 하나지만, 실제로 nextSerialNumber 필드에 두 번 접근한다.
    • 먼저 값을 읽고, 증가한 새로운 값을 저장한다.
    • 두 번째 스레드가 이 두 접근 사이를 들어와 값을 읽게 되면 첫 번째 스레드와 같은 값을 받게 된다.
    • 안전 실패(safety failure) : 프로그램이 잘못된 결과를 계산하는 오류
    • 해결 방법 : generateSerialNumber 메서드에 synchronized 한정자를 붙인다.
      • 동시 호출을 해도 이전 호출이 변경한 값을 읽게 해준다.
      • synchronized을 사용하면 nextSeriaINumber 필드에서 volatile 를 제거해주어야 한다.
private static volatile int nextSeriaINumber = 0;

public static int generateSerialNumber() { 
        return nextSerialNumber++;
}
  • 위의 문제들을 피하는 가장 좋은 방법은 처음부터 가변 데이터를 공유하지 않도록 한다.
  • 가변 데이터는 단일 스레드에서만 사용하도록 한다.

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

  • 과도한 동기화는 성능이 저하되고, 교착상태에 빠질 수 있다.
  • 응답 불가와 안전 실패를 피하기 위해서 동기호 메서드나 동기화 블록 안에서 제어를 클라이언트에 양도하지 않기
    • 동기화된 영역 안에 재정의할 수 있는 메서드를 호출하면 안된다.
    • 클라이언트가 넘겨준 함수 객체를 호출하면 안된다.
  • 외계인 메서드 호출을 동기화 블록 바깥으로 옮겨준다.
private void notifyElementAdded(E element) { 
    List<SetObserver<E>> snapshot = null; 
    synchronized(observers) {
      snapshot = new ArrayList<>(observers);
    }
    for (SetObserver<E> observer : snapshot)
        observer.added(this, element);
}
  • 열린 호출(open call)
    • 동기화 영역 바깥에서 호출되는 외계인 메서드
    • 외계인 메서드는 언제까지 실행될지 알 수 없는데, 동기화 영역 안에서 호출되면 다른 스레드는 사용 대기를 해야 한다.
    • 열린 호출은 실패 방지 효과 이외에 동시성 효율을 개선해준다.
  • 더 좋은 방법은 자바의 동시성 컬렉션 라이브러리 CopyOnWriteArrayList가 있다.
    • ArrayList를 구현한 클래스
    • 내부의 배열이 수정되지 않아 순회할 때 락이 필요 없어 속도가 매우 빠르다.
    • 순회만 일어나는 관찰자 리스트 용도로 좋다.
private final List<SetObserver<E>> observers = 
        new CopyOnWriteArrayList<>();
public void addObserver(SetObserver<E> observer) { 
    observers.add(observer);
}
public boolean removeObserver(SetObserver<E> observer) { 
    return observers.remove(observer);
}
private void notifyElementAdded(E element) { 
    for (SetObserver<E> observer : observers)
      observer.added(this, element);
}
  • 기본 규칙
    • 동기화 영역에서 가능한 일을 적게 하기
  • 멀티코어가 일반화 된 오늘날에, 과도한 동기화는 락을 얻는 데 드는 CPU 비용이 아니다.
  • 경쟁하는 낭비 시간, 모든 코어가 메모리를 일관되게 보기 위한 지연 시간

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

  • java.util.concurrent
    • 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.
    • 간단한 작업 큐 사용 가능
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.execute(runnable); // 실행자에 실행할 태스크를 넘기는 법
exec.shutdown(); // 실행자를 종료시키는 법
  • 큐를 2개 이상의 스레드가 처리하게 하려면, 다른 정적 팩토리로 다른 종류의 실행자 서비스(스레드 풀) 생성하기
  • 스레드 풀 스레드 개수는 고정할 수 있고, 늘어나거나 줄어들게 할 수도 있다.
  • 주로 사용하는 실행자는 java.util.concurrent.Executors 에서 사용
  • ThreadPoolExecutor 클래스를 직접 사용해도 된다.
  • 실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다.
  • 태스크 : 작업 단위를 나타내는 개념
  • 태스크 종류 : Runnable, Callable
    • Callable은 Runnable과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다.
  • 실행자 서비스 : 태스크를 수행하는 일반적인 메커니즘
  • 자바 7부터 실행자 프레임워크는 포크-조인(fork-join) 태스크를 지원한다.
  • fork-join 태스크는 fork-join 풀이라는 특별한 실행자 서비스를 실행한다.
    • fork-join 태스크의 인스턴스는 작은 하위 태스크로 나뉠 수 있다.
    • fork-join 풀을 구성하는 스레드들이 태스크들을 처리한다.
    • 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와서 대신 처리할 수도 있다.
    • 모든 스레드가 움직여 CPU를 최대한 활용한다.
    • 높은 처리량, 낮은 지연시간

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

  • 자바 5부터 고수준 동시성 유틸리티가 wait, notify 대신 일을 처리해준다.
  • java.util.concurrent 의 고수준 유틸리티
    • 실행자 프레임워크
    • 동시성 컬렉션(concurrent collection)
    • 동기화 장치(synchronizer)
  • 동시성 컬렉션
    • List, Queue, Map과 같은 표준 컬렉션 인터페이스에 동시성을 추가한 고성능 컬렉션
    • 동기화를 각자 내부에서 수행해 높은 동시성을 갖춤
    • 동시성 컬렉션에서 동시성을 무력화할 수 없다. 외부에서 락을 추가로 사용하면 속도가 느려진다.
  • ConcurrentMap 으로 구현한 동시성 정규화 맵
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; 
}
  • Collections.synchronizedMap 보다 ConcurrentHashMap 의 성능이 더 좋다.
    • 동기화된 맵 -> 동시성 맵
  • wait, notify 보다 동시성 유틸리티를 사용하는 것이 더 좋지만
  • 레거시 코드를 유지보수해야 하는 경우, wait은 while문 안에서 호출해야 한다.
  • 일반적인 상황에서 notify보다 notifyAll을 사용해야 한다.
    • 응답 불가 상태에 빠질 수 있기 때문에

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

  • 메서드 선언에서 synchronized 은 API에 속하지 않는다.
  • 멀티 스레드 환경에서 API를 안전하게 사용하기 위해서 스레드 안전성 수준을 정확히 명시해야 한다.
  • Collections.synchronizedMap API 문서
// synchronizedMap이 반환한 맵의 컬렉션 뷰를순회하려면 반드시 그 맵을 락으로 사용해 수동으로 동기화하라.

Map<K, V> m = Collections. synchronizedMap(new HashMapo()); 
Set<K> s = m.keySet(); // 동기화 블록 밖에 있어도 된다.
        ...
synchronized(m) { // s가 아닌 m을 사용해 동기화해야 한다! 
  for (K key : s)
    key.f();
}
// 이대로 따르지 않으면 동작을 예측할 수 없다.

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

  • 지연 초기화(lazy initialization)
    • 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법
    • 정적 필드, 인스턴스 필드에서 사용
    • 주로 최적화에 사용, 클래스와 인스턴스 초기화에 발생하는 위험 순환 문제 해결
    • 클래스, 인스턴스 생성의 초기화 비용은 줄지만, 필드 접근 비용이 커진다.
    • 지연 초기화가 성능을 느리게 할 수도 있다.
  • 멀티 스레드 환경에서는 지연 초기화 필드를 2개 이상의 스레드가 공유하면 반드시 동기화 해야 하기 때문에 구현이 어렵다.
  • 대부분의 상황에서 일반적인 초기화가 지연 초기보다 낫다.
  • 인스턴스 필드 초기화하는 일반적인 방법
private final FieldType field = computeFieldValue();
// 지연 초기화가 초기화 순환성(initialization circularity)을 깨뜨릴 것 같을 때 synchronized 접근자 사용하기

private FieldType field;
private synchronized FieldType getFieId() { 
    if (field = null)
        field = computeFieldValue(); 
    return field;
}
  • 정적 필드를 지연 초기화해야 할 때, 지연 초기화 홀더 클래스(lazy initialization holder class) 관용구 사용
private static class FieldHolder {
static final FieldType field = compiiteFieldValue();
}
private static FieldType getFieId() { return FieldHolder.field; }
  • 인스턴스 필드를 지연 초기화해야 할 때, 이중검사(double-check) 관용구 사용 
private volatile FieldType field;

private FieldType getFieId() {
        FieldType result = field;
        if (result != null) { // 첫 번째 검사 (락 사용 안 함)
            return result;
            
        synchronized(this) {
          if (field = null) // 두 번째 검사 (락 사용)
            field = computeFieldValue(); 
          return field;
        }
}

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

  • 여러 스레드가 실행 중일 때, 운영체제의 스레드 스케줄러는 스레드 실행 순서와 시간을 정한다.
  • 구체적인 스케줄링 정책은 운영체제마다 다르다.
  • 정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램은 다른 플랫폼에 이식하기 어렵다.
  • 견고하고 빠르고 이식성 좋은 프로그램 작성 방법
    • 실행 가능 스레드의 평균 개수를 프로세서 수보다 크게 많아지지 않도록 해야한다.
  • 실행 가능 스레드 수를 적게 유지하는 주요 기법
    • 각 스레드가 유용한 작업 후, 다음 일이 생기기 전까지 대기하기
    • 당장 처리할 작업이 없으면 실행 X
    • ex. 실행자 프레임워크 : 스레드 풀 크기를 적절히 설정하고 작업은 짧게 유지한다.

+ Recent posts