Effective Java - Chapter 11
Effective Java
[Effective Java 3/E] 11장 동시성
Item 78. 공유 중인 가변 데이터는 동기화해 사용하라
- synchronized 키워드는 해당 메서드나 블록을 한 번에 한 스레드씩 수행하도록 동기화를 보장
- 동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요
- Thread.stop 은 사용하지 말자
- 스레드를 멈추는 작업은 권장되지 않는다
- 다른 스레드를 멈추는 방법
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);
//다른 스레드에서 이 스레드를 멈추고자 할 때 true로 변경
stopRequested = 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)
while (!stopRequested())
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
// stopRequested = true;
requestStop();
}
}
- 쓰기(requestStop)와 읽기(stopRequested) 모두 동기화되지 않으면 동작을 보장하지 않는다
- Volatile으로 선언?
public class StopThread {
// 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;
}
}
- 일련번호를 생성할 의도로 작성된 잘못된 코드
- 증가 연산자 때문에 문제 발생
- 먼저 값을 읽고, 1증가한 값을 다시 저장하게 되어, 사실상 nextSerialNumber 필드에 두번 접근하게 됨
- 이때, 두번 째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어 가면 첫번째 스레드와 똑같은 값을 돌려받는다
- synchronized 를 붙여 동기화하여 해결
- 가변 데이터는 단일 스레드에서만 쓰자
- 불변 데이터만 공유할 게 아니라면 아무것도 공유하지 말자
Item 79. 과도한 동기화는 피하라
- 과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 예측할 수 없는 동작을 낳기도 한다.
- 응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다.
- 동기화된 영역 안의 재정의할 수 있는 메서드는 호출하면 안되며, 이런 메서드는 동기화된 영역 입장에서는 외계인 메서드
- 외계인 메서드
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;
}
}
- 외계인 메서드 예외 상황
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;)
}
- added 메서드에서 removeObserver 호출, 또 여기서 observers.remove 호출 ⇒ 문제 발생
- notifyElementAdded 가 순회하고 있는 리스트에서 원소를 제거하려고 하는 것 ⇒ 이 순회는 동기화 블록 안에 있어서 동시 수정은 일어나지 않지만 자기 자신이 콜백 거쳐서 되돌아와 수정하는 것은 못막는다
- 외계인 메서드 해결법
- 외계인 메서드 호출을 동기화 블록 바깥으로 옮긴다(열린 호출)
- CopyOnWriteArrayList를 사용
- 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현되었다
- 내부 배열은 절대 수정 X, 순회시 락이 필요없어서 빠르다
- 동기화 영역에서는 가능한 한 일을 적게 하는 것
Item 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라
- Executor Framework
- 인터페이스 기반의 유연한 태스크 실행 기능을 담은 실행자 프레임워크
- 간단하게 작업 큐를 생성할 수 있다
-
E.F의 주요 기능
- 특정 태스크가 완료되기를 기다릴 수 있다
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.submit(() -> s.removeObserver(this)).get(); // 끝날 때까지 기다린다.
- 태스크 모음 중 어느 하나 혹은 모든 태스크가 완료되는 것을 기다릴 수 있다
//하나
List<Future<String>> futures = exec.invokeAll(tasks);
System.out.println("All Tasks done");
//모든 태스크
exec.invokeAny(tasks);
System.out.println("Any Task done");
- 실행자 서비스가 종료하기를 기다린다.
final int MAX_SIZE = 3;
ExecutorService executorService = Executors.newFixedThreadPool(MAX_SIZE);
//ExecutorCompletionService
**ExecutorCompletionService**<String> executorCompletionService = new ExecutorCompletionService<>(executorService);
List<Future<String>> futures = new ArrayList<>();
futures.add(executorCompletionService.submit(() -> "madplay"));
futures.add(executorCompletionService.submit(() -> "kimtaeng"));
futures.add(executorCompletionService.submit(() -> "hello"));
for (int loopCount = 0; loopCount < MAX_SIZE; loopCount++) {
try {
String result = executorCompletionService.take().get();
System.out.println(result);
} catch (InterruptedException e) {
//
} catch (ExecutionException e) {
//
}
}
executorService.shutdown();
- 완료된 태스크들의 결과를 차례로 받는다.
//ScheduledThreadPoolExecutor
**ScheduledThreadPoolExecutor** executor = new ScheduledThreadPoolExecutor(1);
executor.scheduleAtFixedRate(() -> {
System.out.println(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.format(LocalDateTime.now()));
}, 0, 2, TimeUnit.SECONDS);
// 2019-09-30 23:11:22
// 2019-09-30 23:11:24
// 2019-09-30 23:11:26
// 2019-09-30 23:11:28
// ...
- 작업 큐를 직접 만들거나 스레드를 직접 다루는 것을 삼가고 실행자 프레임워크를 이용하자
Item 81. wait와 notify보다는 동시성 유틸리티를 애용하라
- wait과 notify는 사용이 까다롭다. 따라서 고수준 동시성 유틸리티를 사용하자
- java.util.concurretn 패키지
- 동시성 컬렉션
- 동기화 장치
- 실행자 프레임워크 [아이템 80]
-
동시성 컬렉션
- List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션
- 동시성 무력화가 불가능, 외부에서 락 걸면 오히려 속도가 느려진다
- 동시성 무력화가 불가능 하므로 여러 메서드를 원자적으로 묶어 호출 역시 불가능하고 여러 기본동작을 하나의 원자적 동작으로 묶는 상태 의존적 메서드들이 추가됨
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;
}
- putIfAbsent는 Map의 디폴트 메서드, key가 없을 때 value를 추가한다
- 기존 값 있으면 그 값 반환하고 없는 경우 null 반환
-
synchronizedMap 보다는 ConcurrentHashMap을 사용하자
-
동기화 장치
- 스레드가 다른 스레드를 기다릴 수 있게 하여 서로의 작업을 조율할 수 있도록 해준다
- 대표적인 동기화 장치로는 CountDownLatch와 Semaphore가 있으며 CyclicBarrier와 Exchanger도 있다. 가장 강력한 동기화 장치로는 Phaser가 있다.
- ex) CountDownLatch
public class CountDownLatchTest {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(5);
try {
long result = time(executorService, 3,
() -> System.out.println("hello"));
System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
} finally {
executorService.shutdown();
}
}
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;
}
}
- 위 코드의 executor는 concurrency 매개변수로 지정한 값만큼 스레드 생성할 수 있어야 함. 그렇지 않으면 수행이 끝나지 않고 기아 교착 상태가 된다
- wait메서드를 사용할 때는 반드시 대기 반복문(wait loop) 관용구를 사용. notify를 사용할 때는 응답 불가 상태에 빠지지 않도록 각별히 주의하자
Item 82. 스레드 안전성 수준을 문서화하라
- Java API 문서에 synchronized 키워드가 보이면 안전하다? 그럴 수도 있지만 이건 그냥 구현 이슈일 뿐 API에 속하지 않는다. 따라서 멀티스레드 환경에서도 API를 안전하게 사용하려면 스레드 안전성 수준을 정확히 명시해야 한다.
- 스레드 안전성을 높은 순서대로 본다면
- 불변(immutable)
- String, Long 같은 인스턴스는 마치 상수같아서 외부 동기화도 필요 없다
- 무조건 적인 스레드 안전(unconditionally thread-safe)
- AtomicLong, ConcurrentHashMap 등의 인스턴스는 수정될 수 있지만 내부에서도 충실히 동기화해서 별도의 외부 동기화 필요없다
- 조건부 스레드 안전(conditionally thread-safe)
- Collections.synchronized 래퍼 메서드가 반환한 컬렉션 등은 동시에 사용하려면 외부 동기화가 필요하다
- 스레드 안전하지 않음(not thread-safe)
- ArrayList, HashMap 등은 수정될 수 있기에 외부 동기화 로직으로 감싸야 한다
- 스레드 적대적(thread-hostile)
- 외부 동기화로 감싸도 멀티스레드 환경에서 안전하지 않다
- 동기화에 대한 문서화
- 어떤 순서로 호출할 때 외부 동기화 로직이 필요한 지, 그 순서대로 호출하려면 어떤 락 혹은 락을 얻어야만 하는지 알려줘야 한다
- lock 멤버는 항상 final로 선언하자
- 우연히라도 lock 객체가 교체되는 상황을 방지하기 위함
Item 83. 지연 초기화는 신중히 사용하라.
- 지연 초기화는 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법
- but, 필요할 때까지 하지마라
private final FieldType field = computeFieldValue();
- 지연 초기화가 초기화 순환성을 깨뜨릴 것 같으면 synchronized를 단 접근자를 이용
private FieldType field;
private synchronized FieldType getField() {
if (field == null)
field = computeFieldValue();
return field;
}
- 성능 때문에 정적 필드를 초기화해야 한다면 지연 초기화 홀더 클래스를 사용
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
private static FieldType getField() {
return FieldHolder.field;
}
- 성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중검사(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;
}
}
- 반복해서 초기화해도 상관없는 인스턴스 필드를 지연 초기화할 때가 있는데 이럴 때는 두 번째 검사를 생략 (단일검사)
// volatile는 필요하다.
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null)
field = result = computeFieldValue();
return result;
}
- 대부분의 필드는 지연시키지 말고 곧바로 초기화해야 한다. 성능 때문에 지연 초기화를 써야한다면 올바르게 사용하자. 그렇지 않으면 오히려 성능을 저하시킬 수 있다.
Item 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라
- 스레드 스케줄러에 의존하지 말자. 스케줄링 정책은 OS마다 다를 수 있기에 의존하게 되면 다른 플랫폼에 이식하기 어려워진다
- 이식성 좋은 프로그램은 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것
-
스레드는 바쁜 대기 상태가 되면 안 된다
- 바쁜 대기는 스레드가 할 일도 없는 데 계속 실행하면서 대기하는 것
- 프로세서에 큰 부담을 준다
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--;
}
}
- 공유 객체 상태가 바뀔 때까지 쉬지 않고 검사, concurrent의 CountDownLatch보다 훨씬 더 느려진다
- Thread.yield는 동작않는 스레드가 대기 상태 되는 등 다른 스레드에게 양보하는 것. 이 때 이 특성을 사용하고자 yield를 사용하는 것은 삼가자
- 테스트할 수단이 없다.
- 성능이 좋아지더라도 이식성이 나빠질 수 있다
- 프로그램의 동작을 스레드 스케줄러에 의존하지 말자. Thread.yield 같은 우선순위에 의존해서도 안 된다
댓글남기기