Search
Duplicate

10.동시성

목차

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

Previous

개요

synchronized 키워드를 통해 메서드 혹은 블록을 잠금(Lock)으로써 한 번에 한 스레드만 수행할 수 있도록 할 수 있다. 그리고 대부분 이 기능을 한 스레드가 점유 혹은 변경중에 상태가 일관되지 않은 순간을 다른 스레드에서 보지 못하도록 하는 용도로만 생각한다.
그런데, 여기에 동기화의 중요한 기능을 하나 더 얘기할 수 있다. 바로 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.

원자성(atomic)

언어 명세를 보면 long, double을 제외하고는 변수를 읽고 쓰는 동작이 원자적이라 한다.
멀티 스레드 환경에서 변수를 따로 동기화 하지 않고 수정하더라도 스레드가 정상적으로 저장한 값을 온전히 읽어옴을 보장한다는 의미이다.
그래서 얼핏 동기화를 하지 않아도 될 것 같지만, 이는 위험한 생각이다.
자바 언어 명세는 스레드에서 필드를 읽을 때 수정이 반영된 값을 얻는다고 보장하지만, 한 스레드가 저장한 값이 다른 스레드에게 보이는지는 보장하지 않는다.
그렇기에 동기화는 배타적 실행 뿐 아니라 스레드 사이의 안정적인 통신에도 필수다.

자바의 메모리 모델

자바의 메모리 모델은 한 스레드가 만든 변화를 다른 스레드에게 언제 어떻게 보일지를 규정하고 있기 때문인데, 이 때문에 공유 중인 가변 데이터를 동기화에 실패하면 문제가 발생할 수 있다.

읽기/쓰기가 모두 동기화되야 한다.

쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다.
하나만 동기화해도 동작하는것으로 보이는 기능이 있다고 반례로 생각해서는 안된다. 사실 이런 경우 동기화가 없어도 원자적으로 동작하는 경우인 경우이다.

호이스팅(hoisting)

OpenJDK 서버에서 VM이 실제로 적용하는 끌어올리기 호이스팅(hoisting)이라는 최적화 기법이 있다. 다음 코드를 통해 확인해보자.
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; } }
Java
복사
이 코드를 분석해보면 backgroundThread 가 start를 하고 1초 뒤 stopRequested 가 true로 값이 변경되면서 스레드가 종료될 것이라고 생각할 수 있다. 하지만, 실제 실행결과는 끝나지 않는 영원한 수행일 것이다. 어째서일까? stopRequested 필드를 동기화하지 않았기 때문인데, 동기화가 되지않은 이 코드에서는 메인 스레드가 수정한 값(stopRequested = true;)을 backgroundThread 에서 언제 바라볼지를 보증할 수 없다. 그렇기에 호이스팅(hoisting)이라는 최적화 기법으로 코드가 최적화 되면서 다음과 같이 될 수 있다.
//before while (!stopRequested){ i++; } //after if(!stopRequested){ while(true) { i++; } }
Java
복사
즉, 이렇게 최적화가 된 결과 프로그램은 응답 불가(liveness failure)상태가 되어 종료되지 않는다.
그러면, 이를 어떻게 동기화를 해서 해결할 수 있을까? 위에서 언급한 synchronized 키워드를 사용하는 방법과 volatile이라는 키워드를 이용하는 방법이 있다.

1. synchronized 사용

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(); } }
Java
복사
이렇게 읽기(stopRequested), 쓰기(requestStop) 메서드를 동기화해서 만들어서 제공함으로써 문제를 해결할 수 있다.

2. volatile 사용

위에서도 말했듯이 동기화는 배타적 수행 뿐 아니라 스레드간 통신이라는 기능도 수행하는데 이 코드에서는 통신 목적으로만 사용되었다. 이 경우 더 빠른 속도로 해결할 수 있다.
stopRequested 필드를 volatile로 선언하는 것인데, 이렇게하면 동기화를 생략해도 된다. 이 키워드를 사용할 경우 배타적 수행과는 상관이 없지만 항상 가장 최근에 기록된 값을 읽게 된다는 점을 보장할 수 있게 된다.
하지만, volatile은 주의해서 사용해야하는데, 값을 수정하는 경우(ex: volatile int num = 0; num ++;) 해당 필드에 두 번 접근하게 되는데, 필드를 읽고, 새로운 값을 저장하는 두 단계로, 이 중간에 다른 스레드에서 값을 읽어오게되면 문제가 될 수 있다. 이런 상황을 안전 실패(safety failure) 라 한다. 그래서 이런 경우 volatile을 빼고 synchronized 한정자를 이용해 해결하고는 한다.

