BottleH Blog

이펙티브자바 3판 11장 정리

    Tags

  • Java
이펙티브자바 3판 11장 정리 thumbnail

11장 동시성

스레드는 여러 활동을 동시에 수행할 수 있게 해준다. 하지만 신중히 프로그래밍 해야 한다.

목차


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

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

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

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

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

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

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

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


synchronized 키워드는 해당 메서드나 블록을 한번에 한 스레드식 수행하도록 보장한다.

자바 언어 명세는 스레드가 필드를 읽을 때 항상 '수정이 완전히 반영된' 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 '보이는가'는 보장하지 않는다. 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.

78-1. 다른 스레드를 멈추는 올바른 방법

  1. 첫 번째 스레드는 자신의 boolean 필드를 폴리앟면서 그 값이 true가 되면 멈춘다.
  2. Thread.stop은 데이터가 훼손될 수 있어 deprecated API로 지정되어 있다. 쓰지말자!!
public class StopThread{ 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 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(); } }

위의 코드는 정상적으로 1초 후에 종료된다.

  • 쓰기와 읽기 모두 동기화되지 않으면 동작을 보장하지 않는다.

78-2. volatile 한정자

volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 일게 됨을 보장한다.

public class StopThread{ 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++; }

위 코드는 동기화 없이는 올바로 작동하지 않는다.

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

결론: 여러 스레드가 가변 데이터를 공유한다면 그 데이터를 읽고 쓰는 동작은 반드시 동기화 해야 한다. 동기화 하지 않으면 한 스레드가 수행한 변경을 다른 스레드가 보지 못할 수도 있다.

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


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

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

  • 동기화된 영역 안에서는 재정의할 수 있는 메서드는 호출해서도 안 되고, 클라이언트가 넘겨준 함수 객체를 호출해서도 안된다.

  • 동기화된 영역을 포함한 클래스 관점에서는 이런 메서드는 모두 외계인 메서드이다.

79-1. 외계인 메서드 문제

public class ObservableSet<E> extends ForwardingSet<E> { public ObservableSet(Set<E> set) { super(set); } private final List<SetObserver<E>> observers = new ArrayList<>(); public void addObserver(SetObserver<E> observer) { synchronized(observers) { observers.add(observer); } } public boolean removeObserver(SetObserver<E> observer) { synchronized(observers) { return observers.remove(observer); } } private void notifyElementAdded(E element) { synchronized(observers) { for (SetObserver<E> observer : observers) observer.added(this, element); } } @Override public boolean add(E element) { boolean added = super.add(element); if (added) notifyElementAdded(element); return added; } @Override public boolean addAll(Collection<? extends E> c) { boolean result = false; for (E element : c) result |= add(element); // notifyElementAdded를 호출한다. return result; } } @FunctionalInterface public interface SetObserver<E> { // ObservableSet에 원소가 더해지면 호출된다. void added(ObservableSet<E> set, E element); }

집합에 원소가 추가되면 알림을 받는 옵저버 패턴을 사용한 예제 코드이다. 이 코드를 이용해 살펴보자.

  1. 예외 발생
public static void main(String[] args) { ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>()); set.addObserver(new SetObserver<>() { public void added(ObservableSet<Integer> s, Integer e) { System.out.println(e); if (e == 23) s.removeObserver(this); } }); for (int i = 0; i < 100; i++) set.add(i;) }

ConcurrentModificationException이 발생한다. 정작 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다.

  1. 쓸데없이 백그라운드 스레드를 사용
set.addObserver(new SetObserver<>() { public void added(ObservableSet<Integer> s, Integer e) { System.out.println(e); if (e == 23) { ExecutorService exec = Executors.newSingleThreadExecutor(); try { exec.submit(() -> s.removeObserver(this)).get(); } catch (ExecutionException | InterruptedException ex) { throw new AssertionError(ex); } finally { exec.shutdown(); } } } });

이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다.

  • 백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 메인 스레드가 락을 쥐고 있음.
  • 메인 스레드는 백그라운드 스레드가 관찰자를 제거하기만을 기다림.

79-2. 예외, 교착상태 해결방법

  1. 외계인 메서드를 동기화 블록 바깥으로 옮기기
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); } }
  1. 자바의 동시성 컬렉션 라이브러리의 CopyOnWriteArrayList 사용
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); }

79-3. 성능 측면에서의 동기화

과도한 동기화는 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용이다.

가변 클래스를 작성할 때는

  1. 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자.
  2. 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자.

결론: 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자. 동기화 영역 안에서의 작업은 최소한으로 줄이자.

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


80-1. 실행자 프레임워크

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

ExecutorService exec = Executors.newSingleThreadExecutor(); exec.execute(runnable); exec.shutdown();
  • 특정 태스크가 완료되기를 기다린다.
  • 태스크 모음 중 아무것 하나(invokeAny) 혹은 모든 태스크(invokeAll)가 완료되기를 기다린다.
  • 실행자 서비스가 종료하기를 기다린다.(awaitTermination)
  • 완료된 태스크들의 결과를 차례로 받는다.(ExecutorCompletionService)
  • 태스크를 특정 시간에 혹은 주기적으로 실행하게 한다.(ScheduledThreadPllExecutor)

