Effective Java - Chapter 11

16 분 소요

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 같은 우선순위에 의존해서도 안 된다

댓글남기기