DEV ℧ Developer Diary

[EffectiveJava] item79 - 과도한 동기화는 피하라

저번 Item에서는 충분하지 못한 동기화의 피해를 다뤘다면, 이번엔 반대의 상황을 다뤄보자.

과도한 동기화

과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 예측할 수 없는 동작을 낳기도 한다.

응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안된다.

재정의할 수 있는 메서드나, 클라이언트가 넘겨준 함수객체가 그 예시이다.

외계인 메서드(alien method)

동기화된 영역을 포함한 클래스 관점에서는 이런 메서드는 모두 바깥에서온 외계인이다. 그 메서드가 무슨 일을 할지 알지 못하며 통제할 수도 없다는 뜻이다.
외계인 메서드가 하는일에 따라 동기화된 영역은 예외를 일이크거나, 교착상태에 빠지거나, 데이터를 훼손할 수도 있다.

외계인 메서드 예시

잘못된 코드, 동기화 블록 안에서 외게인 메서드를 호출한다.

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 메서드를 호출해 구독을 신청하거나 해지한다.
두 메서드를 호출 할 경우 다음 콜백 인터페이스의 인스턴스를 메서드에 건넨다.

@FunctionalInterface
public interface SetObserver<E> {
    /* ObservableSet에 원소가 더해지면 호출된다. */
    void added(ObservableSet<E> set, E element);
}

메서드 호출 테스트

이제 예시를 테스트 해보자. 다음의 코드는 위의 ObserverSet<E>을 이용해 관찰자가 0부터 99를 출력하는 로직이다.

public class ObserverTest {
    public static void main(String[] args) {
        ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());

        set.addObserver((s, e) -> System.out.println(e));

        for (int i = 0; i < 100; i++)
            set.add(i);
    }
}

실행할 경우 정상적으로 잘 작동한다.

1
2
3
...
98
99

이제 여기에 또 다른 로직을 추가해주자. 앞선 예시와 같이 99까지 출력을 해주다 23이란 숫자가 나올결우 자기 자신을 제거(구독해지) 하는 관찰자를 추가해보자.

public static void main(String[] args) {
    ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());

    set.addObserver(new SetObserver<Integer>() {
        @Override
        public void added(ObservableSet<Integer> set, Integer element) {
            System.out.println(element);
            if (element == 23)
                set.removeObserver(this);
        }
    });

    for (int i = 0; i < 100; i++)
        set.add(i);
}
1
2
...
22
23
Exception in thread "main" java.util.ConcurrentModificationException
	at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
	at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)
	at com.effectiveJava.item79.ObservableSet.notifyElementAdded(ObservableSet.java:29)
	at com.effectiveJava.item79.ObservableSet.add(ObservableSet.java:38)
	at com.effectiveJava.item79.ObserverTest.main(ObserverTest.java:20)

이 프로그램은 23까지 출력한 다음 ConcurrentModificationException을 던진다.

관찰자의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다.

즉, added -> ObservableSetremoveObserver -> observsers.remove 메서드 순으로 호출하지만, observsers.remove 에서 리스트에서 원소를 제거하려는데 지금 이 리스트를 순회중이어서 예외가 발생한 것이다.

멀티 스레드 테스트

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

public static void main(String[] args) {
    ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());

    set.addObserver(new SetObserver<Integer>() {
        @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();
                }
            }
        }
    });

    for (int i = 0; i < 100; i++)
        set.add(i);
}
1
2
...
22
23

이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다. 메인 스레드가 이미 syncronized의 락을 쥐고 있기 때문에, 백그라운드 스레드가 락을 얻을 수 없어 계속 대기만 하게 된것이다.

해결법

이런 문제는 대부분 어렵지 않게 해결할 수 있다. 외계인 메서드 호출을 동기화 블록 바깥으로 옮기면 된다.

관찰자 리스트를 복사해 쓰면 락 없이도 안전하게 순회할 수 있다.

외계인 메서드를 동기화 블록 바깥으로 옮긴다.

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

자바의 동시성 컬렉션 라이브러리의 CopyOnWriteArrayList가 정확히 이 목적으로 설계된 것이다.

ArrayList를 구현했지만, 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현했다.

위의 컬렉션 라이브러리를 이용해 앞서 구현한 ObservableSet를 구현하면 아래와 같다. 명시적으로 동기화한 곳이 사라졌다는것에 주목하자.

public ObservableSet(Set<E> set) { super(set); }

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

동기화의 기본 규칙

동기화의 영역에서는 가능한 한 일을 적게 하는 것이다.

락을 얻고, 공유 데이터를 검사하고, 필요하면 수정하고, 락을 놓는다. 오래 걸리는 작업이라면 Item78의 지침을 어기지 않으면서 동기화 영역 바깥으로 옮기는 방법을 찾아보자.

동기화의 성능

자바의 동기화 비용은 빠르게 낮아져 왔지만, 과도한 동기화를 피하는 일은 오히려 어느때보다 중요하다.

과도한 동기화가 초래하는 진짜 비용은 경쟁하느라 낭비하는 시간, 병렬로 실행할 기회를 잃고 모든 코어가 메모리를 일관되게 보기위한 지연시간이 진짜 비용이다.

가변 클래스 작성시 규칙

  1. 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자. (ex. java.util)
  2. 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자. 단 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을때 써야한다. (ex. java.util.concurrent)

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

멀티스레드의 정적 필드 수정

여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면 그 필드를 사용하기 전에 반드시 동기해야 한다.

클라이언트가 여러 스레드로 복제되 구동되는 상황이라면 다른 클라이언트에서 이 메서드를 호출하는 걸 막을 수 없으니 외부에서 동기화할 방법이 없다.

결과적으로, 이 정적 필드가 심지어 private라도 서로 관련없는 스레드들이 동시에 읽고 수정할 수 있게 되니 주의해야 한다.