← 학습기록 (Java · 이펙티브 자바 3E)

11장 - 동시성

78. 공유 중인 가변 데이터는 동기화해 사용하라

syncronized 키워드는 해당 메서드나 블록을 한번에 한 스레드씩 수행하도록 보장한다.

많은 프로그래머가 동기화를 배타적 실행, 즉 한 스레드가 변경하는 중이라서 상태가 일관되지 않은 순간의객체를 다른 스레드가 보지 못하게 막는 용도로만 생각한다.

먼저 이 관점에서 얘기해보자. 한 객체가 일관된 상태를 가지고 생성되고, 이 객체에 접근하는 메서드는 그 객체에 락을 건다.

락을 건 메서드는 객체의 상태를 확인하고 필요하면 수정한다.

즉, 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시킨다.

동기화를 제대로 사용하면 어떤 메서드도 이 객체의 상태가 일관되지 않은 순간을 볼 수 없을 것이다.

맞는 설명이지만, 동기화에는 중요한 기능이 하나 더 있다.

동기화 없이는 한 스레드가 만든 변화를 다른 스레드에서 확인하지 못할 수 있다.

동기화는 일관성이 깨진 상태를 볼 수 없게 하는 것은 물론, 동기화된 메서드나 블록에 들어간 스레드가 같은 락의 보호하에 수행된 모든 이전 수정의 최종 결과를 보게 해준다.

언어 명세상 long과 double 외의 변수를 읽고 쓰는 동작은 원자적이다.

여러 스레드가 같은 변수를 동기화 없이 수정하는 중이라도, 항상 어떤 스레드가 정상적으로 저장한 값을 온전히 읽어옴을 보장한다는 뜻이다.

이 말을 듣고 “성능을 높이려면 원자적 데이터를 읽고 쓸 때는 동기화하지 말아야겠다”고 생각하기 쉬운데, 아주 위험한 발상이다.

동기화는 배타적 실행뿐 아니라 스레드 사이의 안정적인 통신에 꼭 필요하다.

이는 한 스레드가 만든 변화가 다른 스레드에게 언제 어떻게 보이는지를 규정한 자바의 메모리 모델 때문이다.

반복문에서 매번 동기화하는 비용이 크진 않지만 속도가 더 빠른 대안이 있다.

volatile 로 선언하면 동기화를 생략해도 된다.

volatile 한정자는 배타적 수행과는 상관없지만 항상 가장 최근에 기록된 값을 읽게 됨을 보장한다.

volatile은 주의해서 사용해야 한다. 예를 들어 다음은 일련번호를 생성할 의도로 작성한 메서드다

private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber() {
	return nextSerialNumber++;
}

이 메서드는 매번 고유한 값을 반환할 의도로 만들어졌다.

이 메서드의 상태는 nextSerialNumber라는 단 하나의 필드로 결정되는데, 원자적으로 접근할 수 있고 어떤 값이든 허용한다.

따라서 굳이 동기화하지 않더라도 불변식을 보호할 수 있어 보인다.

하지만 이 역시 동기화 없이는 올바로 동작하지 않는데.

문제는 증가 연산자다(++). 이 연산자는 코드상으로는 하나지만 실제로는 nextSerialNumber 필드에 두 번 접근한다.

먼저 값을 읽고, 그런 다음 새로운 값을 저장하는 것이다.

만약 두 번째 스레드가 이 두 접근 사이를 비집고 들어와 값을 읽어가면 첫 번째 스레드와 똑같은 값을 돌려받게 된다.

프로그램이 잘못된 결과를 계산해내는 이런 오류를 안전 실패라고 한다.

generateSerialNumber 메서드에 synchronized 한정자를 붙이면 이 문제가 해결된다.

동시에 호출해도 서로 간섭하지 않으며 이전 호출이 변경한 값을 읽게 된다는 뜻이다.

메서드에 synchronized를 붙였다면 nextSerialNumber 필드에는 volatime을 제거해야 한다.

