과도한 동기화는 성능을 떨어뜨리고 교착상태에 빠트리고, 예측할 수 없는 결과를 낳는다.
동기화 메서드나 동기화 블럭에서 제어를 클라이언트에 양도하면 안된다.
동기화 블럭에서 제어를 클라이언트에 양도한 예시와 문제점 - 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);
}
}
이 메서드를 실행하면 다음의 오류를 뱉고 실행을 종료한다.
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 을 잡지 못하고 계속해서 대기해야한다.
동기화의 기본 법칙은 동기화 영역 내에서는 가능한 일을 적게하는 것이다.
가변 클래스를 작성할 때 따라야할 선택지
- 동기화를 고려하지 말고 사용하는 측에서 고려하도록 하라
- 동기화를 내부에서 수행해 스레드안전하게 만들자(외부에서 전체에 락을 거는 것보다 더 효율이 높을 시에만)
- 락 분할(lock splitting) - 하나의 클래스에서 기능적으로 락을 분리해서 사용하는 것(두개 이상의 락 - ex: 읽기전용 락, 쓰기전용 락)
- 락 스트라이핑(lock striping) - 자료구조 관점에서 한 자료구조 전체가 아닌 일부분(buckets or stripes 단위)에 락을 적용하는 것
- 비차단 동시성 제어(nonblocking concurrency control)
'Reading Record > 이펙티브자바' 카테고리의 다른 글
[아이템 81] wait와 notify 보다는 동시성 유틸리티를 애용하라 (0) | 2020.07.03 |
---|---|
[아이템 80] 스레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2020.07.03 |
[아이템69 ~77] 예외 (0) | 2020.06.30 |
[아이템 40] @Override 애너테이션을 일관되게 사용하라 (0) | 2020.06.30 |
[아이템 41] 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라 (0) | 2020.06.30 |