📌 API 문서
- API 문서는 해당 클래스와 Client 사이의 중요한 계약이다.
- Multi-thread 환경에서 메서드가 어떻게 동작하는지 명시하지 않으면 Client는 나름의 가정을 해야 한다.
- 가정이 틀리다면 Client 프로그램은 Synchronize를 충분히 하지 못한다. (Item 78)
- 혹은 동기화를 지나치게 한 상태가 된다. (Item 79)
- API 문서에 synchronized 한정자가 모이는 메서드는 Thread-safe와 연관이 없다.
- 메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐 API에 속하지 않는다.
- Thread-safe는 있고, 없고 기준이 아니라, 그 안에서도 안전성의 수준이 나뉜다.
📌 Levels of thread safety
다음은 Thread 안전성이 높은 순으로 나열한 것이다.
- 불변(immutable)
- 해당 클래스 인스턴스는 상수와 같으므로 외부 동기화가 필요없다.
- ex. String, Long, BigInteger
- 무조건적 스레드 안전(unconditionally thread-safe)
- 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하는 경우
- 별도의 외부 동기화 없이 동시에 사용해도 안전하다.
- ex. AtomicLong,ConcurrentHashMap
- 조건부 스레드 안전(conditionally thread-safe)
- 무조건적 Thread-safe에서, 일부 메서드는 동시에 사용하려면 외부 동기화가 필요하다.
- ex. Collections.synchronized wrapper method가 반환한 Collection들
- 이 Collection들이 반환한 반복자는 외부에서 동기화해야 한다.
- 스레드 안전하지 않음(not thread-safe)
- 해당 클래스 인스턴스는 수정될 수 있다.
- 동시에 사용하려면 각각(혹은 일련)의 메서드 호출을 Client가 선택한 외부 동기화 메커니즘을 감싸야 한다.
- ex. ArrayList, HashMap
- 스레드 적대적(thread-hostile)
- 모든 메서드 호출을 외부 동기화로 감싸더라도 Multi-Thread 환경에서 안전하지 않다.
- 이 수준의 클래스는 일반적으로 정적 데이터를 아무 동기화 없이 수정한다.
- 동시성을 고려하지 않고 작성하다 보면 우연히 만들어질 수 있다.
- Thread-hostile Class나 Method는 문제를 고쳐 재배포하거나 사용 자제(deprecated) API로 지정한다.
- ex. generateSerialNumber(Item 78) 메서드의 내부 동기화를 생략하면 스레드 적대적이 된다.
🟡 Thread-safe Annotation
- @Immutable : 불변
- @ThreadSafe : 무조건적/조건부 스레드 안전
- @NotThreadSafe : 스레드 안전하지 않음
📌 Thread-safe 클래스 문서화 주의사항
/**
* synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 반드시 그 맵의 락으로 사용해
* 수동으로 동기화하라.
*
* <pre>
* Map<K, V> m = Collections.synchronizedMap(new HashMap<>());
* Set<k> s = m.keySet(); // 동기화 블록 밖에 있어도 된다.
* ...
* synchronized (m) { // s가 아닌 m을 사용해 동기화해야 한다.
* for (K key : s) // 동기화 블록 안에 있어야 한다.
* Key.f();
* }
* </pre>
* 이대로 하지 않으면 동작을 예측할 수 없다.
*/
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
- 어떤 순서로 호출할 때 외부 동기화가 필요한지, 그 순서를 호출하려면 어떤 Lock 혹은 Locks를 얻어야 하는지 알려야 한다.
- 일반적으로 인스턴스 자체를 Lock으로 얻지만 예외도 있다. (ex. Collections.synchronizedMap)
- Thread-safety는 보통 클래스에 달고, 독특한 특정의 메서드는 메서드 주석에 기재하라.
- Enum 타입은 굳이 immutable이라 사용하지 않아도 된다.
- 반환 타입만으로 명확히 알 수 없는 정적 팩터리는 자신이 반환하는 객체의 Thread-safe를 반드시 문서화하라
- Collections.synchronizedMap이 좋은 예다.
✒️ 외부 동기화 vs 내부 동기화
public class Foo {
private int count;
public void addToCount() {
count++;
log.info("count increased to " + count);
}
}
// 외부 동기화
synchronized (foo) {
foo.addToCount();
}
// 내부 동기화
public class Foo {
private int count;
public void addToCount() {
int val;
synchronized (this) {
val = ++count;
}
// this log call should not be synchronized since it does IO
log.info("count increased to " + val);
}
}
📌 비공개 Lock 객체
🟡 As-is. 외부에서 사용할 수 있는 Lock
public class MyClass {
private final Object lock = new Object();
public synchronized void foo() {
// 동기화 블럭 내용
// ...
}
}
- Client가 일련의 메서드 호출을 Atomic으로 수행할 수 있다. (유연성 증가)
- 내부에서 처리하는 고성능 동시성 제어 메커니즘과 혼용할 수 없게 된다.
- ConcurrentHashMap 같은 동시성 Collection과도 함께 사용할 수 없게 된다.
- 서비스 거부 공격(denial-of-service attack)에 취약하다.
- Client가 공개된 Lock을 오래 쥐고 놓지 않는 공격
🟡 To-be. 비공개 Lock 객체
public class MyClass {
private final Object lock = new Object();
public void foo() {
synchronized (lock) {
// 동기화 블럭 내용
// ...
}
}
}
- 클래스 바깥에서는 볼 수 없으니 Client가 객체의 동기화에 관여할 수 없다.
- Item 15의 조언에 따라 Lock 객체를 캡슐화했다.
- Lock 객체는 반드시 final로 선언하라.
- 우연히라도 Lock 객체가 교체되는 일을 예방해준다.
- Lock 객체가 교체되면 끔찍한 결과(Item 78)로 이어지므로, 변경 가능성을 최소화하라.
- 비공개 Lock 객체 관용구는 Unconditionally thread-safe에서만 사용할 수 있다.
- Conditionally thread-safe에서는 특정 호출 순서에 필요한 Lock을 Client에게 알려주어야 하므로 사용할 수 없다.
- 상속용으로 설계한 클래스(Item 19)에 잘 맞는다.
- 상속용 클래스에서 자신의 인스턴스로 Lock을 사용하면, 하위 클래스는 아주 쉽게, 그리고 의도치 않게 기반 클래스 동작을 방해할 수 있다. (반대도 마찬가지)
- 같은 Lock을 다른 목적으로 사용하게 되어 하위 클래스와 기반 클래스가 서로가 서로를 훼방놓는 상태에 빠진다.
❌ 서로가 서로를 훼방놓는 상태
public class BaseClass {
public void performOperation() {
synchronized (this) {
// 어떤 작업을 수행한다.
}
}
}
public class SubClass extends BaseClass {
@Override public void performOperation() {
// 다른 작업을 수행하기 위해 synchronized 블록을 사용하지 않음
// ...
}
}