이 메서드를 더 견고하게 하려면 int 대신 long을 사용하거나 nextSerialNumber가 최댓값에 도달하면 예외를 던지게 하자.

아직 끝이 아니다. 아이템 59의 조언을 따라 java.util.concurrent.atomic 패키지의 AtomicLong을 사용해보자.

이 패키지에는 락 없이도 스레드 안전한 프로그래밍을 지원하는 클래스들이 담겨있다.

volatile은 동기화의 두 효과 중 통신 쪽만 지원하지만 이 패키지는 원자성까지 지원한다.

우리가 generateSerialNumber에 원하는 바로 그 기능이다.

더구나 성능도 동기화 버전보다 우수하다.

private static final AtomicLong nextSerialNumber = new AtomicLong();

public static int generateSerialNumber() {
	return nextSerialNumber.getAndIncrement();
}

이번 이이템에서 언급한 문제들을 피하는 가장 좋은 방법은 물론 애초에 가변 데이터를 공유하지 않는 것이다.

불변 데이터만 공유하거나 아무것도 공유하지 말자.

다시 말해 가변 데이터는 단일 스레드에서만 쓰도록 하자.

79. 과도한 동기화는 피하라

아이템 78에는 충분하지 못한 동기화의 피해를 다뤘다면, 이번 아이템에서는 반대 상황을 다룬다.

과도한 동기화는 성능을 떨어뜨리고, 교착 상태에 빠뜨리고, 심지어 예측할 수 없는 동작을 낳기도 한다.

응답 불가와 안전 실패를 피하려면 동기화 메서드나 동기화 블록 안에서는 제어를 절대로 클라이언트에 양도하면 안 된다.

예를 들어 동기화된 영역 안에서는 재정의할 수 있는 메서드를 호출하면 안 되며, 클라이언트가 넘겨준 함수 객체를 호출해서도 안 된다.

동기화된 영역을 포함한 클래스 관점에서는 이런 메서드는 모두 바깥 세상에서 온 외계인이다.

그 메서드가 무슨 일을 할지 알지 못하며 통제할 수도 없다는 뜻이다.

외계인 메서드가 하는 일에 따라 동기화된 영역은 예외를 일으키거나, 교착상태에 빠지거나, 데이터를 훼손할 수도 있다.

80. 스레드보다는 실행자, 태스크, 스트림을 애용하라

이 책 초판의 아이템 49에서는 단순한 작업 큐를 선보였다.

그 클래스는 클라이언트가 요청한 작업을 백그라운드 스레드에 위임해 비동기적으로 처리해줬다.

작업 큐가 필요 없어지면 클라이언트는 큐에 중단을 요청할 수 있고, 그러면 큐는 남아 있는 작업을 마저 완료한 후 스스로 종료한다.

예시용의 간단한 코드였지만 책 한 페이지를 가득 메웠는데, 안전 실패나 응답 불가가 될 여지를 없애는 코드를 추가해야 했기 때문이다.

다행히도 더 이상은 이런 코드를 작성하지 않아도 된다.

이 책의 2판이 나오기 앞어 java.util.concurrent 패키지가 등장했다.

이 패키지는 실행자 프레임워크라고 하는 인터페이스 기반의 유연한 태스크 실행 기능을 담고 있다.

그래서 초판의 코드보다 모든 면에서 뛰어난 작업 큐를 다음의 한 줄로 생성할 수 있게 되었다.

ExecutorService exec = Executors.newSingleThreadExecutor();

다음은 이 실행자에 실행할 태스크를 넘기는 방법이다.

exec.execute(runnable);

그리고 다음은 실행자를 우아하게 종료시키는 방법이다.(이 작업이 실패하면 VM 자체가 종료되지 않을 것이다.)

exec.shutdown();

실행자 서비스의 기능은 이 외에도 많다.