AtomicLong

그 밖에 java.util.concurrent 패키지에서는 동시성 프로그래밍을 위한 여러 자바 표준 기술들을 제공하는데 락 없이도 스레드 안전한 프로그래밍들을 지원하는 클래스들이 다양하게 존재하고, 이 중 AtomicLong이라는 클래스가 있는데, 배타적 실행과 통신 모두 제공하면서 성능도 동기화 버전보다 좋다.
private static final AtomicLong nextSerialNum = new AtomicLong(); public static long generateSerialNumber() { return nextSerialNum.getAndIncrement(); }
Java
복사

그냥, 가변 데이터는 공유를 하지 말자.

사실, 이런 복잡한 방식을 사용하기보다는 애초에 가변 데이터는 스레드간에 공유하지 않는게 좋다.
불변데이터 정도만 공유하는게 제일 안전하고, 가변 데이터는 단일 스레드에서만 쓰도록 하자.
혹은, 한 스레드에서 데이터를 수정 완료한 뒤 다른 스레드에 공유할 때 해당 객체에서 공유하는 부분만 동기화하는 수도 있다. 이러면 다시 해당 객체를 수정하기 전까지 동기화 하지않고 자유롭게 값을 동시에 읽어가도 된다. 이런 객체를 사실상 불변(effectively immutable)이라 하고, 이런 객체를 다른 스레드에 건네는 행위를 안전 발행(safe publication)이라 한다.

정리

가변 데이터를 멀티 스레드 환경에서 사용하지 말자.
그래도 사용해야 한다면 데이터를 읽기/쓰기 하는 동작을 반드시 동기화 하자.
읽기/쓰기 둘 다 동기화 하지 않으면 동작을 보장하지 않는다.
통신 동작만 수행한다면 volatile으로도 충분하다.
그게 아니라 배타적 실행까지 되야한다면 synchronized 한정자 혹은 java.util.concurrent 패키지를 활용한다.
OpenJDK 서버 VM은 호이스팅(hoisting) 최적화 기법을 사용하고, 이 때문에 응답 불가 상태가 될 수 있다.
long, double은 원자성을 제공받지 않기 때문에 원자성이 필요하다면 volatile 키워드를 사용하자.

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

78번 아이템에서 계속 동기화를 하라고 했으면서, 반대가 되는 주장을 하고 있는데, 그 이유는 다음과 같다.
과도한 동기화는 성능을 떨어트리고, 교착상태에 빠지게 된다.
예측할 수 없는 동작을 유발할 수 있다.

응답 불가와 안전 실패를 피하려면, 동기화 메서드(or 블록)안에서는 제어권을 클라이언트에 양도해선 안된다.

