과도한 동기화는 1. 성능을 떨어뜨리고 2. 교착상태에 빠뜨리고 3. 예측할 수 없는 동작 을 일으킨다.
응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다.
동기화된 영역 안에서는
1. 재정의할 수 있는 메서드는 호출 하면 안 된다.
2. 클라이언트가 넘겨준 함수 객체를 호출해선 안 된다.(아이템 24)
위에 해당하는 메서드는 '무슨 일을 할지 알지 못하며 통제할 수 없는' 외계인 메서드이다.
외계인 메서드를 사용하면 동기화된 영역은 1. 예외를 일으키거나 2. 교착상태에 빠지거나 3. 데이터를 훼손 할 수 있다.
어떤 집합(Set)을 감싼 래퍼 클래스이고 집합에 원소가 추가되면 알림을 받는 관찰자 패턴을 사용한 예제 코드이다. ForwardingSet은 아이템 18 참고.
[코드 79-1] 잘못된 코드. 동기화 블록 안에서 외계인 메서드를 호출한다.
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;
}
}
관찰자들은 addObserver와 removeObserver 메서드를 호출해 구독을 신청하거나 해지한다. 두 메서드 모두 SetObserver 콜백 인터페이스의 인스턴스를 매개변수로 갖는다.
@FunctionalInterface
public interface SetObserver<E> {
// ObservableSet에 원소가 추가되면 호출된다.
void added(ObservableSet<E> set, E element);
}
public class Test2 {
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
set.addObserver(new SetObserver<Integer>() {
@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);
}
}
이 프로그램은 23까지 출력한 다음 ConcurrentModificationException 예외를 던진다.
관찰자의 added 메서드를 호출한 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다.(add 메서드에서 notifyElementAdded 수행)
added 메서드는 ObservableSet의 removeObserver 메서드를 호출하고, 이 메서드는 다시 observers.remove 메서드를 호출한다. 여기서 문제가 발생!
리스트에서 원소를 제거하려 하는데, 마침 지금 이 리스트를 순회하는 도중이다. 즉, 허용되지 않은 동작이다.
notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시 수정이 일어나지 않도록 보장하지만, 자신이 콜백을 거쳐 되돌아와 수정하는 것까지는 막지 못한다.
[코드 79-2] 쓸데없이 백그라운드 스레드를 사용하는 관찰자
package Item79;
import java.util.HashSet;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test3 {
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
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 {
// 여기서 lock이 발생한다. (메인 스레드는 작업을 기리고 있음)
// 특정 태스크가 완료되기를 기다린다. (submit의 get 메서드)
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();
}
}
[코드 79-3] 외계인 메서드를 동기화 블록 바깥으로 옮겼다.
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);
}
}
[코드 79-4] CopyWriteArrayList를 사용해 구현한 스레드 안전하고 관찰 가능한 집합
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);
CopyOnWriteArrayList는 ArrayList를 구현한 클래스로 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현돼있다.
내부의 배열은 수정되지 않아 순회할 때 락이 필요 없어 매우 빠르다.
다른 용도로 사용된다면 매번 복사해서 느리지만, 수정할 일이 적고 순회만 빈번하게 일어난다면 Observer 리스트 용도로는 최적이다.
가변 클래스를 작성할 때는 두 가지 선택을 할 수 있다.
동기화를 하지 않고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 한다.(ex. Vector와 Hashtable을 제외한 java.util 패키지)
동기화를 내부에서 수행해 thread-safe 한 클래스로 만드는 것이다.(아이템 82) 다만, 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 개선할 수 있을 때 선택해야 한다. (ex. java.util.concurrent 패키지) (아이템 81)
여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면, 그 필드를 사용하기 전에 반드시 동기화해야 한다.