스레드 안전성 수준을 문서화하라.
한 메서드가 여러 메서드를 호출할 때 그 메서드가 어떻게 동작하느냐는 클라이언트와의 중요한 계약이다. API문서에 아무런 언급도 없으면 그 클래스 사용자는 나름의 가정을 해야만 한다.
멀티스레드 환경에서도 API를 안전하게 사용하게 하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야 한다.
- 불변(immutable) : 이 클래스의 인스턴스는 마치 상수같아서 외부 동기화가 필요없다. ( String, Long, BigInteger )
- 무조건적 스레드 안전 : 인스턴스가 수정 될 수 있으나 내부에서 동기화하여 외부 동기화 없이 사용해도 안전하다. ( AtomicLong, ConcurrentHashMap )
- 조건부 스레드 안전 : 무조건적 스레드 안전과 같으나 일부 메서드는 외부 동기화가 필요하다. ( Collections.synchronized 래퍼 메서드가 반환하는 컬렉션 )
- 스레드 안전하지 않음 : 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다. 문제를 고쳐 재배포하거나 사용을 자제해야 한다.
조건부 안전한 문서화
스레드 안전성은 보통 클래스의 문서화 주석에 기재하지만, 독특한 특성의 메서드라면 해당 메서드의 주석에 기재하도록 하자.
/**synchronizedMap이 반환한 맵의 컬렉션 뷰를 순회하려면 반드시 그 맵을 락으로
* 수동으로 동기화 하라.
*/
Map<K, V> m = Collections.synchronizedMap(new HashMap<>());
Set<K> s = m.keySet(); // 동기화 블록 밖에 있어도 된다.
...
synchronized(m) { // s가 아닌 m을 사용해 동기화해야 한다.
for(K key : s)
key.f();
}
비공개 lock이 중요한 이유
- private final로 락을 선언하면 비공개인 동시에 락 객체가 교체되는 일을 예방해준다.
- 호출순서에 따른 락이 무엇인지 클라이언트에게 알려줘야 하므로 스레드 안전 클래스에서는 사용 할 수 없다.
- 상속용 클래스에서는 하위 lock과 상위 lock이 서로 방해할 수 있기 때문에 더 유용하다.
private final Object lock = new Object();
public void foo() {
synchronized (lock) {
...
}
}
지연 초기화는 신중히 사용하라.
지연초기화란 필드가 필요할 때까지 초기화를 늦추는 기법이다.
지연 초기화의 주의할점
- 클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만 지연 초기화 필드에 접근하는 비용은 커진다.
- 초기화 시점, 비용, 호출 횟수에 따라 성능을 더 느려지게 할 수 있다.
지연 초기화가 필요한 시점
필드를 사용하는 인스턴스의 비율이 낮은 반면, 초기화 비용이 클 때 사용하면 좋다. 하지만 정말 이러한 상황인지 적용 전후의 성능을 측정해 보아야 한다.
대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.
일반적인 초기화 방법
private final FieldType field = computeFieldValue();
지연 초기화 방법
지연 초기화가 조기화 순환성(initialization circularity)을 깨디릴 것 같으면 synchronized를 단 접근자를 사용하자.
private FieldType field;
private synchronized FieldType getField() {
if(field == null)
field = computeFieldValue();
return field;
}
정적필드를 지연 초기화
정적 필드 지연 초기화는 지연 초기화 홀더 클래스(lazy initalization holder class) 관용구를 사용하자. 처음 getField를 호출할 때만 field를 초기화하고 그 다음부터는 바로 접근이 되어 성능에 문제가 없다.
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
private static FieldType getField() { return FieldHolder.field; }
인스턴스 필드를 지연 초기화해야 한다면 이중검사 관용구를 사용하라.
한번은 동기화 없이 검사하고 두 번째는 동기화하여 검사한다. 두 번째 검사에도 초기화가 되지 않았다면 초기화한다. 하지만 초기화된 이후로는 동기화하면 안되므로 volatile를 사용해야 한다.
private volatile FieldType field;
private FieldType getField {
FieldType result = field;
if(reuslt != field) {
return field;
}
synchronized (this) {
if(field == null)
field = computeFieldValue();
return field
}
}
result를 쓰게 되면 초기화된 상황에서는 그 필드를 한 번만 읽도록 해주는 역할을 한다. 성능을 높여주고 저수준 동시성 프로그래밍에 표준적으로 적용되는 더 우아한 방법이다.
단일검사 관용구
필드를 여러번 초기화 및 계산해도 상관없는 경우에 적용 할 수 있다. 속도는 빠르지만 스레드당 최대 한 번 더 이뤄질 수 있다.
private FieldType getField {
FieldType result = field;
if(reuslt == null) {
field = result = computeFieldValue();
}
return result;
}
프로그램의 동작을 스레드 스케줄러에 기대지 말라.
운영체제마다 구체적인 스케쥴링 정책이 다르기 때문에 이 정책에 좌지우지 않는 프로그램을 작성해야 한다. 정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다.
- 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하자
- 실행준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 하자
- 전체 스레드 수와 실행가능한 스레드의 수를 구분해야 한다.
- 작업이 완료된 후에는 다음 일거리가 생길때까지 대기하도록 하자. 스레드가 당장 처리해야 할 작업이 없다면 실행되어서는 안된다.
- 작업의 시간을 너무 짧게 분배하면 성능을 떨어뜨릴 수 있다.
스레드는 바쁜 대기(busy waiting) 상태가 되면 안된다.
바쁜 대기(busy waiting)이란 스레드가 권한을 얻을때까지 확인하는 것을 의미한다. 실행가능한 스레드를 조사(await)하기 위해서 너무 많은 시간이 걸린다. CountDownLatch와 비교했을 때 약 10배이상 더 소요된다.
public class SlowCountDownLatch {
private int count;
public SlowCountDownLatch(int count) {
if (count < 0)
throw new IllegalArgumentException(count + " < 0");
this.count = count;
}
public void await() {
while(true) {
synchronized (this) {
if(count == 0)
return;
}
}
}
public synchronized void countDown() {
if(count != 0)
count--;
}
}
Thread.yield를 써서 문제를 고쳐보려는 유혹을 떨쳐내자.
Thread.yield는 프로세서가 다른 스레드에게 일을 실행하도록 명령한다. JVM에 상태에 따라 성능이 더 저하될 가능성이 있으며 이식성도 낮다. Thread.yield는 테스트할 수단도 없다.
우선순위를 바꾸는 방법도 이식성이 낮고 근본적인 문제 해결을 해주진 못한다.
댓글