다음은 실행자 서비스의 주요 기능이다.

  • 특정 태스크가 완료되기를 기다린다.
  • 태스크 모음 중 아무것 하나(invokeAny 메서드) 혹은 모든 태스크가 완료되기를 기다린다.
  • 실행자 서비스가 종료하기를 기다린다.
  • 완료된 태스크들의 결과를 차례로 받는다
  • 태스크를 특정 시간에 혹은 주기적으로 실행하게 된다.

큐를 둘 이상의 스레드가 처리하게 하고 싶다면 간단히 다른 정적 팩터리를 이용하여 다른 종류의 실행자 서비스를 생성하면 된다.

스레드 풀의 스레드 개수는 고정할 수도 있고 필요에 따라 늘어나거나 줄어들게 설정할 수도 있다.

여러분에게 필요한 실행자 대부분은 java.util.concurrent.Executors 의 정적 팩터리를 이용해 생성할 수 있을 것이다.

평범하지 않은 실행자를 원한다면 ThreadPoolExecutor 클래스를 직접 사용해도 된다.

이 클래스로는 스레드 풀 동작을 결정하는 거의 모든 속성을 설정할 수 있다.

실행자 서비스를 사용하기에 까다로운 애플리케이션도 있다,

작은 프로그램이나 가벼운 서버라면 Executors.newCachedThreadPool이 일반적으로 좋은 선택일 것이다.

특별히 설정할 게 없고 일반적인 용도에 적합하게 동작한다.

하지만 CachedThreadPool은 무거운 프로덕션 서버에는 좋지 못하다!

CachedThreadPool에서는 요청받은 태스크들이 큐에 쌓이지 않고 즉시 스레드에 위임 돼 실행된다.

가용한 스레드가 없다면 새로 하나를 생성한다.

서버가 아주 무겁다면 CPU 이용률이 100%로 치닫고, 새로운 태스크가 도착하는 족족 또 다른 스레드를 생성하여 상황을 더욱 악화시킨다.

따라서 무거운 프로덕션 서버에서는 스레드 개수를 고정한 Executors.newFixedThreadPool을 선택하거나 완전히 통제할 수 있는 ThreadPoolExecutor를 직접 사용하는 편이 훨씬 낫다.

작업 큐를 손수 만드는 일은 삼가야 하고, 스레드를 직접 다루는 것도 일반적으로 삼가야 한다.

스레드를 직접 다루면 Thread가 작업 단위와 수행 매커니즘 역할을 모두 수행하게 된다.

반면 실행자 프레임워크에서는 작업 단위와 실행 매커니즘이 분리된다.

작업 단위를 나타내는 핵심 추상 개념이 태스크다.

태스크에서는 두 가지가 있다 바로 Runnable과 Callable(Callable은 Runnable과 비슷하지만 값을 반환하고 임의의 예외를 던질 수 있다)이다.

그리고 태스크를 수행하는 일반적인 매커니즘이 바로 실행자 서비스다.

태스크 수행을 실행자 서비스에 맡기면 원하는 태스크 수행 정책을 선택할 수 있고, 생각이 바뀌면 언제든 변경할 수 있다.

핵심은 실행자 프레임워크가 작업 수행을 담당해준다는 것이다.

자바 7이 되면서 실행자 프레임워크는 포크-조인 태스크를 지원하도록 확장되었다.

포크-조인 태스크는 포크-조인 풀 이라는 특별한 실행자 서비스가 실행해준다.

포크-조인 태스크 즉 ForkJoinTask의 인스턴스는 작은 하위 태스크로 나뉠 수 있고, ForkJoinPool을 구성하는 스레드들이 이 태스크들을 처리하며, 일을 먼저 끝낸 스레드는 다른 스레드의 남은 태스크를 가져와 대신 처리할 수도 있다.

81. wait과 notify 보다는 동시성 유틸리티를 애용하라

자바 5에 도입된 고수준의 동시성 유틸리티가 나오기 이전에는 wait과 notify를 사용해야 할 이유가 자주 있었다.

