👽 외계인 메서드(alien method)
DeadLock과 Safety Failure를 피하려면 동기화 메서드 혹은 블럭 안에서는 제어를 클라이언트에게 넘기지 마라
- 동기화된 영역 안에서는 재정의할 수 있는 메서드를 호출하지 마라
- 클라이언트가 넘겨준 함수 객체(Item 24)를 호출해서도 안 된다.
- 동기화된 영역을 포함한 클래스에서 외계인 메서드는 Exception, 혹은 Dead Lock에 빠트리거나, Safety Failure를 유발할 수 있다.
🚫 As-is
@FunctionalInterface
public interface SetObserver<E>{
// ObservableSet에 원소가 더해지면 호출된다.
void added(ObservableSet<E> set, E element);
}
BiConsumer<OpservableSet<E>, E> (Item 44)와 본질적으로 같지만, 이름이 직관적이고 다중 콜백을 지원하도록 확장성이 높은 Custom Functional Interface를 정의했다.
public class ObservableSet<E> extends ForwardingSet<E> {
public ObservableSet(Set<E> s) {
super(s);
}
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;
}
}
- ForwardingSet (Item 18)을 구현한 래퍼 클래스
- 집합에 원소가 추가되면 알림을 주는 관찰자 패턴
- addObserver와 removeObserver 메서드로 oberver를 등록하거나 해지한다.
public static void main(String[] args) {
ObservableSet<Integer> set = new ObservableSet<>(new HashSet<>());
set.addObserver((s, e) -> System.out.println(e));
for (int i = 4; i <= 100; i++) {
set.add(i);
}
}
- 4~100의 숫자를 출력하고 종료된다.
- 문제가 없어 보이지만, 외계인 메서드를 observer에 등록시켜 동기화된 블록에서 사용하고 있다.
1️⃣ 자기 자신을 제거하는 관찰자 : 동시성 예외
set.addObserver(new SetObserver<Integer>() {
@Override
public void added(ObservableSet<Integer> set, Integer element) {
System.out.println(element);
if (element == 23)
set.removeObserver(this);
}
});
람다를 사용하면 자신을 참조할 수단이 없어서 익명 클래스로 대체했다.
- 23에서 조용히 종료되지 않고 ConcurrentModificationException을 던진다.
- synchronized는 동시성 보장을 해주지만, 정작 자신이 콜백을 거쳐 되돌아와 수정하는 것을 막지 못한다.
- 즉, Java의 Lock은 재진입(reentrant)를 허용한다.
- 자신을 제거하는 외계인 메서드 added가 synchronized 블럭 내에서 실행되면서 순회 도중인 리스트의 원소를 제거하려 시도한다.
- 허용되지 않은 동작임에도 막지 못한다.
🤔 왜 재진입을 허용하는가?
전에 공부할 때 왜 이렇게 구현해놨을까 싶었는데, OS를 공부하다가 유레카를 외치며 다시 뛰어왔다.
Java의 synchronized 메서드 혹은 블럭에 의해 동기화 되는 객체들은 각자 고유한 monitor가 결합이 되어 Synchronization 작업을 수행한다.
그런데 인스턴스 별로 결합된 Monitor가 Lock count 정보를 유지하기 때문에 동일한 Thread에서 lock을 걸 수 있게 되어 버린다. (어차피 자기 자신이 관리하고 있으므로)
2️⃣ 자기 자신을 제거하는 실행자 서비스 : 교착 상태
set.addObserver(new SetObserver<>() {
@Override
public void added(ObservableSet<Integer> set, Integer element) {
ExecutorService exec = Executors.newSingleThreadExecutor();
try {
exec.submit(() -> set.removeObserver(this)).get();
} catch (ExecutionException | InterruptedException ex) { // 다중 catch
throw new AssertionError(ex);
} finally {
exec.shutdown();
}
}
});
- ExecutorService는 Item 80의 내용이기에 해당 메서드에 대한 설명만 하자면,
- newSingleThreadExecutor() : Thread 1개인 작업 큐(ExcutorService)를 생성한다.
- ExecutorService의 submit() : Thread로 처리할 작업 예약
- .get() : 특정 task가 완료되기까지 대기
- 다중 캐치(multi-catch)
- Java 7부터 지원하며, 똑같이 처리할 예외가 여러 개일 때 사용할 수 있다.
관찰자가 자신을 구독하는 데 굳이 Background Thread를 사용하는 데서 억지스러운 예지만, 문제 자체는 진짜다.
- Exception은 발생하지 않지만, Dead lock에 빠진다.
- 메인 Thread에서 add()를 호출하고, notifyElementAdded()를 호출하여 synchronized 블럭에 진입해 Lock을 획득한다.
- notifyElementAdded() 내에서 외계인 메서드 added를 호출하면 instance의 removeObserver() 메서드에 접근한다.
- 이미 메인 Thread에서 Lock을 쥐고 있으므로, 메인은 함수의 작업이 끝나길 기다리고 함수는 unlock을 기다리는 교착 상태에 빠진다.
3️⃣ Lock의 재진입(reentrant) : Safety Failure
- 1번 문제에서 본 재진입 가능 Lock은 Multi-thread Program을 쉽게 구현할 수 있도록 돕는다.
- 하지만 Dead Lock이 될 상황을 Safety Failure로 변모시킬 수도 있다.
📌 To-be
1️⃣ 외계인 메서드를 synchronized 블럭 바깥으로 옮겨라. (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); // 콜백 메서드
}
}
- synchronized 블럭 내에서는 값을 읽어오기만 하고, 외계인 메서드가 읽어온 값을 출력하면 안전하다.
- Lock 없이도 안전하게 순회가 가능하며, Exception과 Dead Lock 모두 해소된다.
- 다른 Thread가 자원을 사용하기 위해 대기하는 시간을 줄일 수 있다. (동시성 효율 개선)
2️⃣ Java의 동시성 Collection Library의 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); // 콜백 메서드
}
}
- 내부를 변경하는 작업은 항상 깨끗한 복사본을 만들어 수행하도록 구현됐다.
- 내부 배열은 불변하므로 Lock이 필요없어서 매우 빠르다.
- 단, 수정할 일은 드물고 순회만 빈번이 일어나는 관찰자 리스트 용도로 최적이다.
- 다른 용도로 쓰인다면 매번 복사해야 하므로 끔찍이 느릴 수도 있다.
📌 기본 규칙
💡동기화 영역에서는 가능한 한 일을 적게 하라
- Lock을 얻고, 공유 데이터를 검사하고, 필요하면 수정하고, Lock을 놓는다.
- 오래 걸리는 작업이라면, Item 78의 지침을 지키면서 Open call을 사용하는 방법을 찾아보라
📌 지연 시간(Latency)
요즘같이 멀티 코어가 일반화된 오늘날, 과도한 동기화가 초래하는 진짜 비용은 Lock을 얻는 데 드는 CPU 시간이 아니다.
진짜 문제는 경쟁하느라 낭비하는 시간이다.
- 병렬로 실행할 기회를 잃는다.
- 모든 코어가 memory을 일관되게 보기 위한 지연시간이 발생한다.
- JVM의 코드 최적화를 제하한다는 점도 과도한 동기화의 숨은 문제점이다.
가변 클래스를 작성하거든, 아래 두 가지 선택지 중 하나를 따라라
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");
}
}
- 동기화 되지 않은 가변 객체 Foo를 사용하기 위해서, Client가 동기화하도록 구현했다.
✒️ 여러 Thread에서 호출할 가능성이 있는 메서드가 정적 필드를 수정하는 경우
public class SharedCounter {
private static int counter = 0;
public static void incrementCounter() {
counter++;
}
public static int getCounter() {
return counter;
}
}
public class Main {
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
SharedCounter.incrementCounter();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
SharedCounter.incrementCounter();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final Counter Value: " + SharedCounter.getCounter());
}
}
위 코드보다 간단한 예시로 Item 78에 나왔던 nextSerialNumber도 마찬가지다.
private static volatile int nextSerialNumber = 0;
public static int generateSerialNumber() {
return nextSerialNumber++;
}
- 여러 Thread가 공유하는 정적 필드(클래스 수준의 필드)에 대한 수정 메서드가 있다면 반드시 동기화 하라
- private로 선언되어 있더라도 사실상 전역 변수(공유 자원)처럼 작동한다.
2️⃣ 동기화를 내부에서 수행해 Thread-safe한 클래스로 다시 만들라(Item 82)
public class Counter {
private int value = 0;
public synchronized void increment() {
value++;
}
public synchronized void decrement() {
value--;
}
public synchronized int getValue() {
return value;
}
}
public class Main {
public static void main(String[] args) {
Counter counter = new Counter();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter.increment();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
counter.decrement();
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Final Counter Value: " + counter.getValue());
}
}
- Client가 외부에서 객체 전체에 Lock을 거는 것보다 동시성을 월등히 개선할 수 있을 때만 사용하라
- Java의 내부적으로 동기화를 수행하던 StringBuffer, Random은 Thread-safe하지만 느리다.
- StringBuilder와 ThreadLocalRandom은 동기화 하지 않은 버전의 클래스다.
- 선택하기 어렵다면 동기화하지 말고, 문서에 "Thread-safe하지 않다"고 명시하라
- 동기화하기로 결정했다면 다양한 기법을 동원해 동시성을 높여줄 수 있다.
- 락 분할(Lock Splitting)
- 하나의 큰 Lock을 여러 개의 작은 Lock으로 분할하여 각각 동시에 접근할 수 있도록 한다.
- ex. 리스트 앞 뒤에 원소를 추가하는 작업의 Lock을 분할하여 동시에 여러 Thread가 접근할 수 있게 한다.
- 락 스트라이핑(Lock Striping)
- 여러 개의 Lock을 생성하여 서로 다른 Lock을 동시에 사용하는 기술
- 각 Entry가 서로 독립적으로 lock을 가지는 경우 유용하다.
- ex. HashTable 버킷마다 별도의 Lock을 두어 동시에 다양한 버킷에 접근하도록 한다.
- 비차단 동시성 제어(Non-blocking Concurrency Control)
- Thread가 Lock을 획득하지 않고도 동시에 공유 자원에 접근할 수 있는 기술
- 하나의 Thread가 Lock을 획득하지 않아도 다른 Thread는 Blocking되지 않고 작업을 수행할 수 있다.
- 원자적(Atomic) 연산이나 CAS(Compare-and-Swap)와 같은 연산을 사용하여 구현된다.
- DeadLock을 피할 수 있으며, Thread 간의 경합 상태가 발생하지 않는다.
- ex. Queue, Stack에 원소를 추가/제거하는 작업을 비차단으로 구현하여 다수의 Thread가 동시에 접근할 수 있도록 한다.
- 락 분할(Lock Splitting)