[Effective Java] 아이템 79: 과도한 동기화는 피하라

Loopy·2023년 6월 24일
0

이펙티브 자바

목록 보기
74/76
post-thumbnail

과도한 동기화는 오히려 성능을 떨어트리고, 교착상태에 빠뜨린다.

☁️ 과도한 동기화 예시

재정의 가능한 메서드나 클라리언트가 넘겨준 함수 객체를 동기화 영역 안에서 호출하면 어떤 짓을 하는지 통제할 수 없기 때문에 예외를 일으키거나 교착상태에 빠지거나, 데이터를 훼손할 수 있다.

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

다음은 아이템 18에서 보았던 컴포지션을 활용한 코드이다.

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

하지만, 이 코드는 외부에서 함수 객체( SetObserver )를 받아오기 때문에 아래와 같은 두가지 에러가 발생할 수 있는 위험에 노출되어 있다.

1. ConcurrentModificationException 예외

한 스레드만 observer 리스트에 접근할 수 있기 때문에 동기화 관련 예외가 터지지 않을것 같아 보인다.

public class Test2 {
    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) // 값이 23이면 자신을 구독해지
                    s.removeObserver(this);
            }
        });
    }
}

하지만 외부 함수 객체 메서드인 added() 가 동기화 블럭 내에 존재하게 되면서, 다음과 같은 문제를 야기한다.

  1. addObserver() -> add() -> notifyElementAdded -> added() 호출
  2. added() -> removeObserver() 호출

이미 1번 과정에서 에서 observerssynchronized 감싸고 있음에도, 콜백을 거쳐 돌아와서 수정되는 것까지 막지는 못하기 때문에 같은 리스트를 동시에 수정하려고 접근하게 되고 ConcurrentModificationException 이 발생하게 된다.

2. 교착 상태(DeadLock)

이번에는 1번에서의 예외를 피하기 위해 아예 다른 스레드를 통해 작업을 수행해보자.

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

하지만 예외는 일어나지 않지만, 메인 스레드가 addObserver 에서 이미 observers 에 대한 락을 쥐고 있기 때문에, 다른 백그라운드 스레드가 removeObserver 를 호출 시 락을 얻을 수 없어 교착 상태가 발생한다.

3. 데이터의 훼손(불변)

자바 고유의 락은 재진입이 가능하다. 따라서, 위에서의 교착 상태는 회피할 수 있다.

무슨 말이냐 하면, 락의 획득이 메서드 호출 단위가 아닌 스레드 단위로 일어난다는 것이다. 스레드 단위로 일어나기 때문에 같은 스레드가 다른 synchronized 영역을 만나게 되면 역시 대기하지 않고 바로 접근할 수 있다.

// 만약 재진입이 불가능했다면, 데드락이 발생 
public class Reentrancy {
  public synchronized void a() {
      System.out.println("a");
      b();    // 2. 동기화 구간 바로 접근 가능
  }

  public synchronized void b() {
      System.out.println("b");
  }

  public static void main(String[] args) {
      new Reentrancy().a(); // 1. 락 획득
  }
}

하지만 만약 다른 동기화 메서드에서 락이 보호하고 있는 데이터에 대해 관련이 없는 다른 작업이 진행 중이라면, 이 일로 인해 데이터가 훼손될 수 있는 문제가 발생한다.

☁️ 과도한 동기화 문제 해결

  1. 동기화 블럭 외부로 외계인 메서드 빼기

메서드의 호출을 동기화 블럭 바깥으로 빼서, 가능한 한 동기화 영역에서 수행하는 일을 최소화 시킴으로써 문제를 해결할 수 있다. 더불어 리스트를 복사해서 사용하기 때문에 굳이 락 없이도 안전하게 순회가 가능하다.

 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);
 }
  1. CopyOnWirteArrayList 사용

내부를 변경하는 작업은 항상 복사본을 통해 이루어지기 때문에, 데이터 순회 시 동기화가 필요 없어 속도가 매우 빠르다는 장점이 있다. 따라서 수정할 일이 드물고 순회만 자주 일어나는 경우 사용하면 좋다.

private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<>();
...
private void notifyElementAdded(E element) {
     for (SetObserver<E> observer : observers)
          observer.added(this, element);
}

☁️ 동기화 시 주의점

과도하게 동기화를 하면, 병렬로 실행을 못하게 되기 때문에 경쟁하고 메모리를 일관되게 보기 위한 지연 시간으로 인해 오히려 비효율적이다.

불변이 아닌 가변 클래스를 설계할 때는, 아래와 같이 두 가지를 고민해보면 된다.

  1. 외부에서 동기화
    해당 클래스를 동시에 사용해야 하는 클래스가 외부에서 객체 전체에 동기화를 거는 방식이다. ex) java.util

  2. 내부에서 동기화
    외부보다 동시성을 월등히 개선 가능할 때만 사용해야 한다.
    ex) java.util.concurrentConcurrentHashMap, BlockingQueue ..

🔖 핵심 정리
교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 호출하지 말고 작업을 최소한으로 줄여야 한다. 멀티코어 세상에서는 과도한 동기화를 피하는게 좋기 때문에 합당한 이유가 있을 때만 내부에서 동기화를 시키고, 문서에 정확히 여부를 밝히도록 하자.

https://dev.to/ahmetoz/thread-safety-issues-in-java-4dkn

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글