과도한 동기화는 성능을 떨어뜨리고, 교착 상태에 빠뜨리며 예측할 수 없는 동작을 낳는다. 응답 불가와 안전 실패를 피하려면 동기화 메소드나 동기화 블록 안에서는 제어를 절대로 클라이언트에게 양도하지 말자. 재정의할 수 있는 메소드 호출이나 클라이언트가 넘겨준 함수 객체도 호출하면 안된다는 것이다. 그 메소드가 어떤 일을 할지 모르고, 통제할 수 없어서 예외 발생, deadlock, 데이터훼손을 일으킬 수 있다.
이러한 외부에서 오는 메소드를 외계인 메소드(alian method)라고 정의했다.
아이템 18에서 사용한 ForwardingSet을 재사용해 만든 옵저버 패턴으로 구현된 코드를 보자. [옵저버 패턴 참고]
관찰자들은 addObserver, removeObserver 메소드를 호출해 구독을 신청/해지한다. 집합에 원소가 추가되면 알림을 받을 수 있다.
적절하게 동기화를 했기 때문에 여러 스레드에서도 안전하다.
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);
return result;
}
}
그 다음 observer가 호출하는 메소드로 직관적이고 다중콜백을 지원하도록 확장하기 위해 커스텀 함수형 인터페이스를 정의해서 사용하자. 물론 BiConsumer<observableset<e>,e>를 사용해도 된다.
public interface SetObserver<E> {
// ObservableSet에 원소가 더해질 때 호출
void added(ObservableSet<E> set, E element);
}
그러면 다음의 메인문은 잘 작동할 것이다. 옵저버는 sout으로 원소만 출력하기에 람다를 사용할 수 있다.
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
set.addObserver((s, e) -> System.out.println(e));
// 원소가 추가될 때마다 알림이 가서 0~99 출력
for (int i = 0; i < 100; i++)
set.add(i);
}
동시 수정 문제
이전 메인문의 경우, sout으로 출력만 하기 때문에 아무런 문제가 없었다. 하지만 메인문을 다음과 같이 값 23이 추가되면 자기 자신을 제거(구독해지)하는 관찰자를 작성해보자. 메인문 내 addObserver로 전달하는 부분만 바꾸면 되고, 람다는 자기 자신을 참조할 수단이 없기에 익명클래스를 사용했다.
set.addObserver(new SetObserver<>() {
public void added(ObservableSet<Integer> s, Integer e) {
System.out.println(e);
if (e == 23) // 값이 23이면 자신을 구독해지한다.
s.removeObserver(this);
}
});
0부터 23까진 정상적으로 출력을 하지만, e가 23임임을 알고 삭제하는 순간 문제가 발생한다. ConcurrentModificationException을 던진다. 관찰자의 added 메소드 호출이 일어난 시점은 notifyElementAdded가 관찰자들의 리스트를 순회중인 상황이다. 따라서 순회중인 리스트에서 원소를 삭제하려했기 때문에 동시수정예외가 발생한 것이다.
순회는 동기화 블럭에 있어서 동시 수정이 일어나진 못하게 하지만, 자신이 콜백을 거쳐 되돌아와 수정하는 것은 막을 수 없다. 자바 언어의 락은 재진입(reentrant)을 허용하기 때문에 교착상태에 빠지지 않는다. SetObserver의 added 메소드를 호출하는 스레드는 이미 락을 쥐고 있기 때문에 다음 번 락 획득도 성공한다.
이는 불변식이 임시로 깨진 경우라도 마찬가지다. 락이 보호하는 데이터에 대해 개념적으로 관련없는 다른 작업이 진행중인데도 락 획득에 성공을 하고, 이는 락이 제 구실을 못한 참혹한 결과를 만든다. 원래면 응답 불가(교착상태)가 될 상황을 안전 실패로 바꾸는 것이다.
교착 상태 문제
관찰자가 자신을 구독해지하는 데 굳이 백그라운드 스레드를 이용할 이유는 없으니 억지스럽지만, 아래 코드에서 보인 문제 자체는 진짜다.구독을 해지하는 관찰자를 작성하는데 removeObserver를 직접 호출하지 않고 실행자 서비스(ExecutorService)를 사용해 다른 스레드에게 위임하는 코드를 작성해보자.
set.addObserver(new SetObserver<>() {
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();
}
}
}
});
예외는 발생하지 않을 것이다. 하지만 교착상태에 빠진다.
- 메인 스레드가 notifyElementAdded 메소드를 호출하면서 23번째가 되면 SetObserver의 added 메소드를 호출한다.
- 실행자 서비스가 파라미터로 넘어온 ObservableSet을 이용해 자기 자신을 remove하겠다고 호출한다.
- 메인 스레드가 notifyElementAdded 메소드를 사용하며 synchronized 블록을 썼고, 순회가 끝나야 락이 풀린다.
- 실행자 서비스는 removeObserver 메소드를 실행하지만, synchronized 블록으로 observers를 사용중임으로 메인 스레드가 순회가 끝나길 기다린다.
- 메인 스레드의 순회는 실행자 서비스가 구독해지 하기를 기다린다.
즉 교착 상태(deadlock)에 빠진다.
외계인 메소드 호출을 동기화 밖으로 변경
아래와 같은 방식으로 동기화 영역 바깥에서 호출되는 외계인 메소드를 열린 호출(open call)이라 한다.
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);
}
관찰자 리스트를 복사해 쓰면 락 없이도 안전하게 순회할 수 있고, 이 방식을 적용하면 앞선 동시 수정 예외와 교착상태 증상이 사라진다.
외계인 메소드는 얼마나 오래 실행될 지 모르고, 동기화 영역 내에서 호출되면 그동안 다른 스레드는 보호된 자원을 사용 못하고 대기해야한다. 따라서 열린 호출은 실패 방지 효과와 함께 동시성 효율을 크게 개선할 수 있다.
기본 규칙은 동기화 영역에서는 가능한 한 일을 적게하자. 락을 얻고, 공유 데이터를 검사하고, 필요하면 수정하고 락을 놓는다. 오래 걸리는 작업이라면 아이템78의 지침을 어기지 않으면서 동기화 영역 밖으로 옮기는 방법을 찾자.
자바의 동기화 비용은 빠르게 낮아져 오지만, 과도한 동기화를 피하는 일은 아주 중요하다. 과도한 동기화가 초래하는 진짜 비용은 락을 얻는 데 드는 CPU 시간이 아니다. 첫 번째로 경쟁하느라 낭비하는 시간, 즉 병렬로 실행할 기회를 잃고 모든 코어가 메모리를 일관되게 보기 위한 지연 시간과 두 번째로 가상머신의 코드 최적화를 제한한다는 점이 진짜 비용이다.
CopyOnWriteArrayList / SynchronizedList
외계인 메소드 호출을 동기화 블록 밖으로 옮기는 더 나은 방법은 자바의 동시성 컬렉션 라이브러리의 CopyOnWriteArrayList를 사용하는 것이다. ArrayList와 차이점은 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행한다. ArrayList는 스레드 안전하게 설계되지 않았기 때문에 synchronized 키워드를 적절히 사용하여 동기화 해줘야했다. CopyOnWriteArrayList 클래스를 살펴보자.
add와 set의 경우 락을 걸고, 자신이 가지고 있는 배열을 카피해서 값을 추가한 후 참조를 변경한다.
remove 메소드의 경우에도 마찬가지로 락을 걸고 배열을 복사해서 제거 후 참조를 변경하는 방식을 사용한다.
원소를 순회하거나 가져오는 행위는, 복사도 않고 그 배열의 참조를 그대로 들고온다.
정리하자면,
내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현했고, 내부의 배열은 절대 수정되지 않으니 순회할 때 락이 필요없어 매우 빠르다. 다른 용도로 쓰인다면 끔찍하게 느리겠지만 추가, 수정, 삭제가 드물고 순회만 빈번히 일어나는 용도로는 최적이다.
실제로 코드를 변경하면 ObservableSet 내부의 일부만 다음과 같이 변경하면 된다.
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);
}
사실 이 외에 SynchronizedList도 존재한다. 이름 그대로 하나의 스레드만이 해당 리스트를 읽거나 쓸 수 있다. 상대적으로 높은 배열 복사 를 하는 CopyOnWriteArrayList보다 쓰기 동작이 빠르다. 따라서 읽기보다 쓰기 동작이 많고 크기가 큰 리스트에는 SynchronizedList를, 쓰기보다 읽기 동작이 많고 크기가 작은 리스트에는 CopyOnWriteArrayList를 적용하는게 바람직하다. 좀 더 자세한 내용은 공식 문서나 참조를 확인하자. CopyOnWriteArrayList는 java.concurrent 패키지고, SynchronizedList는 java.util의 Collections 클래스에 있는 정적 클래스다. Collections.synchronizedList(List<T> lists)를 호출하면 생성할 수 있다.
가변 클래스를 작성할 때 선택지
1. 동기화를 전혀 하지 않고 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하는 방법
ex) java.util 패키지(Vector와 HashTable을 제외한)
2. 동기화를 내부에서 수행해 슬레드 안전한 클래스로 만드는 방법
ex)java.util.concurrent 패키지
클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두 번째 방법을 선택하자.
선택하기 어렵다면 동기화하지 말고, docs에 스레드 안전하지 않다고 명기하자.
위 선택지를 따르지 않은 사례
- StringBuffer 인스턴스는 거의 항상 단일 스레드에서 쓰였음에도 내부적으로 동기화를 수행했다. 그래서 뒤늦게 동기화하지 않은 StringBuffer인 StringBuilder가 등장한 것이다.
- 비슷한 이유로 java.util.Random은 동기화하지 않는 버전인 java.util.concurrent.ThreadLocalRandom으로 대체됐다.
동기화를 할 경우
다양한 동기화 기법 사용
락 분할(lock splitting), 락 스트라이핑(lock striping), 비차단 동시성 제어(nonblocking concurrency control)이 있다.
이 방식들에 대한 자세한 설명은 깃허브 이슈를 참조했다.
[아이템 79] 동기화 기법 · Issue #141 · Java-Bom/ReadingRecord
p.426 락 분할, 락 스트라이핑, 비차단 동시성 제어 질문 : 동기화 기법의 3가지에 대한 각각의 예시를 보고싶어
github.com
정적 필드 동기화하기
여러 스레드가 호출할 가능성 있는 메소드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기화하자. 클라이언트가 여러 스레드로 복제되 구동된다면 다른 클라이언트에서 메소드 호출을 막을 수 없으니 외부에서 동기화할 방법이 없고, 이 정적 필드가 private라도 서로 관련 없는 스레드들이 동시에 읽고 수정할 수 있게 되면서 사실상 전역변수처럼 되버린다.
정리
- 교착 상태와 데이터 훼손(안전 실패)을 피하려면 동기화 영역 안에서 외계인 메소드 호출하지 말자
- 동기화 영역 안에서의 작업은 최소한으로 줄이자
- 가변 클래스를 설계할 때 스스로 동기화해야 할지 고민하자
- 합당한 이유가 있을 때만 내부에서 동기화 하고, 동기화 여부를 docs에 명확히 밝히자
* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.