티스토리 뷰
교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자.
🧶 과도한 동기화는 피하라
과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 심지어 예측할 수 없는 동작을 낳기도 한다.
응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다.
예를 들어 동기화된 영역 안에서는 재정의할 수 있는 메서드를 호출하면 안 되며,
클라이언트가 넘겨준 함수 객체를 호출해서도 안된다.
동기화된 영역을 포함한 클래스 관점에서는 이런 메서드는 모두 바깥세상에서 온 외계인으로 느끼므로, 그 메서드가 무슨 일을 할지 알지 못하며 통제도 할 수 없다.
👻 외계인 메서드 (alien method)
외계인 메서드가 하는 일에 따라 동기화된 영역은 예외를 일으키거나, 교착상태에 빠지거나, 데이터를 훼손할 수도 있다.
다음은 어떤 집합(Set)을 감싼 래퍼 클래스이고, 이 클래스의 클라이언트는 집합에 원소가 추가되면 알림을 받을 수 있다.(관찰자 패턴)
//잘못된 코드. 동기화 블록 안에서 외계인 메서드를 호출한다.
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 (observer) {
observer.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);
return result;
}
}
관찰자들은 addObserver와 removeObserver 메서드를 호출해 구독을 신청하거나 해지한다.
두 경우 모두 다음 콜백 인터페이스의 인스턴스를 메서드에 건넨다.
@FunctionalInterface
public interface SetObserver<E>{
//ObservableSet에 원소가 더해지면 호출된다.
void added(ObservableSet<E> set, E element);
}
👀 예제 1. ConcurrentModificationException
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);
}
이 프로그램은 0부터 23까지 출력한 후 관찰자 자신을 구독해지한 다음 조용히 종료될 것처럼 보이지만,
23까지 출력한 다음 ConcurrentModificationException을 던진다.
관찰자의 added 메서드 호출이 일어난 시점이 notifyElementAdded가 관찰자들의 리스트를 순회하는 도중이기 때문이다.
added메서드는 ObservableSet의 removeObserver 메서드를 호출하고,
이 메서드는 다시 observers.remove 메서드를 호출한다.
여기서 리스트에서 원소를 제거하려 하는데, 마침 지금은 이 리스트를 순회하는 도중이기때문에 허용되지 않은 동작이다.
notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시 수정이 일어나지 않도록 보장하지만, 정작 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다.
👀 예제 2. 교착상태
//쓸데없이 백그라운드 스레드를 사용하는 관찰자
set.addObserver(new SetObserver<>(){
public void added(ObservableSet<Integer> s, Integer e){
System.out.println(e);
if(e==23){
ExcutorService exec = Executors.newSingleThreadExecutor();
try{
exec.submit(() -> s.removeObserver(this)).get();
} catch (ExecutionException | InterruptedException ex){
throw new AssertionError(ex);
} finally{
exec.shutdown();
}
}
}
});
이 프로그램을 실행하면 예외는 나지 않지만 교착상태에 빠진다.
백그라운드 스레드가 s.removeObserver를 호출하면 관찰자를 잠그려 시도하지만 락을 얻을 수 없다.(메인 스레드가 이미 락을 쥐고 있기 때문에)
하지만 메인스레드는 백그라운드 스레드가 관찰자를 제거하기만을 기다리고 있다.
👀 해결방법 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);
}
이 방법을 적용하면 예외 발생과 교착상태 증상이 사라진다.
👀 해결방법 2. CopyOnWriteArrayList를 사용한다.
//CopyOnWriteArrayList를 사용해 구현한 스레드 안전하고 관찰 가능한 집합
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를 구현한 클래스로, 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현했다.
내부의 배열은 절대 수정되지 않으니 순회할 때 락이 필요 없어 매우 빠르다.
다른 용도로 쓰인다면 CopyOnWriteArrayList는 끔찍이 느리겠지만, 수정할 일은 드물고 순회만 빈번히 일어나는 관찰자 리스트 용도로는 최적이다.
✨ 동기화의 기본 규칙
동기화 영역에서는 가능한 한 일을 적게 하는 것이 기본 규칙이다.
락을 얻고, 공유 데이터를 검사하고, 필요하면 수정하고, 락을 놓는다.
오래 걸리는 작업이라면 동기화 영역 바깥으로 옮기는 방법을 찾아보자.
🎉 동기화의 성능 개선
멀티코어가 일반화된 오늘날, 병렬로 실행할 기회를 잃고 모든 코어가 메모리를 일관되게 보기 위한 지연시간이 동기화의 진짜 비용이라고 할 수 있다.
따라서 가변 클래스를 작성하려거든 두 개의 선택지 중 하나를 따르자.
1. 동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 하자. -> java.util
2. 동기화를 내부에서 수행해 스레드 안전한 클래스로 만들자. (단, 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 선택해야 한다) -> java.util.concurrent
💡 정리
- 교착상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자.
- 동기화 영역 안에서의 작업은 최소한으로 줄이자.
- 가변 클래스를 설계할 때는 스스로 동기화해야 할지 고민하자.
- 합당한 이유가 있을 때만 내부에서 동기화하고, 동기화했는지 여부를 문서에 명확히 밝히자
'Programming > Effective Java' 카테고리의 다른 글
[이펙티브자바] Item 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2022.08.30 |
---|---|
[이펙티브자바] Item 78. 공유 중인 가변 데이터는 동기화해 사용하라 (1) | 2022.08.25 |
[이펙티브자바] Item 77. 예외를 무시하지 말라 (1) | 2022.08.23 |
- Total
- Today
- Yesterday
- IMAGE
- Retrofit2
- BOJ
- 백준
- 조합
- EffectiveJava
- 토큰기반인증
- docker-compose
- subset
- dp
- springboot
- OS
- 순열
- bruteforce
- dfs
- BFS
- Java
- Container
- 완전탐색
- 완탐
- 운영체제
- docker
- 이펙티브자바
- cicd
- 그래프탐색
- DevOps
- 아이템59
- 알고리즘
- 아이템60
- 아이템61
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |