본문 바로가기

Reading Record/이펙티브자바

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

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

동기화 메서드나 동기화 블럭에서 제어를 클라이언트에 양도하면 안된다.

 

 

동기화 블럭에서 제어를 클라이언트에 양도한 예시와 문제점 - effective Java

 

기본코드

ObservableSet에 관찰자를 SetObserver를 추가하면(addObserver) ObservableSet.add 메서드가 호출될 때마다 notifyElementAdded가 호출되고, 추가된 관찰자의 added 메서드가 호출된다(observer.added())

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) { // Set에 add하면 관찰자의 added 메서드를 호출한다.
        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;
    }
}

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

        set.addObserver((set1, element) -> System.out.println(element)); // add 된 원소를 출력한다.
    // 제어로직이 람다식으로 밖에있다

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

 

observers 는 한번에 한 스레드에서만 접근할 수 있도록 synchronized 블록으로 감쌌다.

언뜻보면 문제가 없는 코드인 것 같지만(실제로 이 코드는 문제가없다.), 위에서 주의하라고 언급되었던 제어권은 이 코드의 바깥에 있다.(람다식)

 

 

문제를 일으킬 수 있는 익명함수 콜백 - ConcurrentModificationException

위에서는 main 메서드에 addObserver인자로 출력만 담당하는 람다식을 넣어주었다.

 

그렇다면 이번엔 다른 익명함수를 넘겨줘보자.

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

        set.addObserver(new SetObserver<>() {
            @Override
            public void added(ObservableSet<Integer> s, Integer e) {
                System.out.println(e);
                if (e == 23) // 값이 23이면 자신을 구독해지한다.
                    s.removeObserver(this); //this 를 넘겨주어야하기 때문에 람다로는 안됨.
            }
        });

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

 

 

이 메서드를 실행하면 다음의 오류를 뱉고 실행을 종료한다.

image

set.add() 가 호출되면 notifyElementAdded() 메서드가 호출된다.
그리고 이 메서드는 for-each 문으로 각 관찰자를 순회하며 관찰자의 added 메서드를 호출한다.

그런데 여기 메인메서드에거 정의한 added 메서드는 removeObserver 메서드를 호출하여 자기 자신을 넘겨주고 있는데,

이미 for-each 문에서 observers 를 synchronized 로 감싸고 있기 때문에 removeObserver 호출 시 ConcurrentModificationException 이 터지는 것이다. 콜백을 거쳐 돌아와서 수정되는 것까지 막지는 못하기 때문이다.

 

 

문제를 일으키는 스레드 - 교착상태

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

        // 코드 79-2 쓸데없이 백그라운드 스레드를 사용하는 관찰자 (423쪽)
        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();
                    }
                }
            }
        });

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

 

위의 exec는 새로운 쓰레드에 removeObserver를 수행하는 람다함수를 넘긴다.

이 메서드는 실행하면 23까지 출력하고 계속 멈춰있다.

removeObserver를 수행하기 위해서는 observers(락)를 획득해야하는데 이 observers는 메인스레드에서 실행중인 notifyElementAdded 메서드에 의해 획득될 수 없다.

 

 

불변식이 임시로 깨진 경우

위의 경우는 문제가 생길 수는 있지만 observers의 일관성이 깨지지는 않는다.

락의 재진입 가능성: 이미 락을 획득한 스레드는 다른 synchronized 블록을 만났을 때 락을 다시 검사하지 않고 진입 가능하다.

재진입 가능한 락은 다음과 같이 교착상태를 회피할 수는 있게하지만, 안전실패(데이터훼손)로 변모시킬 수 있다.

public class Test {
  public synchronized void a() {
    b(); // 이론적으로라면 여기서 교착상태여야하지만 같은 스레드에 한해 재진입을 허용하기 때문에 
  }
  public synchronized void b() { // 진입 가능하다
  }
  public static void main(String[] args) {
    new Test().a();
  }
}

 

 

// 안전실패 예시 생각하기

 

 

이 문제의 해결방법은 간단하다. 외부 클라이언트에 의해 제어되는 코드를 동기화블럭 밖으로 이동하는 것이다.

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

 

또는 java.util.concurrent.CopyOnWriteArrayList 를 사용하면 똑같은 작업을 대체할 수 있다.

 

 

동기화 영역 밖에서 오는 외계인 메서드(바깥에서 오는 메서드)는 열린호출(open call)이라고 한다.

이 메서드는 언제 끝날지 내부에서는 알 수 없기 때문에 동기화블럭 안에서 실행하면 다른 스레드는 이 Lock 을 잡지 못하고 계속해서 대기해야한다.

 

 

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

가변 클래스를 작성할 때 따라야할 선택지

  1. 동기화를 고려하지 말고 사용하는 측에서 고려하도록 하라
  2. 동기화를 내부에서 수행해 스레드안전하게 만들자(외부에서 전체에 락을 거는 것보다 더 효율이 높을 시에만)
  • 락 분할(lock splitting) - 하나의 클래스에서 기능적으로 락을 분리해서 사용하는 것(두개 이상의 락 - ex: 읽기전용 락, 쓰기전용 락)
  • 락 스트라이핑(lock striping) - 자료구조 관점에서 한 자료구조 전체가 아닌 일부분(buckets or stripes 단위)에 락을 적용하는 것
  • 비차단 동시성 제어(nonblocking concurrency control)