아이템 78: 공유 중인 가변 데이터는 동기화해 사용하라
여러 스레드가 하나의 가변 데이터를 사용할 때에 입출력 시 동기화에 신경쓰지 않는다면 잘못된 동작을 유발할 수 있다.
// 코드 78-1 잘못된 코드 - 이 프로그램은 얼마나 오래 실행될까? (415쪽)
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;
}
}
- 여러 스레드가 공유하는 가변 데이터를 동기화하지 않을 경우 다른 스레드에서 변경한 값을 언제 보게 될 지 알 수 없다.
- 컴파일러 최적화 수행 결과 의도하지 않은 대로 동작할 수도 있다.
멀티 스레드 환경에서 가변 공유 데이터를 다루는 방법
- 멀티스레드에서 가변 데이터를 공유하지 말자. (최선)
→ synchronized
를 사용해 동기화하자.
동기화 시에는 읽기와 쓰기 모두를 동기화해야 한다.
private static boolean stopRequested;
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized boolean stopRequested() {
return stopRequested;
}
→ volatile
을 사용하면 항상 가장 최근에 기록된 값을 읽도록 보증한다. (메모리 접근)
private static volatile boolean stopRequested;
- 하지만 원자적이지 않은 연산 수행 시에는 동기화해야 하며, 하지 않을 경우 안전 실패가 발생할 가능성이 있다.
→ java.util.concurrent에서 제공하는 Atomic* 자료구조를 사용하면, 동기화 락 없이도 스레드 안전하게 구현이 가능하다.
아이템 79: 과도한 동기화는 피하라
너무 과한 동기화는 성능을 떨어뜨리고 데드락을 유발하거나, 안전 실패의 원인이 된다.
응답 불가나 안전 실패를 피하려면 동기화 메서드나 동기화 블럭 안에서는 제어를 클라이언트에 양도해서는 안된다.
외계인(?) 메서드
// 코드 79-1 잘못된 코드. 동기화 블록 안에서 외계인 메서드를 호출한다. (420쪽) 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); } } ... public class Test2 { 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) // 값이 23이면 자신을 구독해지한다. s.removeObserver(this); } }); for (int i = 0; i < 100; i++) set.add(i); } }
테스트 실행 시 ConcurrentModificationException 발생!
→ 동기화 블럭 안에서 클라이언트 메서드가 호출되었기 때문에 synchronized의 보호를 받지 못한다.
// 코드 79-3 외계인 메서드를 동기화 블록 바깥으로 옮겼다. - 열린 호출 (424쪽) 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); }
// 코드 79-4 CopyOnWriteArrayList를 사용해 구현한 스레드 안전하고 관찰 가능한 집합 (425쪽) 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); } @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; }
동기화 영역은 최대한 작게 가져가자.
합당한 이유가 있을 때만 내부에서 동기화하고, 동기화 여부를 문서에 명확히 밝히자.
아이템 80: 스레드보다는 실행자, 태스크, 스트림을 애용하라
작업 큐를 만들기 위해 스레드를 직접 다루기보다는 java.util.concurrent 하위의 실행자 프레임워크를 이용하자.
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.execute(runnable);
exec.shutdown();
실행자 프레임워크의 기능
- 특정 태스크 완료 대기
- 태스크 모음 중 아무거나 하나, 혹은 모든 태스크 완료 대기
- 실행자 서비스 종료 대기
- 완료된 태스크의 결과를 차례로 받기
- 태스크를 특정 시간, 혹은 주기로 실행하기
프로그램 규모에 따라 각각 다른 스레드 관리 메커니즘을 사용하는 실행자 서비스를 생성할 수 있다.
ex) Executors.newCachedThreadPool, Executors.newFixedThreadPool, …
자바 7부터는 ForkJoinPool을 지원하도록 개량되어서 효율성이 개선되었다.
아이템 81: wait과 notify보다는 동시성 유틸리티를 애용하라
wait과 notify는 올바르게 사용하기 매우 어려우므로, 고수준 동시성 유틸리티를 사용하는 것이 좋다.
고수준 동시성 유틸리티의 종류
- 실행자 프레임워크
- 동시성 컬렉션
- ConcurrentHashMap 등 내부에서 동기화를 효율적으로 구현한 컬렉션
- 상태 의존적 메서드들이 추가되어 있음 ex) putIfAbsent
- 동기화 장치
- CountDownLatch, Semaphore, …
// 코드 81-3 동시 실행 시간을 재는 간단한 프레임워크 (433-434쪽)
public class ConcurrentTimer {
private ConcurrentTimer() { } // 인스턴스 생성 불가
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;
}
}
어쩔 수 없이 wait/notify를 사용해야 할 때에는 wait 호출 시 while 문 안에서만 호출하는 표준 방식을 따르자.
synchronized (obj) {
while (<조건이 충족되지 않았다>)
obj.wait(); // (락을 놓고, 깨어나면 다시 잡는다.)
... // 조건이 충족됐을 때의 동작을 수행한다.
}
아이템 82: 스레드 안전성 수준을 문서화하라
멀티스레드 환경에서 API를 안전하게 사용하려면 클래스가 지원하는 스레드 안전성 수준을 명시해야 한다.\
스레드 안전성 수준
- 불변
- 무조건적 스레드 안전: 수정될 수 있으나 내부에서 동기화해서 별도의 처리 없이 스레드 안전하게 사용 가능하다.
- 조건부 스레드 안전: 일부 스레드를 사용하려면 외부 동기화가 필요하다.
- 스레드 안전하지 않음: 동시에 사용하기 위해서는 외부 동기화가 필요하다.
- 스레드 적대적: 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다. (정적 데이터를 동기화 없이 수정하는 경우)
조건부 스레드 안전한 클래스는 주의해서 어떤 순서로 호출할 때 동기화 로직이 필요한지, 어떤 락을 얻어야 하는지 기술해야 한다.
공개된 락을 사용하는 경우 클라이언트에서 락을 풀지 않는 서비스 거부 공격을 할 수 있기 때문에, 비공개 락 객체를 사용하고 final로 선언하는 것이 좋다.
아이템 83: 지연 초기화는 신중히 사용하라
상황에 따라 지연 초기화는 오히려 성능을 더 느리게 만들 수 있으므로 신중히 사용해야 한다.
대부분의 상황에서는 일반적인 초기화가 좋다.
멀티스레드 환경에서의 지연 초기화
지연 초기화가 초기화 순환성을 깨뜨릴 것 같으면 synchronized를 단 접근자를 사용하자.
// 코드 83-2 인스턴스 필드의 지연 초기화 - synchronized 접근자 방식 (443쪽) private FieldType field2; private synchronized FieldType getField2() { if (field2 == null) field2 = computeFieldValue(); return field2; }
성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스 관용구를 사용하자.
// 코드 83-3 정적 필드용 지연 초기화 홀더 클래스 관용구 (443쪽) private static class FieldHolder { static final FieldType field = computeFieldValue(); } private static FieldType getField() { return FieldHolder.field; }
성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중 검사 관용구를 사용하자.
→ 이미 초기화된 경우 synchronized 블럭에 진입하지 않아서 접근 비용이 작아진다.
// 코드 83-4 인스턴스 필드 지연 초기화용 이중검사 관용구 (444쪽) private volatile FieldType field4; private FieldType getField4() { FieldType result = field4; if (result != null) // 첫 번째 검사 (락 사용 안 함) return result; synchronized(this) { if (field4 == null) // 두 번째 검사 (락 사용) field4 = computeFieldValue(); return field4; } }
반복해서 초기화해도 상관없는 경우 동기화 없이 단일 검사해도 괜찮다.
아이템 84: 프로그램의 동작을 스레드 스케줄러에 기대지 말라
스레드 스케줄러의 동작은 플랫폼, 운영체제마다 다르기 때문에 다른 플랫폼에 이식하기 어려워질 수 있다.
실행 가능한 스레드의 수가 프로세스의 수보다 과도하게 많아서는 안 된다.
스레드는 당장 처리해야 할 작업이 없다면 실행되어서는 안 된다.
스레드는 절대 바쁜 대기 상태가 되어서는 안 된다.
// 코드 84-1 끔찍한 CountDownLatch 구현 - 바쁜 대기 버전! (447쪽) public class SlowCountDownLatch { private int count; public SlowCountDownLatch(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--; }
→ 이 문제를 고친답시고 Thread.yield 등을 사용한다면 JVM 구현체별로 다른 효과에 이식성만 나빠지는 결과를 초래할 수 있다.