80-2. 스레드풀

Executors.newCachedThreadPool은 가벼운 프로그램을 실행하는 서버에 적합하다. 요청받은 태스크를 큐에 쌓지 않고 즉시 스레드에 위임돼 실행된다. 가용한 스레드가 없다면 새로 하나를 생성한다. 서버가 무겁다면 CPU 이용률이 100%로 치닫고 새로운 태스크가 도착할 때마다 다른 스레드를 생성하며 상황이 더욱 악화시킨다. 따라서 무거운 프로덕션 서버에는 Executors.newFixedThreadPool을 선택하여 스레드 개수를 고정하는 것이 좋다.

80-3. 태스크

작업 큐를 손수 만들고, 스레드를 직접 다루는 것은 일반적으로 삼가야한다.

  • 스레드가 작업 단위와 수행 메커니즘 역할을 모두 수행하게 됨.

반면 실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다.

  • 작업 단위를 나타내는 핵심 추상 개념이 태스크다.

태스크의 종류RunnableCallable로 나눌 수 있다. CallableRunnable과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다.

자바 7부터 실행자 프레임워크는 포크-조인(fork-join) 태스크를 지원하도록 확장됐다. ForkJoinTask의 인스턴스는 작은 하위 태스크로 나뉠 수 있고 ForkJoinPool을 구성하는 스레드들이 이 태스크들을 처리하며, 일을 먼저 끝낸 스레드가 다른 스레드의 남은 태스크를 가져와 대신 처리할 수도 있다.이렇게 하여 최대한의 CPU 활용을 뽑아내어 높은 처리량과 낮은 지연시간을 달성한다. 포크-조인 풀을 이용해 만든 병렬 스트림을 이용하면 적은 노력으로 그 이점을 얻을 수 있다.

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


waitnotify는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티(자바5에서 도입)를 사용하자.

  • 고수준 동시성 유틸리티는 실행자 프레임워크(아이템80), 동시성 컬렉션, 동기화 장치로 나눌 수 있다.

81-1. 동시성 컬렉션

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

  • 높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다.(아이템79)
  • 즉, 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.
  • 그렇다면, 여러 메서드를 원자적으로 묶어 호출하는 일 역시 불가능할 것이다.
  • 그래서, 여러 기본 동작을 하나의 원자적 동작으로 묶는 '상태 의존적 수정' 메서드들이 추가되었다.
    • 몇몇 메서드는 매우 유용하여 일반 컬렉션 인터페이스의 디폴트 메서드 형태로 추가되었다.
    • MapputIfAbsent(key, value)

다음은 Stringintern 메서드를 아래와 같이 흉내를 낸 것이다.

private static final ConcurrentMap<String, String> map = new ConcurrentHashMap<>(); 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은 동시성이 뛰어나며 속도도 무척 빠르다. 하지만 String.intern은 메모리 누수를 방지하는 기술도 들어가는 것을 감안해야 한다.

  • Collections.synchronizedMap 보다는 ConcurrentHashMap을 사용하자.

또한, BlockingQueue.take 는 큐의 첫 원소를 꺼낸다. 만약 큐가 비었다면 새로운 원소가 추가될 때까지 기다린다.

  • 작업 큐(생산자-소비자 큐)에 쓰이기 적합하다.
  • 대부분의 실행자 서비스 구현체에서 사용한다.

81-2. 동기화 장치

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

  1. CountDownLatch
    • 일회성 장벽(하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다.)
    • 생성자는 int 값을 받으며, 이 값이 latch(걸쇠)의 countDown 메서드를 몇 번 호출해야 대기 중인 스레드들을 깨우는지를 결정함.

아래 코드는 동시 실행시간을 재는 간단한 코드이다.

public class Test { 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; } }
  • 시간 간격을 잴 때는 항상 System.currentTimeMillis가 아닌 System.nanoTime을 사용하자.
    • System.nanoTime은 더 정확하고 정밀하며 시스템의 실시간 시계의 시간 보정에 영향 받지 않는다.
  1. Semaphore
    • CountDownLatch와 함께 가장 자주 쓰임
  2. Phaser
    • 가장 강력한 동기화 장치

81-3. wait와 notify

새로운 코드라면 wait, notify가 아닌 동시성 유틸리티를 써야 한다. 하지만 어쩔 수 없이 쓴다면, 반드시 대기 반복문(wait loop) 관용구를 사용하라. 그리고, 반복문 밖에서는 절대로 호출하지 말자.

