과도한 동기화는 성능을 떨어뜨리고, 교착상태에 빠뜨리고, 예측할 수 없는 동작을 낳기도 한다.
응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다.
동기화된 영역 안에서는 재정의할 수 있는 메서드는 호출하면 안 되며, 클라이언트가 넘겨준 함수 객체를 호출해서도 안된다.
외계인 메서드 (Alien method)
외계인 메서드 (alien method) : 동기화된 영역을 포함한 클래스 관점에서 클라이언트가 넘겨준 함수 객체 또는 재정의할 수 있는 메서드
외계인 메서드가 하는 일에 따라 동기화된 영역은 예외를 일으키거나, 교착상태에 빠지거나, 데이터를 훼손할 수도 있다.
잘못된 코드 예시 - 동기화 블록 안에서 외계인 메서드 호출
- 클래스의 클라이언트는 집합에 원소가 추가되면 알림을 받을 수 있다. (관찰자 패턴)
- 관찰자들은 addObserver와 removeObserver 메서드를 호출해 콜백 인터페이스 SetObserver의 인스턴스를 건넨다.
- SetObserver는 구조적으로 BiConsumer <ObservableSet <E>, E>와 똑같다. (Item44)
Main - 0부터 99까지 집합에 저장
- 잘 동작한다.
Main - 문제를 일으키는 콜백 함수 (앞서와 같이 동작하다 23을 만나면 자신을 제거)
- 람다는 자신을 참조할 수단이 없어 람다 대신 익명 클래스를 사용
- 23까지 출력 후 ConcurrentModificationException 발생
ConcurrentModificationException
컬렉션(Collection)을 수정하는 도중에 다른 스레드에서 해당 컬렉션을 수정하려고 할 때 발생하는 예외
이유
- set.add() 호출 -> notifyElementAdded 호출
- notifyElementAdded는 for-each 문으로 각 observers를 순회하며 observer.added() 호출
- 메인 메서드에서 정의한 added 메서드는 removeObserver 메서드를 호출하여 자기 자신을 넘김
- 리스트에서 원소를 제거하려는데 이미 리스트를 순회하는 도중이다.
notifyElementAdded 메서드에서 수행하는 순회는 동기화 블록 안에 있으므로 동시 수정이 일어나지 않도록 보장하지만, 자기 자신이 콜백을 거쳐 되돌아와 수정하는 것까지 막지는 못한다.
즉, 다른 스레드의 접근을 차단하거나 제어하는 것이지, 이미 진행 중인 스레드의 작업을 막는 것은 아니다.
동기화 블록이 다른 스레드의 접근을 막는다 하더라도, 이미 동기화 블록 안에서 실행 중인 스레드가 다른 메서드나 함수로 이동하면서 동기화 블록 밖에서도 수정 작업을 수행할 수 있다.
콜백 패턴
콜백 패턴은 한 컴포넌트가 완료되면 다른 컴포넌트에게 알림을 주는 메커니즘이다.
이는 일반적으로 이벤트 처리나 비동기 작업에서 사용되는데, 콜백 패턴을 사용하면 어떤 작업의 결과가 준비되었을 때 다른 코드가 그 결과에 반응하거나 특정 작업을 수행할 수 있다.
콜백 인터페이스
콜백 인터페이스는 콜백 패턴의 구현을 위한 인터페이스다.
이는 일반적으로 함수형 인터페이스로서, 하나의 추상 메서드만을 갖는데, 이 추상 메서드를 구현하면 콜백으로 사용될 동작을 정의할 수 있다.
잘못된 코드 예시 - 쓸데없이 백그라운드 스레드를 사용하는 관찰자
해당 코드를 실행하면 예외는 나지 않지만 Deadlock에 빠진다.
- 메인 스레드는 addObserver를 호출하여 observer를 등록
- 등록된 observer는 added 메서드에서 새로운 백그라운드 스레드를 생성하고 해당 스레드에서 set.removeObserver(this) 호출
- set.removeObserver(this) 호출로 인해 observer를 제거하려 하지만 이미 메인 스레드는 addObserver 메서드의 동기화 블록 안에서 observers 리스트에 대한 락을 획득한 상태이므로 락을 얻을 수 없음
- 메인 스레드는 동시에 백그라운드 스레드가 observer를 제거하고 끝날 때까지 대기
- 서로가 가진 락을 해제하지 않고 무한정 대기
재진입 (reentrant)
자바의 락은 재진입(reentrant)을 허용한다.
락의 재진입 가능성: 이미 락을 획득한 스레드는 다른 synchronized 블록을 만났을 때 락을 다시 검사하지 않고 진입 가능하다.
- 재진입 가능 락은 객체 지향 멀티스레드 프로그램을 쉽게 구현할 수 있도록 해준다.
- 하지만 Deadlock이 될 상황을 데이터 훼손으로 변모 시킬 수도 있다.
솔루션 1 - 외계인 메서드를 동기화 블록 바깥으로 이동 (Open Call)
- 관찰자 리스트를 복사해서 쓰면 락 없이도 안전하게 순회할 수 있다.
- 외계인 메서드가 동기화 영역 밖에서 호출된다면 다른 스레드가 보호된 자원을 사용하지 못하고 대기해야 하는 일이 없다.
- 따라서 Open call은 실패 방지 효과외에도 동시성 효율을 크게 개선해 준다.
솔루션 2 - CopyOnWriteArrayList
- ArrayList를 구현한 클래스로, 내부를 변경하는 작업은 깨끗한 복사본을 만들어 수행하도록 구현되었다.
- 내부의 배열은 절대 수정되지 않으니 순회할 때 락이 필요 없어 매우 빠르다.
동기화의 성능
자바의 동기화 비용은 빠르게 낮아져 왔지만 과도한 동기화를 피하는 일은 중요하다.
과도한 동기화가 초래하는 비용
- 경쟁하느라 낭비하는 시간 - 병렬로 실행할 기회를 잃고, 모든 코어가 메모리를 일관되게 보기 위한 지연시간
- 가상머신의 코드 최적화를 제한되어 발생하는 지연시간
가변 클래스 작성 시 선택해야 할 방법
1.동기화를 전혀 하지 말고, 그 클래스를 동시에 사용해야 하는 클래스가 외부에서 알아서 동기화하게 한다.
public class Foo {
private int value;
public Foo(int value) {
this.value = value;
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
public class ExternalClass {
public static void main(String[] args) throws InterruptedException {
Foo foo = new Foo(0);
// 하나의 스레드에서 값 설정하는 작업
Runnable setTask = () -> {
for (int i = 0; i < 1000; i++) {
foo.setValue(i);
}
};
// 다른 스레드에서 값 읽는 작업
Runnable getTask = () -> {
for (int i = 0; i < 1000; i++) {
int value = foo.getValue();
if (value != i) {
System.out.println("Inconsistent value detected: " + value);
}
}
};
Thread thread1 = new Thread(setTask);
Thread thread2 = new Thread(getTask);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Done");
}
}
2.동기화를 내부에서 수행해 스레드 안전한 클래스로 만든다.
- 클라이언트가 외부에서 객체 전체에 락을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 두 번째 방법을 선택해야 한다.
- 선택하기 어렵다면 동기화하지 말고 문서에 "스레드 안전하지 않다"라고 명기하자.
StringBuffer와 java.util.Random 은 항상 단일 스레드에서 쓰였음에도 내부적으로 동기화를 수행했다.
- StringBuffer의 동기화하지 않은 버전 : StringBuilder
- java.util.Random의 동기화하지 않은 버전 : java.util.conncurrent.ThreadLocalRandom
클래스 내부에서 동기화하기로 했다면, 락 분할(lock splitting), 락 스트라이핑(lock striping), 비차단 동시성 제어(nonblocking concurrency control) 등 다양한 기법을 동원해 동시성을 높여줄 수 있다.
결론
- Deadlock과 데이터 훼손을 피하려면 동기화 영역 안에서 외계인 메서드를 절대 호출하지 말자.
- 동기화 영역 안에서의 작업은 최소한으로 줄이자.
- 가변 클래스를 설계할 때는 스스로 동기화해야 할지 고민하자.
- 합당한 이유가 있을 때만 내부에서 동기화하고, 동기화했는지 여부를 문서에 명확히 밝히자
'Java > Effective Java' 카테고리의 다른 글
[Effective Java] Item 81. wait와 notify보다는 동시성 유틸리티를 애용하라 (0) | 2023.08.20 |
---|---|
[Effective Java] Item 80. 스레드보다는 실행자, 태스크, 스트림을 애용하라 (0) | 2023.08.20 |
[Effective Java] Item 78. 공유 중인 가변 데이터는 동기화해 사용하라 (0) | 2023.08.13 |
[Effective Java] Item 77. 예외를 무시하지 말라 (0) | 2023.08.13 |
[Effective Java] Item 76. 가능한 한 실패 원자적으로 만들라 (0) | 2023.08.12 |