peaches-book-study / effective-java

이펙티브 자바 3/E
0 stars 2 forks source link

Item 79. 과도한 동기화는 피하라 #69

Open Lainlnya opened 1 month ago

Lainlnya commented 1 month ago

Chapter : 11. 동시성

Item : 79. 과도한 동기화는 피하라

Assignee : Lainlnya


🍑 서론

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

🍑 본론

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

동기화된 영역을 포함한 클래스 관점에서 “재정의할 수 있는 메서드” 나 “클라이언트가 넘겨준 함수 객체”는 모두 무슨 일을 할지 알지 못하며 통제할 수 없다.

즉, 예외를 일으키거나, 교착상태에 빠지거나, 데이터를 훼손할 수도 있다.

example

public static 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); // List 내부에 포함되어있는 메서드
        }
    }

    public boolean removeObserver(SetObserver<E> observer) {
        synchronized (observers) {
        return observers.remove(observer); // List 내부에 포함되어있는 메서드
        }
    }

    private void notifyElementAdded(E element) {
        **synchronized (observers) {
            for (SetObserver<E> observer : observers) {
                observer.added(this, element); // List 내부에 포함된 메서드가 아닌 클라이언트가 넘겨준 함수
            }
        }**
    }

    @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 호출
            // 왼쪽 값에 오른쪽 값을 비트 OR 연산 후 왼쪽에 대입
        }

        return result;
    }
}

// 함수형 인터페이스로 단 하나의 추상 메서드만을 가질 수 있음
@FunctionalInterface
public interface SetObserver<E> {
    void added(ObservableSet<E>set, E element);
}

만약 여기서 아래 코드를 실행한다고 가정했을 때

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) {
                    s.removeObserver(this);
                }
            }
        });
        for (int i = 0; i < 100; i++) {
            set.add(i); // 여기서 add를 하면 override해서 만든 add 메서드가 실행되기 때문에 notifyElementAdded 메서드가 실행된다.
        }
}

✅ 결과

23까지 출력하고 난 이후 ConcurrentModificationException을 던진다.

Untitled

ConcurrentModificationException 란? 하나 이상의 스레드가 동시에 컬렉션을 수정하려고 할 때 발생하는 예외

  1. 위에서 added 하며 removeObserver 메서드를 호출한다.
  2. removeObserver의 경우, 다시 observers.remove 메서드를 호출한다.
  3. 리스트를 제거하려고 하는데 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중과 겹친다.

⭐️ notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시 수정이 일어나지 않도록 보장하지만, 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다.


만약 구독해지를 하는 관찰자를 실행자 서비스(ExecutorService)를 통해 다른 스레드에게 부탁한다면?

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) {
                    // 백그라운드 스레드가 하나 실행됨
                    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);
        }
    }

✅ 결과

기존의 notifyElementAdded 메서드 내부의 for-each 에서 락을 쥐고 있기 때문에, 새로 생성된 스레드는 removeObserver 메서드에서 락을 얻을 수 없다.

즉, 데드락 (deadlock) 교착 상태에 빠지게 된다.

🔒 락(lock)을 얻는 다는 것의 의미 여러 스레드가 동시에 접근하는 공유 자원에 대한 접근을 제어하는 메커니즘 스레드가 공유 자원에 접근하려면 먼저 그 자원에 대한 락을 획득해야 한다.


만약 같은 상황이지만 불변식이 임시로 깨진 경우라면 ?

🔒 불변식이 깨진다는 것의 의미

객체의 상태가 유효한 범위를 벗어났다는 의미.

즉, 해당 객체를 가져다가 연산을 수행하면 예기치 않은 동작으로 이어질 수 있다는 것

🔒 불변식이 임시적으로 깨지는 상황이 발생하는 이유

  1. 멀티스레딩 환경
    1. 여러 스레드가 동시에 객체의 상태를 변경하는 경우, 불변식이 일시적으로 깨질 수 있다.
  2. 복잡한 연산 과정
    1. 객체의 상태를 변경하는 과정에서 복잡하다면 중간 단계에서 불변식이 깨질 수 있다.
  3. 예외 상황
    1. 예외가 발생하면 객체의 상태가 유효하지 않은 상태에 놓일 수 있다.
  4. 외부 요인
    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);
            }
        }

** 동기화 바깥에서 호출되는 외계인 메서드를 열린 호출(open call)이라 한다.

외계인 메서드는 얼마나 오래 실행될지 알 수 없는데, 동기화 영역 안에서 호출된다면 다른 스레드는 보호된 자원을 사용하지 못하고 대기해야 한다.

그렇기 때문에 열린 호출은 실패 방지 효과 외에도 동시성 효율을 개선하는 효과가 있다.

  1. 자바의 동시성 컬렉션 라이브러리인 CopyOnWriteArrayList를 사용하기

    정확히 이 목적을 위해 설계된 라이브러리이다.

    CopyOnWriteArrayList ArrayList를 구현한 클래스로, 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행한다. 내부의 배열은 절대 수정되지 않기 때문에 락이 필요 없어 매우 빠르다. 수정할 일은 드물고 순화만 번번히 일어나는 관찰자 리스트 용도로는 최적이지만, 다른 용도로 사용했을 경우에는 굉장히 느리다.

private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<>();

public void addObserver(SetObserver<E> observer) {
    observers.add(observer); // List 내부에 포함되어있는 메서드

}

public boolean removeObserver(SetObserver<E> observer) {
    return observers.remove(observer); // List 내부에 포함되어있는 메서드
}

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

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


가변 클래스

객체의 내부 상태를 변경할 수 있는 클래스

가변 클래스는 멀티스레드 환경에서 안전하지 않다.

여러 스레드가 동시에 가변 객체의 상태를 변경하면 데이터 경합(Race Condition)이 발생할 수 있다.

가변 클래스를 사용할 때는 적절한 동기화 메커니즘을 적용해야 하며, 동기화를 통해 여러 스레드가 가변 객체에 안전하게 접근할 수 있도록 보장해야 한다.

예) StringBuilderArrayListHashMap

가변 클래스를 작성하는 2가지 선택지

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

    StringBuffer 인스턴스의 경우 항상 단일 스레드에서 쓰이나, 내부적으로 동기화를 수행했기 때문에 이후에 동기화가 되지 않는 StringBuilder가 등장했다.

    이렇게 동기화 되어있는 버전과 동기화 되어있지 않은 버전이 두 가지 존재하고, 선택하기 어렵다면

    동기화 하는 것보다는, 문서에 “스레드 안전하지 않다”고 명기하는 것이 좋다. ⇒ 과도한 동기화를 피하는 것이 중요하기 때문이다.

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

    ⇒ 락 분할 (lock splitting), 락 스트라이핑 (lock striping), 비차단 동시성 제어(nonblocking concurrency control) 등 다양한 기법을 동원해 동시성을 높여줄 수 있다.

🍑 결론


Referenced by

-