동기화 영역안에서 재정의 가능한 메서드를 호출해서는 안된다.
클라이언트가 넘겨준 함수(ex: lambda, anonymous function, ...)를 호출해서도 안된다.
이 경우 동작의 흐름을 외부에 의존하기 때문에, 내가 의도하지 않은 문제를 야기시킬 수 있다.
SetObserver interface
ForwardingSet
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 = true; for(E element: c) result |= add(element); return result; } }
Java
복사
notifyElementAdded 메서드를 보면 동기화 영역안에서 콜백 인터페이스의 인스턴스를 사용해 메서드를 호출하는데, 전달하는 함수로 다음과 같은 코드를 보낸다면 어떻게 될까?
set.addObserver(new SetObserver<>() { public void added(ObservableSet<Integer> s, Integer e) { if(e == 23) s.removeObserver(this); } }
Java
복사
람다는 자기자신을 참조할 수 없기에 익명클래스를 사용했다.
위와같은 코드는 e가 23일때 구독을 해제하고 종료될 것 같지만, 실제로는 ConcurrentModificationException예외를 던진다. 이는 리스트에서 원소를 제거하려 하는데, 동시에 리스트를 순회하려 하기 때문이다. 즉, notifyElementAdded 메서드에서 순회로직은 동기화 블록 안에 있지만, 정작 콜백 메서드로 수정을 하는 것을 막지 못하게 되는 것이다.

교착상태와 재진입(reentant)

set.addObserver(new SetObserver<>() { @Override public void added(ObservableSet<Integer> set, Integer element) { System.out.println(element); if (element == 23) { ExecutorService exec = Executors.newSingleThreadExecutor(); try { exec.submit(() -> set.removeObserver(this)).get(); } catch (ExecutionException | InterruptedException ex) { throw new AssertionError(ex); } finally { exec.shutdown(); } } } });
Java
복사
이 코드를 실행하면 교착상태에 빠지게 되는데 그 수순은 다음과 같다.
1.
set.removeObserver 메서드를 호출하면 관찰자를 잠그려 한다.
2.
하지만, 락을 얻을 수 없다. 메인 스레드에서 락을 쥐고 있기 때문이다.
3.
이 때 동시에 메인 스레드에서는 백그라운드 스레드가 관찰자를 제거하기를 기다리고 있다.
4.
교착상태 발생!
물론, 자바 언어에서는 락의 재진입(reentant)을 허용하기에 교착상태에 빠지지는 않을 수 있다.
이런 재진입 가능 락(ReentrantLock)은 객체 지향 멀티스레드 프로그램을 쉽게 구현할 수 있게 해주지만, 응답 불가(교착 상태)가 될 상황을 안전 실패(데이터 훼손)으로만 바꿔서 문제를 만들 수 있다.

해결책

1. 콜백 메서드를 동기화 바깥으로 옮기자.

이런 문제의 해결책은 이런 문제가 될 여지가 있는 코드를 동기화 블록 바깥으로 옮기면 된다.
private void notifyElementAdded(E element) { List<SetObserver<E>> snapshot = null; synchronized (observers) { snapshot = new ArrayList<>(observers); } for (SetObserver<E> observer : observers) { observer.added(this, element); } }
Java
복사

2. CopyOnWriteArrayList

java.util.concurrent 에서 제공하는 CopyOnWriteArrayList 클래스는 이런 문제를 해결하기 위해 설계된 객체로, ArrayList를 구현하되 내부를 변경하는 작업은 늘 복사본을 만들어 수행하도록 구현되었다. 다른 용도로는 끔찍하게 느리지만, 순회가 대부분이고 간혹 수정할 일이 있는 관찰자 리스트 용도로는 적절하다.

가변 클래스 작성 팁

가변 클래스를 작성하려거든 다음 두 선택지 중 하나를 따르자.

1. 동기화를 하지말고, 클래스를 동시에 사용해야 하는 클래스가 외부에서 동기화도록 하자.

java.util

2. 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자.

락 분할(lock splitting)
락 스트라이이핑(lock striping)
비차단 동시성 제어(nonblocking concurrency control)

정리

외계인 메서드(callback method)를 동기화 영역안에서 호출하지 말자.
동기화 영역 안에서 작업은 최소한으로 줄이자.
가변 클래스를 설계할 때는 스스로 동기화가 필요한지 고민하자.

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

java.util.concurrent

JDK 5에서 다중 스레드 환경에서 동시성을 다루는데 도움을 주는 java.util.concurrent 패키지 등장.
스레드 풀, 동기화, 동시성 컬렉션 등과 같은 기능등을 제공
실행자 프레임워크라 불리는(Executor Framework)라는 인터페이스 기반의 태스크 실행 기능을 담고 있다.
단 한줄로 고성능의 작업 큐를 만들 수 있게 되었다.
ExecutorService exec = Executors.newSingleThreadExecutor();
Java
복사

ExecutorService 주요 기능

태스크 실행(execute(runnable))
public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(5); Runnable task = () -> { System.out.println("Task is executing"); }; // 태스크 실행 executor.execute(task); // 실행자 서비스 종료 executor.shutdown(); }
Java
복사
태스크 우아한 종료(shutdown)
public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(5); // 실행자 서비스 종료 executor.shutdown(); }
Java
복사
특정 태스크 완료 시점 대기(get)
public static void main(String[] args) throws Exception { ExecutorService executor = Executors.newFixedThreadPool(5); Runnable task = () -> { System.out.println("Task is executing"); }; // 태스크 실행 후 Future 객체 반환 Future<?> future = executor.submit(task); // 태스크 완료 시점 대기 future.get(); // 실행자 서비스 종료 executor.shutdown(); }
Java
복사
태스크 모음 중 아무것 하나(invokeAny) 혹은 모든 태스크(invokeAll)가 완료되기를 대기
public static void main(String[] args) throws Exception { ExecutorService executor = Executors.newFixedThreadPool(5); List<Callable<String>> tasks = List.of( () -> "Task 1", () -> "Task 2", () -> "Task 3" ); // 아무 태스크 하나 완료 시점 대기 String result = executor.invokeAny(tasks); System.out.println("Result: " + result); // 모든 태스크 완료 시점 대기 executor.invokeAll(tasks); // 실행자 서비스 종료 executor.shutdown(); }
Java
복사
실행자 서비스가 종료하기를 대기(awaitTermination)
public static void main(String[] args) throws Exception { ExecutorService executor = Executors.newFixedThreadPool(5); // 태스크 실행 executor.execute(() -> { // 작업 수행 }); // 실행자 서비스 종료 executor.shutdown(); // 실행자 서비스 종료하기를 대기 (타임아웃 설정 가능) if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { // 타임아웃 후에도 종료되지 않은 태스크에 대한 처리 } }
Java
복사
완료된 태스크들의 결과를 차례로 받는다(ExecutorCompletionService)
public static void main(String[] args) throws Exception { ExecutorService executor = Executors.newFixedThreadPool(5); ExecutorCompletionService<String> completionService = new ExecutorCompletionService<>(executor); // 태스크 실행 completionService.submit(() -> "Result 1"); completionService.submit(() -> "Result 2"); completionService.submit(() -> "Result 3"); // 완료된 태스크들의 결과를 차례로 받음 for (int i = 0; i < 3; i++) { Future<String> future = completionService.take(); String result = future.get(); System.out.println("Result: " + result); } // 실행자 서비스 종료 executor.shutdown(); }
Java
복사
태스크를 특정 시간에 혹은 주기적으로 실행(ScheduledThreadPoolExecutor)
public static void main(String[] args) throws Exception { ScheduledExecutorService executor = Executors.newScheduledThreadPool(5); Runnable task = () -> { System.out.println("Task is executing"); }; // 태스크를 1초 후에 실행 executor.schedule(task, 1, TimeUnit.SECONDS); // 태스크를 1초 후부터 주기적으로 실행 (처음 실행 후 2초마다 실행) executor.scheduleAtFixedRate(task, 1, 2, TimeUnit.SECONDS); // 일정 시간 후 실행자 서비스 종료 executor.shutdown(); }
Java
복사

태스크(Task)

작업 단위를 나타내는 핵심 추상 개념
두 종류의 태스크가 있다.
Runnable
public interface Runnable { public abstract void run(); }
Java
복사
Callable
Runnable과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다.
public interface Callable<V> { V call() throws Exception; }
Java
복사

Fork-Join 태스크

JDK7 부터 실행자 프레임워크는 포크-조인(fork-join) 태스크를 지원하도록 확장되었다.
포크-조인 풀이라는 특별한 실행자 서비스를 이용해 실행할 수 있다.
ForkJoinTask 인스턴스는 작은 하위 태스크로 나뉠 수 있고 ForkJoinPool을 구성하는 스레드들이 이 태스크들을 처리하며, 일을 먼저 끝낸 태스크는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수도 있다.
더 자세한 내용은 해당 링크를 참고하자(link)

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

개요

wait과 notify를 올바르게 사용하는것은 아주 어렵다.
고수준 동시성 유틸리티를 사용하자 (ex: java.uti.concurrent)
실행자 프레임워크
동시성 컬렉션(concurrent collection)
동기화 장치(synchronizer)

동시성 컬렉션

동시성 컬렉션은 List, Queue, Map과 같은 표준 컬렉션 인터페이스에 동시성을 가미한 고성능 컬렉션이다.
내부적으로 동기화를 각자의 내부에서 수행한다.
동시성 컬렉션에서 동시성을 무력화하는건 불가능하다.
외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.
동기화를 중첩으로 사용한다고 더 안전해지고 그런건 아니기에 불필요하다.
동시성을 무력화하지 못하기에 여러 메서드가 원자적으로 묶여 호출하지는 못하고, 여러 기본 동작을 하나의 원자적 동작으로 묶는 상태 의존적 수정 메서드들이 추가되었다.
Ex: Map의 putIfAbsent(key, value) 메서드
동기화 컬렉션보다 안전과 속도 모두 개선되었기에 동시성 컬렉션을 사용하는걸 권장한다.

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 한다.

그래서 작업들을 조율할 수 있게 해주는데, 예를들어 Queue를 확장한 BlockingQueue도 확장된 기능중 take라는 기능을 볼 수 있는데, 큐의 첫 번째 원소를 꺼내는 기능으로 만약 큐가 비었다면 새로운 원소가 추가될 떄까지 기다린다. 그렇기에 BlockingQueue는 작업 큐로 쓰기 적당하고 ThreadPoolExecutor나 실행자 서비스 구현체에서 BlockingQueue를 사용한다.
그 밖에 또 자주 사용되는 동기화 장치로 다음과 같은 장치들이 있다.
CountDownLatch
Semaphore
CyclicBarrier
Exchanger
Phaser

참고: 스레드 기아 교착상태(thread starvation deadlock)

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; }
Java
복사
동시 실행 시간 체크 프레임워크
위와 같은 메서드에 넘겨진 실행자는 concurrency 매개변수로 동시성 수준만큼의 스레드를 생성할 수 있어야하는데, 스레드 생성을 하지 못하면 이 메서드는 끝나지 않고 교착상태에 걸리게 되는데, 이런 상태를 스레드 기아 교착상태라 한다.

참고: 시간 간격을 잴 떄는 System.nanoTime을 사용하자.

System.currentTimeMillis보다 더 정확하고 정밀하며, 시스템의 실시간 시계의 시간 보정에 영향을 받지 않는다.

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

여러 스레드에서 하나의 메서드를 호출하는 경우가 생길 경우 이 메서드가 이런 멀티 스레드 환경에서 어떻게 동작할지에 대해서 문서화 되있지 않다면, 클라이언트는 추측에 사용을 할테고, 이 추측이 틀릴경우 문제가 생길 수 있다. 그렇기에 스레드 안전성 수준을 문서화 할 필요가 있다.

synchronized 한정자만으로 안전하다고 믿을 수 없다.

이 말은 몇가지 부분에서 틀렸다.
메서드 선언에 synchronized 한정자 선언 여부는 구현 이슈이지 API에 속하기 힘들기에 javadoc 기본옵션으로 생성하는 API문서에 포함되지 않는다.
스레드 안전도 안전/불안전 두 단계로만 나뉘는게 아니다.

스레드 안전성 수준

멀티 스레드 환경에서 API를 안전하게 사용하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야 한다.
불변(immutable)
이 클래스의 인스턴스는 마치 상수처럼 외부 동기화도 필요없다.
Ex: String, Long, BigInteger, ...
무조건적 스레드 안전(unconditionally thread-safe)
인스턴스는 수정될 수 있지만, 내부에서 동기화를 충분히 했기에 외부 동기화 없이 사용해도 안전하다.
Ex: AtomicLong, ConcurrentHashMap
조건부 스레드 안전(conditionally thread-safe)
일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다.
Ex: Collections.synchronized 래퍼 메서드가 반환한 컬렉션들
스레드 안전하지 않음(not thread-safe)
이 클래스의 인스턴스는 수정될 수 있다.
멀티 스레드 환경에서 사용하려면 메서드 호출을 클라이언트가 호출하는 외부 동기화 메커니즘으로 감싸야 한다.
Ex: ArrayList, HashMap, .. 과 같은 기본 컬렉션
스레드 적대적(thread-hostile)
외부 동기화로 감싸더라도 클래스의 모든 메서드 호출이 멀티 스레드 환경에서 안전하지 않다.
이런 클래스나 메서드는 재배포 혹은 사용 자제(deprecated) API로 지정한다.

Collections.synchronizedMap의 API 문서

/** * synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 반드시 그 맵을 락으로 사용해 * 수동으로 동기화 하라. */ Map<K, V> m = Collections.synchronizedMap(new HashMap<>()); Set<K> s = m.keySet() synchronized(m) { for(K key : s) { key.f(); } }
Java
복사
클래스의 스레드 안전성은 보통 클래스에 문서화 주석에 기재하지만 독특한 특성의 메서드는 해당 메서드의 주석에 기재하도록 하자.

서비스 거부 공격(denial-of-service attack)

클래스가 외부에서 사용할 수 있는 락을 제공할 경우 클라이언트에서 원자적으로 메서드 호출을 수행할 수 있는데, 이런 방식은 내부에서 처리하는 동시성 제어 메커니즘을 같이 사용할 수 없다.
그리고 클라이언트가 공개된 락을 돌려주지 않는 서비스 거부 공격(denial-of-service attack)을 수행할 수도 있다.
그래서 이런 공격을 막기 위해선 synchronized 메서드 대신 비공개 락 객체를 사용해야 한다.
private final Object lock = new Object(); public void foo() { synchronized(lock) { //... } }
Java
복사
비공개 락 객체 관용구
lock 객체는 외부에서 볼 수 없고, final 키워드로 선언해줬기 때문에 락 객체를 교체할 수 있는 일도 예방해줄 수 있다. 이런 비공개 락 객체 관용구는 무조건적 스레드 안전 클래스에서만 사용할 수 있는데, 조건부 스레드 안전 클래스같은 경우 특정 호출 순서에 필요한 락이 무엇인지를 클라이언트에 알려줘야 하기에 이 관용구를 사용할 수 없다.

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

필드의 초기화 시점을 필드가 실제로 필요한 시점까지 늦추는 지연 초기화(lazy initialization) 기법은 정적, 인스턴스 필드 모두 사용할 수 있어서 주로 최적화 용도로 사용되고, 또 클래스와 인스턴스 초기화 때 발생하는 순환참조 문제를 해결하는 방법이 되기도 한다.
하지만 아이템 67. 최적화는 필요할때까지 하지 말라는 말처럼 굳이 필요 없는데 할 필요는 없다.
이런 지연 초기화는 클래스, 인스턴스 생성시의 초기화 비용은 줄어들 수 있지만, 지연 초기화를 하는 필드에 접근하는 비용이 커진다.
그렇기에 지연초기화를 원하는 필드 중에서 초기화가 이뤄지는 비율, 초기화에 드는 비용, 초기화가 된 필드를 호출하는 빈도에 따라 실제로는 지연초기화가 성능을 오히려 느려지게 할 수도 있다.

필드를 사용하는 빈도가 낮고 필드 초기화 비용이 비싼 경우에만 고려하라.

그리고 실제로 지연 초기화 적용 전 후로 성능을 측정해서 정말 적절한 최적화인지 비교해 볼 필요가 있다. 심지어 멀티스레드 환경에서는 지연초기화를 사용하기 더 까다롭다.

대부분 일반적인 초기화가 지연초기화보다 낫다.

멀티 스레드 환경에서는 필드를 둘 이상의 스레드가 공유하게 되는데 이 경우 어떻게든 반드시 동기화가 되야하는데, 지연 초기화에서 초기화 순환성(initialization circularity)을 깨트릴 것 같을 경우 synchronized 접근자를 사용하면 된다.
private FieldType field; private synchronized FieldType getField() { if(field == null ) { field = computeFieldValue(); } return field; }
Java
복사
그럼 정적 필드도 지연초기화가 필요하다면 어떻게 해야할까?
이런 경우 지연 초기화 홀더 클래스(lazy initialization holder class) 관용구를 사용해보자.
private static class FieldHolder { static final FieldType field = computeFieldValue(); } private static FieldType getField() { return FieldHolder.field; }
Java
복사
위와같이 홀더 클래스를 작성하면 getField가 처음 호출되는 순간 field가 읽히면서 FieldHolder 클래스 초기화를 하게 된다. 심지어 이러한 방식은 getField 메서드가 필드에 접근할 때 동기화를 하지 않기에 성능이 느려지지도 않을 것이다.

인스턴스 필드 지연 초기화가 필요하면 이중검사(doublecheck) 관용구

초기화된 필드에 접근할 때 동기화 비용을 없애주는 이 방식은 필드의 값을 동기화 없이 한 번 동기화해서 한 번 검사해서 두 번째 검사에서도 필드 초기화가 되지 않은 경우에만 필드를 초기화 해준다. 필드 초기화 이후로는 동기화를 하지 않을 것이기에 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; } }
Java
복사
인스턴스 필드 지연초기화용 이중검사 관용구
FieldType result = field;
: 이 코드는 왜 있는 것일까? 그냥 if(field ≠ null){return field;} 으로 작성하면 안됐을까?
이 변수(result) 는 필드가 초기화된 상황에서는 필드를 한 번만 읽도록 보장하는 역할을 한다.
그래서 필수적인 코드는 아니지만, 성능을 높히고 저수준 동시성 프로그래밍에 표준적으로 적용되는 더 우아한 방법이다.
만약 가끔 반복해서 초기화가 되어도 되는 인스턴스 필드를 지연초기화하는 경우 이중검사가 아닌 단일검사(single-check) 관용구로 만들 수 있다.
private volatile FieldType field; private FieldType getField() { FieldType result = field; if(result == null) { field = result = computeFieldValue(); } return result; }
Java
복사
단일검사 관용구
이 방식은 사실 초기화가 스레드당 최대 한 번 더 이뤄질 수 있고, 보통은 잘 사용되지 않는다.

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

여러 스레드가 실행 중이면 운영체제의 스레드 스케줄러는 어떤 스레드를 얼마나 오래 실행할지 정한다. 이런 구체적인 스케줄링 정책은 운영체제마다 다를 수 있다. 그렇기에 이런 스케줄링 정책에 기대지 않는 프로그램을 만들어야 한다. 프로그램의 성능이나 정확성이 스레드 스케줄러에 따라 달라지는 프로그램은 다른 플랫폼에 이식하기 어렵다.
그럼 어떻게 해야 스케줄링에 기대지 않는 좋은 프로그램을 만들 수 있을까?

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

스레드가 많을수록 스레드 스케줄러는 스케줄링을 위해 필요한 고민의 양이 늘어날 수 밖에 없고, 이런 경우 스케줄링 정책에 따라 변화의 폭이 클 수 밖에 없다. 그렇기에 스레드의 평균 숫자를 줄일수록 스케줄러가 스케줄링을 편하게 할 수 있고, 스케줄링 정책이 상이한 플랫폼 간에도 큰 차이가 발생하지 않는다. 그러면 어떻게 실행 가능한 스레드의 수를 적게 유지할 수 있을까?
전체 스레드 수에서 실행 가능한 수를 적게 유지하는 주요 기법은 전체 스레드 수에서 각각의 스레드가 작업 완료 후 다음 일이 생길 때까지 대기하도록 하는것으로 대기 중인 스레드를 늘리는 것이다.
그리고, 스레드는 당장 처리해야 할 작업이 없다면 실행되지 않아야 않다.

2. 스레드를 바쁜 대기(busy waiting) 상태가 되지 않도록 하자.

공유 객체의 사태가 바뀔 때까지 쉬지 않고 검사하지 않도록 하라는 의미로 바쁜 대기는 스레드 스케줄러의 변덕에 취약하고 프로세서에도 부담을 주기 때문에 다른 작업이 실행될 기회를 빼앗게 된다.
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--; } } }
Java
복사
바쁜 대기 버전 CountDownLatch 구현 코드

3. Thread.yield 는 해결책이 되기 힘들다.

특정 스레드에서 다른 스레드보다 CPU 시간을 충분히 얻지 못한다고 Thread.yield를 사용하고자 하는 마음을 버려야 한다. 이식성이 좋지 않고 테스트할 수단도 없고 오히려 느려질 가능성도 있다.
차라리 위에서 언급했던대로 실행 가능한 스레드 수를 줄여보도록 하자.
Thread.yield나 스레드 우선순위같은 기능들은 스레드 스케줄러에게 제공하는 힌트로 이미 잘 돌아가는 서비스의 품질을 높히기 위해 드물게 사용될 뿐 수리의 목적으로 사용해서는 안된다.

이전 챕터로

다음 챕터로