wait과 notify는 올바르게 사용하기가 아주 까다로우니 고수준 동시성 유틸리티를 사용하자.

java.util.concurrent의 고수준 유틸리티는 세 범주로 나눌 수 있다.

바로 실행자 프레임워크, 동시성 컬렉션, 동기화 장치다.

실행자 프레임워크는 아이템 80에서 가볍게 살펴보았고, 동시성 컬렉션, 동기화 장치는 이번 아이템에서 살펴본다.

동시성 컬렉션은 List, Queue, Map 같은 표준 컬렉션 인터페이스에 동시성을 가미해 구현한 고성능 컬렉션이다.

높은 동시성에 도달하기 위해 동기화를 각자의 내부에서 수행한다.

따라서 동시성 컬렉션에서 동시성을 무력화하는 건 불가능하며, 외부에서 락을 추가로 사용하면 오히려 속도가 느려진다.

동시성 컬렉션에서 동시성을 무력화하지 못하므로 여러 메서드를 원자적으로 묶어 호출하는 일 역시 불가능하다.

그래서 여러 기본 동작을 하나의 원자적 동작으로 묶는 상태 의존적 수정 메서드들이 추가되었다.

이 메서드들은 아주 유용해서 자바8에서는 일반 컬렉션 인터페이스에도 디폴트 메서드 형태로 추가되었다.

예를 들어 Map의 putIfAbsent(key, value) 메서드는 주어진 키에 매핑된 값이 아직 없을 때만 새 값을 집어넣는다.

그리고 기존 값이 있었다면 그 값을 반환하고, 없었다면 null을 반환한다.

이 메서드 덕에 스레드 안전한 정규화 맵을 쉽게 구현할 수 있다.

Collections.synchronizedMap 보다는 ConcurrentHashMap을 사용하는게 훨씬 좋다.

동기화된 맵을 동시성 맵으로 교체하는 것만으로 동시성 애플리케이션의 성능은 극적으로 개선된다.

컬렉션 인터페이스 중 일부는 작업이 성공적으로 완료될 대까지 기다리도록 확장되었다.

예를 살펴보자. Queue를 확장한 BlockingQueue에 추가된 메서드 중 take는 큐의 첫 원소를 꺼낸다.

이때 만약 큐가 비었다면 새로운 원소가 추가될 때까지 기다린다.

이런 특성 덕에 BlockingQueue는 작업 큐(생산자-소비자 큐)로 쓰기에 적합하다.

짐작하다 시피 ThreadPoolExecutor를 포함한 대부분의 실행자 서비스 구현체에서 이 BlockingQueue를 사용한다.

동기화 장치는 스레드가 다른 스레드를 기다릴 수 있게 하여, 서로 작업을 조율할 수 있게 해준다.

가장 자주 쓰이는 동기화 장치는 CountDownLatch와 Semaphore다.

CyclicBarriar과 Exchanger는 그보다 덜 쓰인다.

그리고 가장 강력한 동기화 장치는 바로 Phaser다.

카운트다운 래치는 일회성 장벽으로, 하나 이상의 스레드가 또 다른 하나 이상의 스레드 작업이 끝날 떄까지 기다리게 한다.

CountDownLatch의 유일한 생성자는 int 값을 받으며, 이 값이 래치의 countDown 메서드를 몇 번 호출해야 대기 중인 스레드를 깨우는지를 결정한다.

시간 간격을 잴 때는 항상 System.currentTimeMillis가 아닌 System.nanoTime을 사용하자. System.nanoTime은 더 정확하고 정밀하며 시스템의 실시간 시계의 시간 보정에 영향받지 않는다.

wait 메서드를 사용할 때는 반드시 대기 반복문(wait loop) 관용구를 사용하라. 반복문 밖에서는 절대로 호출하지 말자.

82. 스레드 안전성 수준을 문서화하라