synchronized (obj) { while (<조건이 충족되지 않았다>) obj.wait(); // 락을 놓고, 깨어나면 다시 잡는다. ... // 조건이 충족됐을 때의 동작을 수행한다. }
  • 대기 전에 조건을 검사하여 조건이 충족되었다면 wait를 건너뛰게 한 것은 응답 불가 상태를 예방하는 조치다.

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


API 문서에 synchronized 한정자가 보이는 메서드는 스레드 안전하다는 이야기는 몇 가지 면에서 틀렸다.

  • 자바독 기본 옵션에서 생성한 API 문서에는 synchronized 한정자가 포함되지 않는다.
    • 메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐 API에 속하지 않는다.
    • 즉, 스레드 안전하다가 믿기 어렵다.
  • 스레드 안전성은 모 아니면 도 라는 오해에 뿌리를 둔 것이다.
    • 멀티스레드 환경에서도 API를 안전하게 사용하게 하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야 한다.

82-1. 스레드 안전성이 높은 순서

  1. 불변

    • 이 클래스의 인스턴스는 마치 상수와 같아서 외부 동기화도 필요 없다.

    • String, Long, BigInteger

  2. 무조건적 스레드 안전

    • 이 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화 없이 동시에 사용해도 안전하다.

    • AtomicLong, ConcurrentHashMap

  3. 조건부 스레드 안전

    • 무조건적 스레드 안전과 같으나, 일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다.

    • Collections.synchronized

  4. 스레드 안전하지 않음

    • 이 클래스의 인스턴스는 수정될 수 있다.

    • ArrayList, HashMap

  5. 스레드 적대적

    • 이 클래스는 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다.

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


지연 초기화(lazy initialization)는 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법이다. 그래서 값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않는다.

  • 정적 필드와 인스턴스 필드 모두에 사용할 수 있다.
  • 최적화 용도이자, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다.
  • 필요할 때 까지는 하지 말라(아이템67)
    • 클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만 그 대신 지연 초기화하는 필드에 접근하는 비용은 커진다.

83-1. 일반적인 초기화

멀티스레드 환경에서는 지연 초기화를 하기가 까다롭다. 지연 초기화하는 필드를 둘 이상의 스레드가 공유한다면 어떤 형태로든 반드시 동기화해야 한다.

대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다. 아래 코드는 일반적인 초기화 방법이다.

private final FieldType field = computeFieldValue();

83-2. 지연 초기화

지연 초기화가 초기화 순환성(initialization circularity)을 깨뜨릴 것 같으면 synchronized를 단 접근자를 사용하자.

private FieldType field; private synchronized FieldType getField(){ if(field == null){ field = computeFieldValue(); } return field; }

83-3. 정적 필드 지연 초기화

성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스 관용구를 사용하자.

private static class FieldHolder{ static final FieldType field = computeFieldValue(); } private static FieldType getField(){ return FieldHolder.field; }

지연 초기화 홀더 클래스는 getField가 처음 호출되는 순간 FieldHolder.field가 처음 읽히면서, 비로소 FieldHolder 클래스 초기화를 촉발한다.

  • getField가 필드에 접근하면서 동기화를 전혀 하지 않으니 성능이 느려질 거리가 전혀 없다.

83-4. 인스턴스 필드 지연 초기화

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

  • 한 번은 동기화 없이 검사하고, 필드가 아직 초기화되지 않았다면 동기화하여 한 번 더 검사한다.
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 지역 변수의 역할은 필드가 이미 초기화된 상황에서는 그 필드를 딱 한 번만 읽도록 보장하는 역할을 한다.

  • 성능을 높여주고, 저수준 동시성 프로그래밍에 표준적으로 적용되는 더 우아한 방법이다.

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


어떤 스레드가 실행 중이면 운영체제의 스레드 스케줄러가 어떤 스레드를 얼마나 오래 실행할지 정한다. 정상적인 운영체제라면 이 작업을 공정하게 수행하지만 구체적인 스케줄링 정책은 운영체제마다 다를 수 있다. 하지만 이 정책에 좌지우지돼서는 안 된다.

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

84-1. 견고하고 빠릿하고 이식성 좋은 프로그램을 작성하는 방법

  1. 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하자!

    • 스레드 스케줄러가 고민할 거리가 줄어듬.
    • 스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안 된다.
  2. 실행 준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들자.

    • 스레드 스케줄링 정책이 아주 상이한 시스템에서도 동작이 크게 달라지지 않는다.
  3. 실행 가능한 스레드의 수와 전체 스레드 수는 구분해야 한다.

84-2. Thread.yield

Thread.yield: 다른 스레드에게 실행 양보하는 메소드

특정 스레드가 다른 스레드들과 비교해 CPU 시간을 충분히 얻지 못해서 간신히 돌아가는 프로그램을 보더라도 Thread.yield를 써서 문제를 고쳐보려는 유혹을 떨쳐내자.

  • 증상은 호전이 되더라도, 이식성은 그렇지 않을 것이다.
  • 테스트할 수단도 없다!!
Written by@BottleH
Back-End Developer

GitHub