핵심 요약
- 교착 상태와 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메소드를 절대 호출하지 말자.
- 동기화 영역 안에서의 작업은 최소한으로 줄이자.
- 가변 클래스를 설계할 때는 스레드 안전한 클래스를 만들지에 대한 여부를 고민하자. 그리고 이를 문서에 명기하자.
과도한 동기화
- 동기화는 프로그램 안정성을 보장하기 위해 중요하지만, 과도하게 사용하면 성능 저하, 교착 상태, 예측할 수 없는 동작 등의 문제를 야기할 수 있다.
- 동기화된 영역에서는 클라이언트에게 제어를 양도하면 안 된다.
- 예를 들어, 재정의할 수 있는 메소드 호출이나 클라이언트가 전달한 함수 객체 호출은 동기화된 영역에서 예측 불가능한 문제를 발생시킬 수 있다.
❔ 외계인 메소드
더보기
외계인 메서드란 동기화된 영역 안에서 재정의 메서드를 호출하거나 클라이언트가 넘겨준 함수 객체를 호출하는 것을 뜻한다.
예시
: 과도한 동기화
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;
}
}
- Set을 감싸는 래퍼 클래스인 ObservableSet 클래스는 관찰자 패턴을 구현한 것이다.
- 클라이언트가 이 클래스를 사용하면 집합에 원소가 추가되면 알림을 받을 수 있다.
- ForwardingSet 클래스를 재사용하여 구현하였으며, addObserver와 removeObserver 메소드로 구독 신청 및 해지를 할 수 있다.
@FunctionalInterface
public interface SetObserver<E> {
// ObservableSet에 원소가 추가되면 호출된다.
void added(ObservableSet<E> set, E element);
}
- 관찰자들은 SetObsever 인터페이스를 구현하여 관찰자 객체를 생성한다.
- 이 인터페이스는 added 메소드를 정의하며, ObservableSet에 원소가 추가될 때 호출된다. 이를 통해 관찰자들은 집합에 변화가 생겼을 떄 알림을 받을 수 있다.
- cf. SetObserver 인터페이스는 구조적으로 BiConsumer와 유사하다. 그러나 더 직관적인 이름을 가지며, 다중 콜백을 지원하도록 확장할 수 있다. (item 44).
문제 상황 1
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
set.addObserver((s, e) -> System.out.println(e));
for (int i = 0; i <= 100; i++) {
set.add(i);
}
}
- 원소가 추가될 때마다 해당 원소를 출력하는 동작을 한다.
- 과연 문제가 없을까? 아래 코드를 보자.
문제 상황 2 - 안전 실패(데이터 훼손)
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) s.removeObserver(this);
}
});
for (int i = 0; i < 100; i++)
set.add(i);
}
- 집합에 원소가 추가하면 해당 원소를 출력하고 만약 해당 원소가 23이라면 관찰자를 제거하는 로직을 구현한 예시이다.
- 그러나 동시에 여러 스레드가 관여하면서 문제가 발생할 수 있다.
- added 메소드는 notifyElementAdded 내에서 호출되며, 만약 원소가 23이라면 해당 원소를 출력하고 removeObserver를 호출하여 관찰자를 제거하려 한다.
- 그런데 removeObserver 메소드는 내부적으로 observers.remove 메소드를 호출하며, 이로 인해 리스트의 구조가 변경된다. 동시에 notifyElementAdded 메소드에서 해당 리스트를 순회하고 있으므로, 리스트의 구조 변경으로 인해 ConcurrentModificationException이 발생한다.
- 즉, notifyElementAdded 메소드의 순회는 동기화 블록 안에 있어서 관찰자 리스트의 구조를 변경하는 것은 막을 수 있다. 그러나 순회하는 중에 added 메소드가 관찰자 리스트를 수정할 수 있는 상황을 완전히 막을 수 없다.
문제 상황 3 - 응답 불가(교착 상태)
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();
}
}
}
});
- ObservableSet에 구독을 해지하는 관찰자를 만드는데, 이번에는 removeObserver 메소드를 직접 호출하지 않고, 별도의 스레드를 이용해서 호출한다. 다른 스레드에게 작업을 부탁할 때 ExecutorService를 사용한다.
- ExecutorService은 스레드 풀을 관리하고 다른 스레드에게 작업을 위임할 수 있는 도구이다.
- 원소가 23일 때 백그라운드 스레드를 생성하고 거기에서 removeObserver를 호출하도록 한다.
- 이때 메인 스레드와 백그라운드 스레드가 서로 락을 얻으려고 경쟁을 한다.
- 메인 스레드가 관찰자 스레드를 수정하려고 락을 잡고 있기 때문에 백그라운드 스레드는 락을 얻을 수 없다.
- 동시에, 메인 스레드는 백그라운드 스레드가 removeObserver를 호출하고 끝날 때까지 기다리고 있다.
- 이렇게 되면 두 스레드가 서로를 기다리는 교착 상태에 빠지게 된다.
- 한 스레드는 락을 얻기 위해 기다리고, 다른 스레드는 호출이 끝날 때까지 기다리기 때문이다.
Java의 락
- 자바의 락은 재진입이 가능하다(이미 획득한 락을 다시 획득할 수 있다)는 특징을 가지고 있다.
- ex. 문제 상황 2
- 락을 이미 획득한 스레드가 다시 락을 획득하려고 해도 획득에 성공하는데, 이 스레드가 다른, 데이터와 관련 없는 작업을 수행하고 있을 수 있다.
- 이런 경우에는 락이 데이터 보호를 못하고, 예상치 못한 결과를 초래할 수 있다.
- 즉, 응답 불가(교착 상태) 상황을 안전 실패(데이터 훼손) 상황으로 만들 수 있다는 문제가 생길 수 있다.
재진입 가능 락으로 인한 문제 해결 방법
- 해결 방법 1: 락을 보호하고자 하는 데이터와 관련된 동작을 동기화 블록 바깥으로 옮긴다.
// 열린 호출(open call)
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를 사용한다.
- 이 컬렉션은 복사본을 만들어 작업을 수행하므로 순횐 작업에 락이 필요하지 않는다.
- 재진입 가능 락이 생길 수 있는 문제를 예방할 수 있다.
private final List<SetObserser<E>> observers = new CopyOnWriteArrayList<>(); // CopyOnWriteArrayList로 생성
public void addObserver(SetObserver<E> observer) { // 관찰자 추가
observers.add(observer);
}
public boolean removeObserver(SetObserver<E> observer) { // 관찰자 제거
return observers.remove(observer);
}
public void notifyElementAdded(E element) { // 원소가 추가될 때 관찰자들에게 알림을 보내는 메소드
for (SetObserver<E> observer : observers) {
observers.added(this, element);
}
}
열린 호출(open call)
- 동기화 영역 바깥에서 호출되는 외계인 메소드를 뜻한다.
- 외계인 메소드는 얼마 동안 실행될지 예측할 수 없으며, 동기화 락을 확보하고 관련 데이터를 처리하는 시간동안 다른 스레드들을 대기해야 한다.
- 따라서 동기화 영역에서는 가능한 일을 최소화해야 한다.
- 만약 왜 걸리는 작업이 필요하다면, 이를 동기화 영역 바깥으로 옮겨서 열린 호출로 처리하는 것이 좋다.
- 이렇게 하면 락을 최소한으로 확보하여 동시성 효율을 개선할 수 있다.
- + 아이템 78의 원칙 또한 고려하자.
성능 측면에서 과도한 동기화의 문제점
- 동기화는 멀티스레드 환경에서의 정확성을 보장하는 중요한 개념이며, 과도한 동기화는 성능에 부정적인 영향을 미칠 수 있다.
- 현대의 멀티코어 시스템에서는 CPU 시간 자체보다는 병렬 실행 기회를 놓치는 것과 모든 코어가 일관된 메모리 상태를 보기 위해 필요한 지연 시간이 중요한 성능 영향 요소이다.
- 심지어 가상머신의 코드 최적화를 제한한다.
가변 클래스를 작성할 경우 선택 가이드
클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두 번째 방법을 선택해야 한다. (선택하기 어렵다면 동기화하지 말고, 문서에 스레드 안전하지 않다고 명기하자)
방법 1. 외부에서 동기화
- 클래스 내부에 동기화를 추가하지 않고, 클래스를 사용하는 클라이언트가 외부에서 동기화를 수행하도록 하는 방식이다.
- 동시에 여러 스레드가 클래스 인스턴스에 접근할 떄 외부에서 락을 사용하여 동기화하게 된다.
- ex. java.util (Vector, Hashtable 제외), StringBuilder, java.util.concurrent.ThreadLocalRandom
방법 2. 클래스 내부에서 동기화(item 82)
- 클래스 내부에서 동기화를 수행하여 스레드 안전한 클래스를 만드는 방식이다.
- 클래스 내부의 메소드나 블록에 동기화를 사용하여 여러 스레드가 안전하게 클래스를 사용할 수 있다.
- ex. java.util.concurrent(item 81), StringBuffer, java.util.Random
- 락 분할, 락 스트라이핑, 비차단 동시성 제어 등 다양한 기법을 통해 동시성을 향상시킬 수 있다.
- 만약 여러 스레드가 호출할 가능성이 있는 메서드가 정적 필드를 수정한다면, 이 정적 필드를 사용하기 전에 반드시 동기화해야 한다.
- 그런데 클라이언트가 여러 스레드로 복제돼 구동되는 상황이라면 다른 클라이언트에서 이 메소드를 호출하는 걸 막을 수 없으니 외부에서 동기화할 방법이 없다.
- 결과적으로 이 정적 필드가 심지어 private라도 서로 관련 없는 스레드들이 동시에 읽고 수정할 수 있게 된다.
- ex. item 78의 generateSerialNumber 메소드의 nextSerialNumber 필드
'Language > Java' 카테고리의 다른 글
[effective java] 아이템 81. wait와 notify보다는 동시성 유틸리티를 애용하라. (0) | 2023.08.13 |
---|---|
[effective java] 아이템 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라. (0) | 2023.08.13 |
[effective java] 아이템 78. 공유 중인 가변 데이터는 동기화해 사용하라. (0) | 2023.08.13 |
[effective java] 아이템 77. 예외를 무시하지 말라. (0) | 2023.08.12 |
[effective java] 아이템 76. 가능한 한 실패 원자적으로 만들라. (0) | 2023.08.12 |