한 메서드를 여러 스레드가 동시에 호출할 때 그 메서드가 어떻게 동작하느냐는 해당 클래스와 이를 사용하는 클라이언트 사이의 중요한 계약과 같다.

API 문서에서 아무런 언급도 없으면 그 클래스 사용자는 나름의 가정을 해야만 한다.

만약 그 가정이 틀리면 클라이언트 프로그램은 동기화를 충분히 하지 못하거나 지나치게 한 상태일 것이며, 두 경우 모두 심각한 오류로 이어질 수 있다.

API 문서에 synchronized 한정자가 보이는 메서드는 스레드 안전하다는 이야기를 들었을지 모르겠다.

하지만 이 말은 몇 가지 면에서 틀렸다.

자바독이 기본 옵션에서 생성한 API 문서에는 synchronized 한정자가 포함되지 않는데.

물론 합당한 이유가 있다.

메서드 선언에 synchronized 한정자를 선언할지는 구현 이슈일 뿐 API에 속하지 않는다.

따라서 이것만으로는 그 메서드가 스레드 안전하다고 믿기 어렵다.

멀티스레드 환경에서도 API를 안전하게 사용하게 하려면 클래스가 지원하는 스레드 안전성 수준을 정확히 명시해야 한다.

다음 목록은 스레드 안전성이 높은 순으로 나열한 것이다.

완벽하진 않지만 일반적인 경우는 포괄한다.

  • 불변 : 이 클래스의 인스턴스는 마치 상수와 같아서 외부 동기화도 필요 없다. String, Long 등
  • 무조건적 스레드 안전 : 이 클래스의 인스턴스는 수정될 수 있으나, 내부에서 충실히 동기화하여 별도의 외부 동기화 없이 동시에 사용해도 안전하다. AtomicLong, ConcurrentHashMap이 여기에 속한다.
  • 조건부 스래드 안전 : 무조건적 스레드 안전과 같으나, 일부 메서드는 동시에 사용하려면 외부 동ㄱ화가 필요하다.
  • 스레드 안전하지 않음 : 이 클래스의 인스턴스는 수정될 수 있다. 동시에 사용하려면 각각의 메서드 호출을 클라이언트가 선택한 외부 동기화 매커니즘으로 감싸야 한다. ArrayList, HashMap 등이 여기에 속한다.
  • 스레드 적대적 : 이 클래스는 모든 메서드 호출을 외부 동기화로 감싸더라도 멀티스레드 환경에서 안전하지 않다. 이 수준의 클래스는 일반적으로 정적 데이터를 아무 동기화없이 수정한다. 이런 클래스를 고의로 만드는 사람은 없겠지만, 동시성을 고려하지 않고 작성하다 보면 우연히 만들어질 수 있다. 일반적으로 고쳐지거나 deprecated 된다.

조건부 스레드 안전한 클래스는 주의해서 문서화해야 한다. 어떤 순서로 호출할 때 외부 동기화가 필요한지, 그리고 그 순서로 호출하려면 어떤 락 혹은 락들을 얻어야 하는지 알려줘야 한다.

클래스가 외부에서 사용할 수 있는 락을 제공하면 클라이언트에서 일련의 메서드 호출을 원자적으로 수행할 수 있다.

하지만 이 유연성에는 대가가 까른다.

내부에서 처리하는 고성능 동시성 제어 매커니즘과 혼용할 수 없게 되는 것이다.

그래서 ConcurrentHashMap 같은 동시성 컬렉션과는 함께 사용하지 못한다.

또한, 클라이언트가 공개된 락을 오래 쥐고 놓지 않는 서비스 거부 공격을 수행할 수도 있다.

서비스 거부 공격을 막으려면 synchronized 메서드 대신 비공개 락 객체를 사용해야 한다.

private final Object lock = new Object();

public void foo() {
	synchronized(lock) {
		//....
	}
}

락 필드는 항상 final로 선언하라.

83. 지연 초기화는 신중히 사용하라

