📌 11장 : 동시성
아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라
- synchronized
- 해당 메서드나 블록을 한 번에 한 스레드씩 수행하도록 보장
- 일반적인 동기화
- 한 객체가 일관된 상태를 가지고 생성, 객체에 접근하는 메서드는 그 객체에 lock 을 건다.
- lock 을 건 메서드는 객체의 상태를 확인하고 필요하면 수정한다.
- 동기화의 중요한 기능
- 일관성이 깨진 상태를 볼 수 없게 해주고
- 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.
- 동기화는 배타적 실행뿐만 아니라, 스레드 사이의 안정적인 통신에 필요하다.
- 다른 스레드를 멈추는 올바른 방법
- 첫 번째 스레드는 자신의 boolean 필드를 폴링하면서 값이 true가 되면 멈춘다.
- 필드를 false로 초기화하고, 다른 스레드에서 이 스레드를 멈추려고 할 때 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())
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}
- 쓰기 메서드(requestStop), 읽기 메서드(stopRequested) 모두 동기화 해야 한다.
- 둘 다 동기화되지 않으면, 동작을 보장하지 않는다.
- volatile
- 배타적 수행과는 상관없이 항상 가장 최근에 기록된 값을 읽게 해준다.
- 반복문에서 동기화 하는 비용을 더 빠르게 하는 방법
private static volatile boolean stopRequested;
- 주의해야 하는 상황
- 증가 연산자는 코드상으로 하나지만, 실제로 nextSerialNumber 필드에 두 번 접근한다.
- 먼저 값을 읽고, 증가한 새로운 값을 저장한다.
- 두 번째 스레드가 이 두 접근 사이를 들어와 값을 읽게 되면 첫 번째 스레드와 같은 값을 받게 된다.
- 안전 실패(safety failure) : 프로그램이 잘못된 결과를 계산하는 오류
- 해결 방법 : generateSerialNumber 메서드에 synchronized 한정자를 붙인다.
- 동시 호출을 해도 이전 호출이 변경한 값을 읽게 해준다.
- synchronized을 사용하면 nextSeriaINumber 필드에서 volatile 를 제거해주어야 한다.
private static volatile int nextSeriaINumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
- 위의 문제들을 피하는 가장 좋은 방법은 처음부터 가변 데이터를 공유하지 않도록 한다.
- 가변 데이터는 단일 스레드에서만 사용하도록 한다.
아이템 79. 과도한 동기화는 피하라
- 과도한 동기화는 성능이 저하되고, 교착상태에 빠질 수 있다.
- 응답 불가와 안전 실패를 피하기 위해서 동기호 메서드나 동기화 블록 안에서 제어를 클라이언트에 양도하지 않기
- 동기화된 영역 안에 재정의할 수 있는 메서드를 호출하면 안된다.
- 클라이언트가 넘겨준 함수 객체를 호출하면 안된다.
- 외계인 메서드 호출을 동기화 블록 바깥으로 옮겨준다.
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);
}
- 열린 호출(open call)
- 동기화 영역 바깥에서 호출되는 외계인 메서드
- 외계인 메서드는 언제까지 실행될지 알 수 없는데, 동기화 영역 안에서 호출되면 다른 스레드는 사용 대기를 해야 한다.
- 열린 호출은 실패 방지 효과 이외에 동시성 효율을 개선해준다.
- 더 좋은 방법은 자바의 동시성 컬렉션 라이브러리 CopyOnWriteArrayList가 있다.
- ArrayList를 구현한 클래스
- 내부의 배열이 수정되지 않아 순회할 때 락이 필요 없어 속도가 매우 빠르다.
- 순회만 일어나는 관찰자 리스트 용도로 좋다.
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);
}
- 기본 규칙
- 멀티코어가 일반화 된 오늘날에, 과도한 동기화는 락을 얻는 데 드는 CPU 비용이 아니다.
- 경쟁하는 낭비 시간, 모든 코어가 메모리를 일관되게 보기 위한 지연 시간
아이템 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라
- java.util.concurrent
- 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.
- 간단한 작업 큐 사용 가능
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.execute(runnable); // 실행자에 실행할 태스크를 넘기는 법
exec.shutdown(); // 실행자를 종료시키는 법
- 큐를 2개 이상의 스레드가 처리하게 하려면, 다른 정적 팩토리로 다른 종류의 실행자 서비스(스레드 풀) 생성하기
- 스레드 풀 스레드 개수는 고정할 수 있고, 늘어나거나 줄어들게 할 수도 있다.
- 주로 사용하는 실행자는 java.util.concurrent.Executors 에서 사용
- ThreadPoolExecutor 클래스를 직접 사용해도 된다.
- 실행자 프레임워크에서는 작업 단위와 실행 메커니즘이 분리된다.
- 태스크 : 작업 단위를 나타내는 개념
- 태스크 종류 : Runnable, Callable
- Callable은 Runnable과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다.
- 실행자 서비스 : 태스크를 수행하는 일반적인 메커니즘
- 자바 7부터 실행자 프레임워크는 포크-조인(fork-join) 태스크를 지원한다.
- fork-join 태스크는 fork-join 풀이라는 특별한 실행자 서비스를 실행한다.
- fork-join 태스크의 인스턴스는 작은 하위 태스크로 나뉠 수 있다.
- fork-join 풀을 구성하는 스레드들이 태스크들을 처리한다.
- 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와서 대신 처리할 수도 있다.
- 모든 스레드가 움직여 CPU를 최대한 활용한다.
- 높은 처리량, 낮은 지연시간
아이템 81. wait와 notify보다는 동시성 유틸리티를 애용하라
- 자바 5부터 고수준 동시성 유틸리티가 wait, notify 대신 일을 처리해준다.
- java.util.concurrent 의 고수준 유틸리티
- 실행자 프레임워크
- 동시성 컬렉션(concurrent collection)
- 동기화 장치(synchronizer)
- 동시성 컬렉션
- List, Queue, Map과 같은 표준 컬렉션 인터페이스에 동시성을 추가한 고성능 컬렉션
- 동기화를 각자 내부에서 수행해 높은 동시성을 갖춤
- 동시성 컬렉션에서 동시성을 무력화할 수 없다. 외부에서 락을 추가로 사용하면 속도가 느려진다.
- ConcurrentMap 으로 구현한 동시성 정규화 맵
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;
}
- Collections.synchronizedMap 보다 ConcurrentHashMap 의 성능이 더 좋다.
- wait, notify 보다 동시성 유틸리티를 사용하는 것이 더 좋지만
- 레거시 코드를 유지보수해야 하는 경우, wait은 while문 안에서 호출해야 한다.
- 일반적인 상황에서 notify보다 notifyAll을 사용해야 한다.
아이템 82. 스레드 안전성 수준을 문서화하라
- 메서드 선언에서 synchronized 은 API에 속하지 않는다.
- 멀티 스레드 환경에서 API를 안전하게 사용하기 위해서 스레드 안전성 수준을 정확히 명시해야 한다.
- Collections.synchronizedMap API 문서
// synchronizedMap이 반환한 맵의 컬렉션 뷰를순회하려면 반드시 그 맵을 락으로 사용해 수동으로 동기화하라.
Map<K, V> m = Collections. synchronizedMap(new HashMapo());
Set<K> s = m.keySet(); // 동기화 블록 밖에 있어도 된다.
...
synchronized(m) { // s가 아닌 m을 사용해 동기화해야 한다!
for (K key : s)
key.f();
}
// 이대로 따르지 않으면 동작을 예측할 수 없다.
아이템 83. 지연 초기화는 신중히 사용하라
- 지연 초기화(lazy initialization)
- 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늦추는 기법
- 정적 필드, 인스턴스 필드에서 사용
- 주로 최적화에 사용, 클래스와 인스턴스 초기화에 발생하는 위험 순환 문제 해결
- 클래스, 인스턴스 생성의 초기화 비용은 줄지만, 필드 접근 비용이 커진다.
- 지연 초기화가 성능을 느리게 할 수도 있다.
- 멀티 스레드 환경에서는 지연 초기화 필드를 2개 이상의 스레드가 공유하면 반드시 동기화 해야 하기 때문에 구현이 어렵다.
- 대부분의 상황에서 일반적인 초기화가 지연 초기보다 낫다.
- 인스턴스 필드 초기화하는 일반적인 방법
private final FieldType field = computeFieldValue();
// 지연 초기화가 초기화 순환성(initialization circularity)을 깨뜨릴 것 같을 때 synchronized 접근자 사용하기
private FieldType field;
private synchronized FieldType getFieId() {
if (field = null)
field = computeFieldValue();
return field;
}
- 정적 필드를 지연 초기화해야 할 때, 지연 초기화 홀더 클래스(lazy initialization holder class) 관용구 사용
private static class FieldHolder {
static final FieldType field = compiiteFieldValue();
}
private static FieldType getFieId() { return FieldHolder.field; }
- 인스턴스 필드를 지연 초기화해야 할 때, 이중검사(double-check) 관용구 사용
private volatile FieldType field;
private FieldType getFieId() {
FieldType result = field;
if (result != null) { // 첫 번째 검사 (락 사용 안 함)
return result;
synchronized(this) {
if (field = null) // 두 번째 검사 (락 사용)
field = computeFieldValue();
return field;
}
}
아이템 84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라
- 여러 스레드가 실행 중일 때, 운영체제의 스레드 스케줄러는 스레드 실행 순서와 시간을 정한다.
- 구체적인 스케줄링 정책은 운영체제마다 다르다.
- 정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램은 다른 플랫폼에 이식하기 어렵다.
- 견고하고 빠르고 이식성 좋은 프로그램 작성 방법
- 실행 가능 스레드의 평균 개수를 프로세서 수보다 크게 많아지지 않도록 해야한다.
- 실행 가능 스레드 수를 적게 유지하는 주요 기법
- 각 스레드가 유용한 작업 후, 다음 일이 생기기 전까지 대기하기
- 당장 처리할 작업이 없으면 실행 X
- ex. 실행자 프레임워크 : 스레드 풀 크기를 적절히 설정하고 작업은 짧게 유지한다.