78. 공유 중인 가변 데이터는 동기화해 사용하라
동기화
•
synchronized 키워드는 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다.
•
동기화는 두 가지 기능이 존재한다.
◦
객체를 하나의 일관된 상태에서 다른 상태로 변화를 일으키는 순간에 해당 객체의 상태 변화를 볼 수 없도록 한다.
◦
동기화는 다른 스레드에서 만든 변화를 볼 수 있도록 해준다.
•
언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적(atomic)이다.
◦
이 말을 듣고 "성능을 높이려면 원자적 데이터를 읽고 쓸 때는 동기화하지 말아야겠다"고 생각하기 쉬운데, 아주 위험한 발상이다.
•
동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.
•
공유 중인 가변 데이터를 비록 원자적으로 읽고 쓸 수 있을지라도 동기화에 실패하면 처참한 결과로 이어질 수 있다.
스레드 중지
•
먼저 스레드 중지에 Thread.stop() 사용은 금지다! (자바11에서 제거되었다)
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
복사
잘못된 코드
•
1초 후 정지될 것으로 보이는 코드지만 메인 스레드가 동기화 되지 않은 stopRequested로 인해 영원히 루프에 빠지게 된다.
이유
// 원래 코드
while (!stopRequested)
i++;
Java
복사
•
동기화가 빠진 코드는 JVM이 원래 코드를 아래와 같이 최적를 수행할 수도 있기 때문이다.
// 최적화 된 코드
if (!stopRequested)
while (true)
i++;
Java
복사
해결
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
복사
•
쓰기 메서드와 읽기 메서드 모두를 동기화 했음에 주목하자.
◦
쓰기와 읽기 모두가 동기화되지 않으면 동작을 보장하지 않는다.
•
동기화는 배타적 수행과 스레드 간 통신이라는 두 가지 기능을 수행하는데, 이 코드에서는 통신 목적으로만 사용된 것이다.
더 나은 방법
public class StopThread {
private staic volatile boolean stopRequest;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread= new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequest = true;
}
}
Java
복사
•
stopRequested 필드를 volatile으로 선언하면 동기화를 생략해도 된다.
•
volatile 한정자는 배타적 수행과는 상관 없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.
volatile은 주의해서 사용해야 한다.
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
Java
복사
•
해당 코드 역시 동기화가 없이는 올바로 동작하지 않는다.
•
문제는 증가 연산자(++)로 코드상으로는 하나지만 실제로는 nextSerialNumber 필드에 두번 접근한다.
◦
먼저 값을 읽고, 그 다음 새로운 값을 저장하는 것이다.
•
만약 두번째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫 번째 스레드와 똑같은 값을 돌려받게 되는데, 프로그램이 잘못된 결과를 계산해내는 이런 오류를 안전 실패라고 한다.
•
해당 메서드에 synchronized 한정자를 붙여야하고 이때 nextSerialNumber에 volatile을 제거해야한다.
더 나은 방법
private static final AtomicLong nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
Java
복사
•
java.util.concurrent.atomic 패키지의 AtomicLong를 이용해 락 없이도 스레드 안전한 프로그래밍을 할 수 있다.
◦
이 패키지에는 락 없이도 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨있다.
•
성능이 우수한 코드를 작성 할 수 있다.
동기화 문제를 피하는 좋은 방법
•
가장 좋은 방법은 데이터를 공유하지 않거나 불변 데이터만 공유하는 것이다. 가변 데이터는 단일 스레드에서만 쓰도록 하자.
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;)
}
Java
복사
•
관찰자들은 addObserver와 removeObserver 메서드를 호출해 구독을 신청하거나 해지한다.
•
눈으로 보기에 ObservableSet은 잘 동작할 것 같지만 23까지 출력 후 ConcurrentModificationException을 던지게 된다.
◦
관찰자의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다.
◦
리스트에서 원소를 제거하려 하는데 마침 리스트를 순회하는 도중에 발생하는 문제다.
set.addObserver(new SetObserver<Integer>() {
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();
}
}
}
});
Java
복사
쓸떼없이 백드라운드 스레드를 사용하는 관찰자
•
이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다.
•
백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 메인 스레드가 이미 락을 쥐고 있기 때문이다.
•
그와 동시에 메인 스레드는 백그라운드 스레드가 관찰자를 제거하기만을 기다리는 중이다. 바로 교착상태다!
•
해당 예제는 좀 억지스러운 예지만 보인 문제 자체는 진짜다.
◦
실제 시스템에서도 동기화된 영역 안에서 외계인 메서드를 호출될 때 교착상태에 빠질 수 있다.
•
외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 해결된다.
•
혹은 관찰자 리스트를 복사해서 사용하면 락 없이 이용할 수 있다.
동기화의 기본 규칙
동기화 영역에서는 가능한 한 일을 적게하자
•
락을 얻고, 공유 데이터를 검사하고, 필요하면 수정하고, 락을 놓는다.
•
오래 걸리는 작업이라면 아이템 78의 지침을 어기지 않으며 동기화 영역 바깥으로 옮기는 방법을 찾아보자.
과도한 동기화를 피해야하는 이유
•
멀티코어가 일반화된 오늘날, 과도한 동기화가 초래하는 문제는 락을 얻는데 드는 CPU 시간이 아니다.
•
바로 경쟁하느라 낭비하는 시간, 즉 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 진짜 비용이다.
•
가상머신의 코드 최적화를 제한한다는 점도 과도한 동기화의 또다른 숨은 비용이다.
가변 클래스 작성 가이드
•
첫 번째, 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자.
•
두 번째, 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자.
◦
단, 클라이언트가 외부에서 객체 전체에 락을 거는것보다 동시성을 월등하게 개선할 수 있을때만 두번째 방법을 선택한다.
80. 스레드보다는 실행자, 태스크, 스트림을 애용하라
실행자 프레임워크
•
java.util.concurrent 패키지는 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.
// 큐를 생성한다.
ExecutorService exec = Executors.newSingleThreadExecutor();
// 태스크 실행
exec.execute(runnable);
// 실행자 종료
exec.shutdown();
Java
복사
실행자 서비스의 주요 기능들
•
특정 태스크가 완료되기를 기다린다.
•
태스크 모음 중 아무것 하나(invokeAny 메서드) 혹은 모든 태스크(invokeAll 메서드)가 완료되기를 기다린다.
•
실행자 서비스가 종료하기를 기다린다.(awaitTermination 메서드)
•
완료된 태스크들의 결과를 차례로 받는다.(ExecutorCompletionService 메서드)
•
태스크를 특정 시간에 혹은 주기적으로 실행하게 한다.(ScheduledThreadPoolExecutor 이용)
실행자 서비스를 사용하기에 까다로운 예시
•
작은 프로그램이나 가벼운 서버라면 Excutors.newCachedThreadPool이 일반적으로 좋은 선택일 것이다.
•
특별히 설정할 게 없고 일반적인 용도에 적합하게 동작한다.
•
하지만 CachedThreadPool은 무거운 프로덕션 서버에는 좋지 못하다.
•
서버가 무겁다면 CPU 이용률이 100%로 치닫고, 새로운 태스크가 도작하는 족족 또 다른 스레드를 생성하며 상황을 더욱 악화시킨다.
실행자 서비스를 적극 사용하자
•
작업 큐를 손수 만드는 일을 삼가야 하고, 스레드를 직접 다루는것도 삼가야 한다.
•
스레드를 직접 다루면 Thread가 작업 단위와 수행 메커니즘 역할을 모두 수행하게 된다.
•
반면 실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다.
•
태스트 수행을 실행자 서브스에 맡기면 원하는 태스크 수행 정책을 선택할 수 있고, 생각이 바뀌면 언제든 변경할 수 있다.
포크-조인 태스크
•
자바 7이 되면서 실행자 프레임워크는 포크-조인 태스크를 지원하도록 확장되었다.
•
포크-조인 태스크, 즉 ForkJoinTask의 인스턴스는 작은 하위 태스크로 나뉠 수 있고, ForkJoinPool을 구성하는 스레드들이 이 태스크들을 처리하며, 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수도 있다.
•
이렇게 모든 스레드가 바삐 움직여 CPU를 최대한 활용하며 높은 처리량과 낮은 지연시간을 달성한다.
•
이러한 포크-조인 태스크를 직접 작성하고 튜닝하기란 어려운 일이지만, 포크-조인 풀을 이용해 만든 병렬 스트림을 이용하면 적은 노력으로 그 이점을 얻을 수 있다.
•
물론 포크-조인에 적합한 형태의 작업이어야 한다.
81. wait과 notify보다는 동시성 유틸리티를 애용하라
•
wait과 notifiy는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자.
•
java.util.concurrent의 고수준 유틸리티는 세 범주로 나눌 수 있다.
◦
실행자 프레임워크, 동시성 컬렉션, 동기화 장치다.
동시성 컬렉션
•
동시성 컬렉션은 List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다.
•
높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다.
•
동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.
상태 의존적 수정 메서드
•
동시성 컬렉션의 동시성을 무력화하지 못하기 때문에 여러 메서드를 원자적으로 묶어 호출하는 것도 불가능하다.
◦
그래서 여러 동작을 하나의 원자적 동작으로 묶는 상태 의존적 수정 메서드가 추가되었다.
◦
예를 들면 putIfAbsent는 주어진 키에 매핑된 값이 없을 때만 새 값을 집어넣는다.
◦
그리고 기존 값이 있으면 그 값을 반환하고 없는 경우에는 null을 반환한다.
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;
}
Java
복사
•
ConcurrentMap은 동시성이 뛰어나며 속도도 무척 빠르다.
•
이제는 Collections.synchronizedMap보다는 ConcurrentHashMap을 사용하는 것이 훨씬 좋다.
•
동기화된 맵을 동시성 맵으로 교체하는 것 하나만으로 성능은 극적으로 개선될 수 있다.
동기화 장치
•
동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여 서로의 작업을 조율할 수 있게 해준다.
•
가장 자주 쓰이는 동기화 장치로는 CountDownLatch와 Semaphore다.
◦
CyclicBarrier와 Exchanger는 그보다 덜 쓰이고 가장 강력한 동기화 장치는 Phaser다.
•
카운트다운 래치(latch; 걸쇠)는 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 때까지 기다리게 한다.
•
CountDownLatch의 유일한 생성자는 인자로 int 값을 받으다.
◦
이 값은 래치의 countdown 메서드를 몇 번 호출해야 대기 중인 스레드들을 깨우는지 결정한다.
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;
}
}
Java
복사
동시 실행 시간을 재는 간단한 프레임워크
•
이 코드는 카운트다운 래치를 3개 사용한다.
◦
ready 래치는 작업자 스레드들이 준비가 완료됨을 타이머 스레드에게 통지할 때 사용한다.
◦
통지를 끝낸 작업자 스레드들은 두 번째 래치인 start가 열리기를 기다린다.
•
time에 넘겨진 executor는 concurrency 매개변수로 지정한 값만큼의 스레드를 생성할 수 있어야 한다.
•
그렇지 못하면 메서드 수행이 끝나지 않는데 이를 스레드 기아 교착 상태라고 한다.
•
시간을 잴 때는 시스템 시간과 무관한 System.nanoTime을 사용하는 것이 더 정확하다.
wait과 notify메서드
•
새로운 코드라면 wait, notify가 아닌 동시성 유틸리티를 써야 한다.
•
wait 메소드는 스레드가 어떤 조건이 충족되기를 기다리게 할 때 사용하고 락 객체의 wait 메소드는 반드시 그 객체를 잠근 동기화 영역 안에서 호출해야한다
synchronized (obj) {
while (<조건 미충족>) {
obj.wait(); // 락을 놓고, 깨어나면 다시 잡기
}
... // 조건 충족시 동작 수행
}
Java
복사
•
wait 메소드를 사용할 땐 반드시 대기 반복문(wait loop) 관용구를 사용하고 반복문 밖에서는 절대 호출하지 말자.
•
대기 전 조건을 검사해 조건이 이미 충족되었다면 wait을 건너뛰게 하는 것은 응답 불가 상태를 예방하는 조치다.
•
만약 조건이 충족되지 않았는데 스레드가 동작을 이어가면 락이 보호하는 불변식을 깰 위험이 있다.
•
조건이 만족되지 않아도 스레드가 깨어날 수 있는 상황의 예시를 살펴보자
◦
스레드가 notify를 호출한 다음 대기 중이던 스레드가 깨어나는 사이! 다른 스레드가 락을 얻어 동기화 블럭 안의 상태를 변화시킬 수 있다
◦
조건이 만족되지 않았는데 다른 스레드가 실수 혹은 악의적으로 notify를 호출할 수 있다. 공개된 객체를 락으로 사용해 대기하는 클래스는 이런 위험에 노출되고, 외부에 노출된 객체의 동기화된 메소드 안에서 호출하는 wait는 모두 이 문제에 영향을 받는다.
◦
깨우는 스레드의 관대함에, 대기 중인 스레드 중 일부만 조건이 충족되도 notifyAll을 호출해 모든 스레드를 깨울 수 있다.
◦
대기중인 스레드가 notify 없이 깨어날 수 있는데 허위 각성(spurious wakeup) 현상이다.
notify vs notifyAll
•
notify는 스레드 하나만 깨우며, notifyAll은 모든 스레드를 깨운다.
◦
일반적으론 언제나 notifyAll을 사용하는게 낫다.
•
모든 스레드가 같은 조건을 기다리고, 조건이 한 번 충족될 때마다 단 하나의 스레드만 헤택을 받는다면 notify를 사용해 최적화할 수 있다.
82. 스레드 안전성 수준을 문서화하라
•
메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐 API에 속하지 않는다.
•
멀티스레드 환경에서도 API를 안전하게 사용하려면 클래스가 지원하는 스레드 안정성 수준을 정확히 명시해야 한다.
스레드 안전성이 높은 수준으로 정리
•
불변(immutable)
◦
이 클래스의 인스턴스는 마치 상수와 같아서 외부 동기화도 필요 없다. String, Long, BigInteger가 대표적이다.
•
무조건적 스레드 안전(unconditionally thread-safe)
◦
이 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화 없이 동시에 사용해도 안전하다.
◦
AtomoicLong, ConcurrentHashMap이 여기에 속한다.
•
조건부 스레드 안전(conditionally thread-safe)
◦
무조건적 스레드 안전과 같으나, 일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다.
◦
Collections.synchronized 래퍼 메서드가 반환한 컬렉션들이 여기 속한다(이 컬렉션들이 반환한 반복자는 외부에서 동기화해야 한다).
•
스레드 안전하지 않음 (not thread-safe)
◦
이 클래스의 인스턴스는 수정될 수 있다. 동시에 사용하려면 각각의(혹은 일련의) 메서드 호출을 클라이언트가 선택한 외부 동기화 메커니즘으로 감싸야 한다.
◦
ArrayList, HashMap같은 기본 컬렉션이 여기 속한다.
•
스레드 적대적(thread-hostile)
◦
이 클래스는 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다.
◦
이 수준의 클래스는 일반적으로 정적 데이터를 아무 동기화 없이 수정한다.
◦
이런 클래스를 고의로 만드는 사람은 없겠지만, 동시성을 고려하지 않고 작성하다 보면 우연히 만들어질 수 있다.
동기화에 대한 문서화
/**
* synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 반드시 그 맵을 락으로 사용해 수동으로 동기화하라.
* Map<K, V> = Collections.synchronizedMap(new HashMap());
* Set<K> s = m.keySet(); // 동기화 블록 밖에 있어도 된다
* ...
* synchronized (m) { // s가 아닌 m을 사용해 동기화해야 한다!
* for(K key : s)
*. key.f();
* }
* 이대로 따르지 않으면 동작을 예측할 수 없다.
*/
Java
복사
Collections.synchronizedMap의 API의 문서
•
조건부 스레드 안전한 클래스는 주의하여 문서화해야 한다.
•
어떤 순서로 호출할 때 외부 동기화 로직이 필요한지, 그리고 그 순서대로 호출하려면 어떤 락 혹은 락들을 얻어야만 하는지 알려줘야 한다.
•
클래스의 스레드 안전성은 보통 클래스의 문서화 주석에 기재하지만, 독특한 특성의 메서드라면 해당 메서드의 주석에 기재하도록 하자.
•
반환 타입만으로 명확히 알 수 없는 정적 팩토리 메서드라면 자신이 반환하는 객체에 대한 스레드 안전성을 문서화해야 한다.
외부에 공개된 락
•
클래스가 외부에서 사용할 수 있는 락을 제공하면 클라이언트에게 일련의 메서드 호출을 원자적으로 수행할 수 있다.
◦
하지만 이 유연성에는 대가가 따른다.
◦
내부에서 처리하는 고성능 동시성 제어 메커니즘과 혼용할 수 없게 되는 것이다.
•
클라이언트가 공개된 락을 가지고 놓지 않는 서비스 거부 공격(denial-of-service attack)을 수행할 수 있다.
•
서비스 거부 공격을 막으려면 synchronized 메서드 대신 비공개 락 객체를 사용해야 한다.
◦
synchronized 역시 공개된 락이나 마찬가지다.
private final Object lock = new Object();
public void someMethod() {
synchronized(lock) {
// do something
}
}
Java
복사
•
비공개 락 객체는 클래스 바깥에서는 볼 수 없으니 클라이언트가 그 객체의 동기화에 관여할 수 없다.
•
여기서 lock 멤버를 final로 선언한 이유는 우연히라도 락 객체가 교체되는 상황을 방지하기 위함이다.
83. 지연 초기화는 신중히 사용하라
•
지연 초기화는 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법이다.
•
값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않는다.
•
지연 초기화는 주로 최적화 기법으로 사용되지만, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과가 있다.
•
다른 모든 최적화와 마찬가지로 “필요할 때 까지는 하지 말라”
•
클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄어들지만, 초기화된 각 필드의 호출 빈도에 따라 실제로 성능이 느려지게 할 수도 있다.
•
멀티스레드의 경우 지여 초기화가 까다롭다.
•
대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.
지연 초기화가 필요한 경우
•
해당 클래스의 인스턴스 중 그 필드를 사용하는 인스턴스의 비율이 낮은 반면, 그 필드를 초기화 하는 비용이 크다면 제 역할을 해줄 것이다.
•
지연 초기화 적용 전후의 성능 측정을 통해 확인해보자.
지연 초기화 방법
84. 프로그램 동작을 스레드 스케줄러에 기대지 말라
•
여러 스레드가 실행 중이면 운영체제의 스레드 스케줄러가 어떤 스레드를 얼마나 오래 실행할지 정한다.
•
정상적인 운영체제라면 이 작업을 공정하게 수행하지만 구체적인 스케줄링 정책은 운영체제마다 다를 수 있다.
•
정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다.
견고하고 빠릿하고 이식성 좋은 프로그램을 작성하는 가장 좋은 방법
•
실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다.
•
그래야 스레드 스케줄러가 고민할 거리가 줄어든다.
•
실행 준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들자.
•
이런 프로그램이라면 스레드 스케줄링 정책이 아주 상잏간 시스템에서도 동작이 크게 달라지지 않는다.
•
여기서 실행 가능한 스레드의 수와 전체 스레드 수는 구분해야 한다.
실행 가능한 스레드 수를 적게 유지하는 주요 기법
•
각 스레드가 무언가 유용한 작업을 완료한 후에는 다음 일거리가 생길 때까지 대기하도록 하는 것이다.
•
스레드는 당장 처리해야 할 작업이 없다면 실행돼서는 안 된다.
•
실행자 프레임워크를 예로 들면, 스레드 풀 크기를 적절히 설정하고 작업은 짧게 유지하면 된다.
◦
단, 너무 짧으면 작업을 분배하는 부담이 오히려 성능을 떨어뜨릴 수도 있다.
Thread.yield 사용을 자제하자.
•
특정 스레드가 다른 스레드들과 비교해 CPU 시간을 충분히 얻지 못해서 간신히 돌아가는 프로그램을 보더라도 Thread.yield를 써서 문제를 고쳐보려는 유혹을 떨쳐내자.
•
Thread.yield는 테스트할 수단도 없다.