지연 초기화는 필드의 초기화 시점을 그 값이 처음 필요할 때까지 늧추는 기법이다.

그래서 값이 전혀 쓰이지 않으면 초기화도 결코 일어나지 않는다.

이 기법은 정적 필드와 인스턴스 필드 모두에 사용할 수 있다.

지연 초기화는 주로 최적화 용도로 쓰이지만, 클래스와 인스턴스 초기화 때 발생하는 위험한 순환 문제를 해결하는 효과도 있다.

다른 모든 최적화와 마찬가지로 지연 초기화에 대해 해줄 최선의 조언은 “필요할 때까지는 하지 말라” 다.

지연 초기화는 양날의 검이다.

클래스 혹은 인스턴스 생성 시의 초기화 비용은 줄지만 그 대신 지연 초기화하는 필드에 접근하는 비용은 커진다.

지연 초기화하려는 필드들 중 결국 초기화가 이뤄지는 비율에 따라, 실제 초기화에 드는 비용에 따라, 초기화된 각 필드를 얼마나 빈번히 호출하느냐에 따라 지연 초기화가 실제로는 성능을 느려지게 할 수도 있다.

그럼에도 지연 초기화가 필요할 때가 있다.

해당 클래스의 인스턴스 중 그 필드를 사용하는 인스턴스의 비율이 낮은 반면, 그 필드를 초기화 하는 비용이 크다면 지연 초기화가 제 역할을 해줄 것이다.

하지만 안타깝게도 정말 그런지를 알 수 있는 유일한 방법은 지연 초기화 적용 전후의 성능을 측정해보는 것이다.

멀티스레드 환경에서는 지연 초기화를 하기가 까다롭다.

지연 초기화하는 필드를 둘 이상의 스레드가 공유한다면 어떤 형태로든 반드시 동기화해야 한다.

그렇지 않으면 심각한 버그로 이어질 것이다.

참고로 이번 아이템에서 다루는 모든 초기화 기법은 스레드 안전하다.

대부분의 상황에서 일반적인 초기화가 지연 초기화보다 낫다.

다음은 인스턴스 필드를 선언할 때 수행하는 일반적인 초기화 모습이다.

final 한정자를 사용했음에 주목하자.

private final FieldType field = computeFieldValue();

지연 초기화가 초기화 순환성을 깨뜨릴 것 같으면 synchronized를 단 접근자를 사용하자.

이 방법이 가장 간단하고 명확한 대안이다.

private FieldType field;

private synchronized FieldType getField() {
	if(field == null)
		field = computeFieldValue();
	return field;
}

이상의 두 관용구는 정적 필드에도 똑같이 적용된다.

물론 필드와 접근자 메서드 선언에 static 한정자를 추가해야 한다.

성능 때문에 정적 필드를 지연 초기화해야 한다면 지연 초기화 홀더 클래스 관용구를 사용하자.

클래스는 클래스가 처음 쓰일 때 비로소 초기화된다는 특성을 이용한 관용구다.

어떻게 동작하는지 함께 보자

private static class FieldHolder {
	static final FieldType field = computeFieldValue();
}

private static FieldType getField() {
	return FieldHolder.field;
}

getField가 처음 호출되는 순간 FieldHolder.field가 처음 읽히면서, 비로소 FieldHolder 클래스 초초기화를 촉발한다.

이 관용구의 멋진 점은 getField 메서드가 필드에 접근하면서 동기화를 전혀 하지 않으니 성능이 느려질 거리가 전혀 없다는 것이다.

일반적인 VM은 오직 클래스를 초기화할 때만 필드 접근을 동기화할 것이다.

클래스 초기화가 끝난 후에는 VM이 동기화 코드를 제거하여, 그 다음부터는 아무런 검사나 동기화 없이 필드에 접근하게 된다.

성능 때문에 인스턴스 필드를 지연 초기화해야 한다면 이중검사 관용구를 사용하라.

