아이템79 과도한 동기화는 피하라

Effective Java 3e 아이템 79를 요약한 내용 입니다.

과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 심지어 예측할 수 없는 동작을 낳기도 한다. 응답 불가안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다. 예를 들어 동기화된 영역 안에서는 재정의할 수 있는 메서드는 호출하면 안 되며, 클라이언트가 넘겨준 함수 객체를 호출해서도 안 된다. (아이템 24) 이는 예외를 발생 시키거나 교착상태에 빠지거나 데이터를 훼손할 수도 있다.

다음은 어떤 집합(Set)을 감싼 래퍼 클래스이고, 이 클래스의 클라이언트는 집합에 원소가 추가되면 알림을 받을 수 있다.

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;
    }
}

관찰자들은 addObserverremoveObserver 메서드를 호출해 구독을 신청하거나 해지한다. 눈으로 보기에 ObservableSet은 잘 동작할 것 같다.

다음 프로그램은 0부터 99까지를 출력한다. 평상시에는 앞서와 같이 집합에 추가된 정숫값을 출력하다가, 그 값이 23이면 자기 자신을 제거하는 관찰자를 추가해보자.

public static void main(String[] args) {
	ObservableSet<Integer> set = new ObservableSet<>(New HashSet<>());
	
	set.addObserver(new SetObserver<Integer>() {
		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);
}

이 프로그램은 23까지 출력한 다음 ConcurrentModificationException을 던진다. 관찰자의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다. added 메서드는 ObservableSetremoveObserver 메서드를 호출하고, 이 메서드는 다시 observers.remove 메서드를 호출한다. 여기서 문제가 발생한다. 리스트에서 원소를 제거하려 하는데, 마침 지금은 이 리스트를 순회하는 도중이다. 즉 허용하는 동작이다.

이번엔 다른 예외를 시도해보자.

구독 해지 하는 관찰자를 작성하는데 removeObserver를 직접 호출하지 않고 실행자 서비스(ExecutorService, 아이템 80)를 사용해 다른 스레드한테 부탁할 것이다.

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();
            }
        }
    }
});

이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다. 백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 을 얻을 수 없다. 메인 스레드가 이미 락을 쥐고 있기 때문이다. 그와 동시에 메인 스레드백그라운드 스레드가 관찰자를 제거하기만을 기다리는 중이다. 바로 교착상태다!

사실 관찰자가 자신을 구독 해지 하는 데 굳이 백그라운드 스레드를 이용할 이유가 없으니 좀 억지스러운 예지만, 실제 시스템에서도 동기화된 영역 안에서 외계인 메서드를 호출하여 교착상태에 빠지는 사례는 자주 있다.

외계인 메서드란 동기화된 영역 안에서 재정의 메서드를 호출하거나 클라이언트가 넘겨준 함수 객체를 호출하는 것을 뜻한다

다행히 이런 문제는 대부분 어렵지 않게 해결할 수 있다. notifyElementAdded 메서드 에서 관찰자 리스트를 복사해 쓰면 락 없이도 안전하게 순회할 수 있다. 이 방식을 적용하면 앞서의 두 예제에서 예외 발생교착상태 증상이 사라진다.

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);
}

사실 외부 메서드 호출을 동기화 블록 바깥으로 옮기는 더 나은 방법이 있다. 자바의 동시성 컬렉션 라이브러리CopyOnWriteArrayList가 정확히 이 목적으로 특별히 설계된 것이다.

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 시간이 아니다. 바로 경쟁 하느라 낭비하는 시간, 즉 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연 시간이 진짜 비용이다.

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

  1. **동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자**

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

java.util은 (이제 구식이 된 VectorHashTable을 제외하고) 첫 번째 방식을 취했고, java.util.concurrent는 두 번째 방식을 취했다(아이템 81)

자바도 초창기에는 이 지침을 따르지 않은 클래스가 많았다. 예컨대 StringBuffer 인스턴스는 거의 항상 단일 스레드에서 사용했음에도 내부적으로 동기화를 수행했다. (뒤늦게 StringBuilder가 등장한 이유이기도 하다)

클래스를 내부에서 동기화 하기로 했다면, 락 분할(lock splitting), 락 스트라이핑(lock striping), 비차단 동시성 제어(nonblocking concurrecy control) 등 다양한 기법을 동원해 동시성을 높여줄 수 있다.

여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기화해야 한다. 그런데 클라이언트가 여러 스레드로 복제돼 구동되는 상황이라면 다른 클라이언트에서 이 메서드를 호출하는 걸 막을 수 없으니 외부에서 동기화할 방법이 없다.

정리

교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자. 일반화해 이야기하면, 동기화 영역 안에서의 작업은 최소한으로 줄이자. 가변 클래스를 설계할 때는 스스로 동기화해야 할지 고민하자. 멀티 코어 세상인 지금도 과도한 동기화를 피하는 게 과거 어느 때보다 중요하다. 합당한 이유가 있을 때문 내부에서 동기화하고, 동기화했는지 여부를 문서에 명확히 밝히자

Last updated