이 관용구는 초기화된 필드에 접근할 때의 동기화 비용을 없애준다.

(이름에서 알 수 있듯) 필드의 값을 두 번 검사하는 방식으로, 한 번은 동기화없이 검사하고, 필드가 아직 초기화되지 않았다면)두 번째는 동기화하여 검사한다.

두 번째 검사에서도 필드가 초기화되지 않았을 때만 필드를 초기화한다.

필드가 초기화된 후로는 동기화하지 않으므로 해당 필드는 반드시 volatile로 선언해야 한다.

구체적인 예를 보자

private volatile FieldType field;

private FieldType getField() {
	FieldType result = field;
	if(result != null)
		return result;
	
	synchronized(thi) {
		if(field == null)
			field = computeFieldValue();
		return field;
	}
}

result라는 지역 변수라 필요한 이유는 이 필드가 이미 초기화된 상황에서는 그 필드를 딱 한 번만 읽도록 보장하는 역할을 한다.

반드시 필요하지는 않지만 성능을 높여주고, 저수준 동시성 프로그래밍에 표준적으로 적용되는 더 우아한 방법이다.

84. 프로그램의 동작을 스레드 스케줄러에 기대지 말라

여러 스레드가 실행 중이면 운영체제의 스레드 스케줄러가 어떤 스레드를 얼마나 오래 실행할지 정한다.

정상적인 운영체제라면 이 작업을 공정하게 수행하지만 구체적인 스케줄링 정책은 운영체제마다 다를 수 있다.

따라서 잘 작성된 프로그램이라면 이 정책에 좌지우지돼서는 안 된다.

정확성이나 성능이 스레드 스케줄러에 따라 달라지는 프로그램이라면 다른 플랫폼에 이식하기 어렵다.

견고하고 빠릿하고 이식성 좋은 프로그램을 작성하는 가장 좋은 방법은 실행 가능한 스레드의 평균적인 수를 프로세서 수보다 지나치게 많아지지 않도록 하는 것이다.

그래야 스레드 스케줄러가 고민할 거리가 줄어든다.

실행 준비가 된 스레드들은 맡은 작업을 완료할 때까지 계속 실행되도록 만들자.

이런 프로그램이라면 스레드 스케줄링 정책이 아주 상이한 시스템에서도 동작이 크게 달라지지 않는다.

여기서 실행 가능한 스레드의 수와 전체 스레드 수는 구분해야 한다.

전체 스레드 수는 훨씬 많을 수 있고, 대기 중인 스레드는 실행 가능하지 않다.

실행 가능한 스레드 수를 적게 유지하는 주요 기법은 각 스레드가 무언가 유용한 작업을 완료한 후에는 다음 일거리가 생길 때까지 대기하도록 하는 것이다.

스레드는 당장 처리해야 할 작업이 없다면 실행되서는 안 된다.

실행자 프레임워크를 예로 드면, 스레드 풀 크기를 적절히 설정하고 작업은 짧게 유지하면 된다.

단, 너무 짧으면 작업을 분배하는 부담이 오히려 성능을 떨어뜨릴 수도 있다.

스레드는 절대 바쁜 대기 상태가 되면 안 된다.

공유 객체의 상태가 바뀔 때까지 쉬지 않고 검사해서는 안 된다는 뜻이다.

바쁜 대기는 스레드 스케줄러의 변덕에 취약할 뿐 아니라, 프로세서에 큰 부담을 주어 다른 유용한 작업이 실행될 기회를 박탈한다.

Thread.yield와 스레드 우선순위에 의존해서도 안 된다.

이 기능들은 스레드 스케줄러에 제공하는 힌트일 뿐이다.

스레드 우선순위는 이미 잘 동작하는 프로그램의 서비스 품질을 높이기 위해 드물게 쓰일 수는 있지만, 간신히 동작하는 프로그램을 ‘고치는 용도’로 사용해서는 절대 안 된다.