간단 회고
👀 다시 읽고 싶다: ★★★★★
🔥 추천한다: ★★★★★
🔖 리뷰:
이 책을 읽지 않고 자바 개발자로서 연차가 계속 쌓이다가 시니어쯤 되었을 때 이 책을 만나면 정말 억울했을 것 같다. 사실 벌써 좀 억울하다. 최근에 자바에서 동시성을 탐구해보고 작성했는데, 그 글이 부끄러워졌다. 이 책을 읽고나서 썼더라면 아마 완전히 다른 글이 되었을 것 같다.
이 책은 어렵다. 하지만 그만큼 깊이 있는 내용을 다룬다. 조금 오래된 책이긴 하지만 CS에 있어서 다른 책에서 읽은 부분과 조합되는 부분이 많았고, 퍼즐이 맞춰지면서 지적인 성취감이 느껴지는 경험을 조금 했다. 그런데 그럼에도 확실히 어려워서 몇 년 후 다시 읽으면 새롭게 이해되는 부분이 또 많을 것 같은 책이다.
자바 개발자로서 1회독은 필수인 것 같다.
1부 기본 원리
스레드 안정성
스레드 안정성이란
여러 스레드가 클래스에 접근할 때 실행 환경이 해당 스레드의 실행을 어떻게 스케줄하든 어디에 끼워 넣든, 호출하는 쪽에서 추가적인 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드 안전하다고 말한다.
- 48p
상태 없는 객체에 접근하는 스레드가 어떤 일을 하든 다른 스레드가 수행하는 동작의 정확성에 영향을 끼칠 수 없기 때문에 상태 없는 객체는 항상 스레드 안전하다.
- 49p
단일 연산
값을 증가시키는 ++count는 한 줄짜리 간단한 코드인지라 단일 작업처럼 보이지만 실제로 단일 연산이 아니다. 다시 말해 나눌 수 없는 최소 단위의 작업으로 실행되는 게 아니라는 뜻이다. 해당 문장은 현재 값을 가져와서, 거기에 1을 더하고, 새 값을 저장하는 별도의 3개 작업을 순차적으로 실행하는 것을 한 줄의 코드로 간략히 표현한 것이다. 이는 읽고 변경하고 쓰는 전형적인 연산의 모습을 갖추고 있으며, 이전 상태로부터 결과 상태를 도출한다.
- 50p
@NotThreadSafe
public class LazyInitRace {
private ExpensiveObject instance = null;
public ExpensiveObject getInstance() {
if (instance == null ) {
instance = new ExpensiveObject();
}
return instance;
}
}
LazyInitRace는 경쟁 조건 때문에 제대로 동작하지 않을 가능성이 있다. 스레드 A와 B가 동시에 getInstance를 수행한다고 하자.
instance라는 변수가 null이라는 사실을 본 다음 스레드 A는 expensiveObject의 인스턴스를 새로 생성한다. 스레드 B도 instance 변수가 null 인지 살펴본다. 이때 instancerk null의 여부는 스케줄이 어떻게 변경될지 또는 스레드 A가 ExpensiveObject 인스턴스를 생성하고 instance 변수에 저장하기까지 얼마나 걸리는지 등의 예측하기 어려운 타이밍에 따라 달라진다. 원래 getInstancce는 항상 같은 인스턴스를 리턴하도록 설계돼 있는데, 스레드 B가 살펴보는 그 시점에 instance가 null이면 getInstance를 호출한 두 스레드가 각각 서로 다른 인스턴스를 가져갈 수도 있다.
- 53p
스레드 안정성을 보장하기 위해 점검 후 행동과 일고 수정하고 쓰기 등의 작업은 항상 단일 연산이어야 한다.
- 54p
@NotThreadSafe
public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
private final AtomicReference<BigInteger> lastNumber
= new AtomicReference<BigInteger>();
private final AtomicReference<BigInteger[]> lastFactors
= new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get()))
encodeIntoResponse(resp, lastFactors.get());
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}
스레드 안정성의 정의에 따르면 여러 스레드에서 수행되는 작업의 타이밍이나 스케줄링에 따른 교차 실행과 관계 없이 불변 조건이 유지돼야 스레드에 안전하다. UnsafeCachingFactorizer에는 인수분해 결과를 곱한 값이 lastNumber에 캐시된 값과 같아야 한다는 불변조건이 있으며, 이와 같은 불변조건이 항상성립해야 서블릿이 제대로 동작한다고 볼 수 있다. 여러 개의 변수가 하나의 불변조건을 구성하고 있다면, 이 변수들은 서로 독립적이지 않다. 즉 한 변수의 값이 다른 변수에 들어갈 수 있는 값을 제한할 수 있다. 따라서 변수 하나를 갱신할 땐, 다른 변수도 동일한 단일 연산 작업 내에서 함께 변경해야 한다.
타이밍이 좋지 않았다면 unsafeCachingFactorizer 클래스의 불변조건이 깨질 수 있다. 개별적인 각 set 메서드는 단일 연산으로 동작하지만 단일 연산 참조 클래스를 쓰더라도 lastNumber과 lastFacotrs라는 두 개의 값을 동시에 갱신하지는 못한다. 하나는 수정됐고 다른 하나는 수정되지 않은 그 시점에 여전히 취약점이 존재한다. 이 순간 다른 스레드가 값을 읽어가면 불변조건이 깨진 상태를 보게 된다. 반대로 두 값을 동시에 읽어올 수도 없다. 즉, 스레드 A가 두 값을 읽는 사이에 스레드 B가 변수들을 변경할 수 있어 A 입장에선 불변조건이 만족하지 않는 경우를 발견할 수도 있다.
- 57P
자바에는 단일 연산 특성을 보장하기 위해 synchronized라는 구문으로 사용할 수 있는 락을 제공한다.
- 57p
자바에서 암묵적인 락은 뮤텍스로 동작한다. 즉 한번에 한 스레드만 특정 락을 소유할 수 있다. 스레드 B가 가지고 있는 락을 스레드 A가 얻으려면, A는 B가 해당 락을 놓을 때까지 기다려야 한다. 만약 B가 락을 놓지 않으면, A는 영원히 기다릴 수밖에 없다.
- 58p
스레드가 다른 스레드가 가진 락을 요청하면 해당 스레드는 대기 상태에 들어간다. 하지만 암묵적인 락은 재진입 기능(reentrant)하기 때문에 특정 스레드가 자기가 이미 획득한 락을 다시 확보할 수 있다. 재진입성은 확보 요청 단위가 아닌 스레드 단위로 락을 얻는다는 것을 의미한다. 재진입성을 구현하려면 각 락마다 확보 횟수와 확보한 스레드를 연결시켜 둔다. 확보 횟수가 0이면 락은 해제된 상태이다. 스레드가 해제된 락을 확보하면 JVM이 락에 대한 소유 스레드를 기록하고 확보 횟수를 1로 지정한다. 같은 스레드가 락을 다시 얻으면 횟수를 증가시키고, 소유한 스레드가 synchronized 블록 밖으로 나가면 횟수를 감소시긴다. 이렇게 횟수가 0이 되면 해당 락은 해제된다.
재진입성 때문에 락의 동작을 쉽게 캡슐화할 수 있고, 객체 지향 병렬 프로그램을 개발하기가 단순해졌다. 재진입 가능한 락이 없으면 하위 클래스에서 synchronized 메소드를 재정의하고 상위 클래스의 메소드를 호출하는 예제 2.7과 겉은 지극히 자연스러워 보이는 코드도 데드락에 빠질 것이다.
Widget과 LoggingWidget의 doSomething 메소드는 둘 다 synchronized로 선언돼 있고, 각각 진행 전에 Widget에 대한 락을 얻으려고 시도한다. 하지만 암묵적인 락이 재진입 가능하지 않았다면, 이미 락을 누군가가 확보했기 때문에 super.doSomething 호출에서 락을 얻을 수 없게 되고, 결과적으로 확보할 수 없는 락을 기다리면서 영원히 멈춰 있었을 것이다. 재진입성은 이런 경우에 데드락에 빠지지 않게 해준다.
public class Widget {
public synchronized void doSomething(){}
public class LoggingWidget extends Widget{
public synchronized void doSomething(){
super.doSomething();
}
}
- 59p
락으로 상태 보호하기
여러 스레드에서 접근할 수 있고 변경 가능한 모든 변수를 대상으로 해당 변수에 접근할 때는 항상 동일한 락을 먼저 확보한 상태여야 한다. 이 경우 해당 변수는 확보된 락에 의해 보호된다고 말한다.
- 60p
활동성과 성능
그림 2.1은 동기화된 인수 분해 서블릿에 여러 요청이 들어왔을 때 어떤 일이 생기는지를 나타낸 것이다. 보다시피 요청들이 큐에 쌓이고 순서대로 하나씩 처리된다. 이런 방법으로 동작하는 웹 애플리케이션은 병렬 처리 능력이 떨어진다고 말한다. 즉 처리할 수 있는 동시 요청의 개수가 자원의 많고 적음이 아닌 애플리케이션 자체의 구조 때문에 제약된다.
- 63p
객체 공유
가시성
public class NoVisibility {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
while (!ready)
Thread.yield();
System.out.println(number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
number = 42;
ready = true;
}
}
NoVisibility 클래스의 소스코드를 보면, ready 변수의 값을 읽기 스레드에서 영영 읽지 못할 수도 있기 때문에 무한 반복에 빠질 수 있다. 더 이상하게는 읽기 스레드가 메인 스레드에서 number 변수에 지정한 값보다 ready 변수의 값을 먼저 읽어가는 상황도 가능하다.
흔히 '재배치reodering'라고 하는 현상이다. 재배치 현상은 특정 메소드의 소스코드가 100% 코딩된 순서로 동작한다는 점을 보장할 수 없다는 점에 기인하는 문제이며, 단일 스레드로 동작할 때는 전혀 알아챌 수 없지만 여러 스레드가 동시에 동작하는 경우에는 확연하게 나타날 수 있다. 메인스레드는 number 변수에 값을 먼저 저장하고 ready 변수에도 값을 지정하지만, 동기화되지 않은 상태이기 때문에 읽기 스레드 입장에서는 마치 ready 변수에 값이 먼저 쓰여진 이후에 number 변수에 값이 저장되는 것처럼 순서가 바뀌어 보일 수도 있고, 심지어는 아예 변경된 값을 읽지 못할 수도 있다.
- 69p
변수를 사용하는 모든 경우에 동기화를 시켜두지 않으면 해당 변수에 대한 최신 값이 아닌 다른 값을 사용하게 되는 경우가 발생할 수 있다. 게다가 더 큰 문제는 항상 스테일 데이터를 사요하게 될 때고 있고, 정상적으로 동작하는 경우도 있다는 점이다. 다시 말하자면 특정 스레드가 어떤 변수를 사용할 때 정상적인 최신 값을 사용할 '수'도 있고, 올바르지 않은 값을 사용할 '수'도 있다는 말이다.
- 70p
락은 상호 배제 뿐만 아니라 정상적인 메모리 가시성을 확보하기 위해서도 사용한다.
- 73p
자바 언어에서는 volatile 변수로 약간 다른 형태의 좀더 약한 동기화 기능을 제공하는데, 다시 말해 volatile로 선언된 변수의 값을 바꿨을 때 다른 스레드에서 항상 최신 값을 읽어갈 수 있도록 해준다. 특정 변수를 선언할 때 volatile 키워드를 지정하면, 컴파일러와 런타임 모두 '이 변수는 공유해 사용하고, 따라서 실행 순서를 재배치해서는 안 된다'고 이해한다. volatile로 지정된 변수는 프로세서의 레지스터에 캐시되지도 않고, 프로세서 외부의 캐시에도 들어가지 않기 때문에 volatile 변수의 값을 읽으면 항상 다른 스레드가 보관해둔 최신의 값을 읽어갈 수 있다.
- 73p
락을 사용하면 가시성과 연산의 단일성을 모두 보장받을 수 있다. 하지만 volatile 변수는 연산의 단일성은 보장하지 못하고 가시성만 보장한다.
- 75p
ThreadLocal
스레드 내부의 값과 값을 갖고 있는 객체를 연결해 스레드 한정 기법을 적용할 수 있도록 도와주는 좀 더 형식적인 방법으로 ThreadLocal이 있다. ThreadLocal 클래스에는 get과 set 메서드가 있는데 호출하는 스레드마다 다른 값을 사용할 수 있도록 관리해준다. 다시 말해 ThreadLocal 클래스의 get 메서드를 호출하면 현재 실행 중인 스레드에서 최근에 set 메서드를 호출해 저장했던 값을 가져올 수 있다.
- 83p
ConnectionHolder와 같이 JDBC 연결을 보관할 때 ThreadLocal을 사용하면 스레드는 저마다 각자의 연결 객체를 갖게 된다.
private static final ThreadLocal connectionHolder = new ThreadLocal() {
@Override
protected Connection initialValue() {
return DriverManager.getConnection(DB_URL);
}
};
public static Connection getConnection() {
return connectionHolder.get();
}
}
- 83p
특정 스레드가 ThreadLocal.get 메소드를 처음 호출한다면 initialValue 메소드에서 값을 만들어 해당 스레드에게 초기 값으로 넘겨준다. 개념적으로 본다면 ThreadLocal<T>
클래스는 Map<Thread, T>
라는 자료 구조로 구성되어 있고, Map<Thread, T>
에 스레드별 값을 보관한다고 생각할 수 있겠다. 물론 ThreadLocal이 Map<Thread, T>
를 사용해 구현되어 있다는 말은 아니다. 스레드별 값은 실제로 Thread 객체 자체에 저장되어 있으며, 스레드가 종료되면 스레드별 값으로 할당되어 있던 부분도 가비지 컬렉터가 처리한다.
- 84p
불변성
불변 객체는 맨 처음 생성되는 시점을 제외하고는 그 값이 전혀 바뀌지 않는 객체를 말한다. 다시 말해 불변 객체의 변하지 않는 값은 처음 만들어질 때 생성 메서드에서 설정되고, 상태를 바꿀 수 없기 때문에 맨 처음 설정도니 값이 나중에도 바뀌지 않는다. 따라서 불변 객체는 그 태생부터 스레드에 안전한 상태이다.
- 85p
안전 공개
객체를 안전하게 공개하려면 해당 객체에 대한 참조와 객체 내부의 상태를 외부의 스레드에게 동시에 볼 수 있어야 한다. 올바르게 생성 메소드가 실행되고 난 객체는 다음과 같은 방법으로 안전하게 공개할 수 있다.
- 객체에 대한 참조를 static 메소드에서 초기화시킨다.
- 객체에 대한 참조를 volatile 변수 또는 AtomicReference 클래스에 보관한다.
- 객체에 대한 참조를 올바르게 생성된 클래스 내부의 final 변수에 보관한다.
- 락을 사용해 올바르게 막혀 있는 변수에 객체에 대한 참조를 보관한다.
- 93p
기술적으로만 본다면 특정 객체가 불변일 수 없다고 해도, 한 번 공개된 이후에는 그 내용이 변경되지 않는다고 하면 결과론적으로 봤을 때 해당 객체도 불변 객체라고 볼 수 있다. 이런 정도의 불변성이라고 하면 3.4절에서 불변 객체를 정의하면서 살펴봤던 여러 가지 요구 조건을 반드시 만족시켜야 할 필요는 없다. 대신 프로그램 내부에서 해당 객체를 한 번 공개한 이후에는 마치 불변 객체인 것처럼 사용하기만 하면 된다. 이와 같이 결과적인 불변 객체는 개발 과정도 훨씬 간편하고 동기화 작업을 할 필요가 없기 때문에 프로그램의 성능을 개선하는 데도 도움이 된다.
- 95p
병렬 컬렉션
ConcurrentHashMap
ConcurrentHashMap은 HashMap과 같이 해시를 기반으로 하는 Map이다. 하지만 내부적으로는 이전에 사용하던 것과 전혀 다른 동기화 기법을 채택해 병렬성과 확장성이 훨씬 나아졌다. 이전에는 모든 연신에서 하나의 락을 사용했기 때문에 특정 시점에 하나의 스레드만이 해당 컬렉션을 사용할 수 있었다. 하지만 ConcurrentHasbMap은 락 스트라이핑(lock striping)이라 부르는 굉장히 세밀한 동기화 방법을 사용해 여러 스레드에서 공유하는 상태에 훨씬 잘 대응할 수 있다. 값을 읽어가는 연산은 많은 수의 스레드라도 얼마든지 동시에 처리할 수 있고, 읽기 연산과 쓰기 연산도 동시에 처리할 수 있으며, 쓰기 연산은 제한된 개수만큼 동시에 수행할 수 있다. 속도를 보자면 여러 스레드가 동시에 동작하는 환경에서 일반적으로 훨씬 높은 성능 결과를 볼 수 있 으며, 이와 함께 단일 스레드 환경에서도 성능상의 단점을 찾아볼 수 없다.
다른 병렬 컬렉션 클래스와 비슷하게 ConcurrentHashMap 클래스도 Iterator를 만들어내는 부분에서 많이 발전했는데, ConcurrentHasbMap이 만들어 낸 Iterator는 ConcurrentModficationException을 발생시키지 않는다. 따라서 ConcurrentHashMap의 항목을 대상으로 반복문을 실행하는 경우에는 따로 락을 걸어 동기화해야 할 필요가 없다.
ConcurrentHashMap에서 만들어 낸 Iterator는 즉시 멈춤(fail-fase) 대신 미약한 일관성 전략을 취한다. 미약한 일관성 전략은 반복문과 동시에 컬렉션의 내용을 변경한다 해도 Iterator를 만들었던 시점의 상황대로 반복을 계속 할 수 있다. 게다가 Iterator를 만든 시점 이후에 변경된 내용을 반영해 동작할 수도 있다(이 부분은 반드시 보장되지는 않는다).
- 139p
CopyOnWriteArrayList
CopyOnWriteArrayList 클래스는 동기화된 List 클래스보다 병렬성을 훨씬 높이고자 만들어졌다. 예를 들어 대부분의 일반적인 용도에 쓰일 때 병렬성이 향상됐고, 특히 List에 들어 있는 값을 Iterator로 불러다 사용하려 할 때 List 전체에 락을 걸거나 List를 복제할 필요가 없다.
- 140p
'변경할 때마다 복사'하는 컬렉션 클래스는 불변 객체를 외부에 공개하면 여러 스레드가 동시에 사용하려는 환경에서도 별다른 동기화 작업이 필요 없다는 개념을 바탕으로 스레드 안정성을 확보하고 있다. 하지만 컬렉션이라면 항상 내용이 바뀌어야 하기 때문에, 컬렉션의 내용이 변경될 때마다 복사본을 새로 만들어 내는 전략을 취한다.
만약 CopyOnWriteArrayList 컬렉션에서 Iterator를 뽑아내 사용한다면 Iterator를 뽑아내는 시점의 컬렉션 데이터를 기준으로 반복하며, 반복하는 동안 컬렉션에 추가되거나 삭제되는 내용은 반복문과 상관 없는 복사본을 대상으로 반영하기 때문에 동시 사용성에 문제가 없다. 물론 반복문에서 락을 걸어야 할 필요가 있기는 하지만, 반복할 대상 전체를 한번에 거는 대신 개별 항목마다 가시성을 확보하려는 목적으로 잠깐씩 락을 거는 정도면 충분하다.
- 141p
물론 컬렉션의 데이터가 변경될 때마다 복사본을 만들어내기 때문에 성능의 측면에서 손해를 볼 수 있고, 특히나 컬렉션에 많은 양의 자료가 들어 있다면 손실이 클 수 있다. 따라서 변경할 때마다 복사하는 컬렉션은 변경 작업보다 반복문으로 읽어내는 일이 훨씬 빈번한 경우에 효과적이다.
- 141p
이런 조건은 이벤트 처리 시스템에서 이벤트 리스너를 관리하는 부분에 유용하게 사용할 수 있다. 리스너를 활용해 이벤트를 처리하는 시스템에서는 이벤트가 발생하는 부분에 이벤트를 처리할 리스너를 등록하고, 특정 이벤트가 발생하면 등록된 리스너를 차례로 호출하도록 되어 있다. 이런 경우 리스너를 등록하거나 해제하는 기능을 사용하는 빈도가 리스너 목록의 내용을 반복문으로 호출하는 기능의 발생 빈도보다 훨씬 낮기 때문에 변경할 때마다 복사하는 컬렉션을 사용하기에 적당한 상황이라고 할 수 있다.
- 142p
블로킹 큐와 프로듀서-컨슈머 패턴
블로킹 큐blocking queue는 put과 take라는 핵심 메소드를 갖고 있고, 더불어 offer와 poll이라는 메소드도 갖고 있다. 만약 큐가 가득 차 있다면 Put 메소드는 값을 추가할 공간이 생길 때까지 대기한다. 반대로 큐가 비어 있는 상태라면 take 메소드는 뽑아낼 값이 들어올 때까지 대기한다. 큐는 그 크기를 제한할 수도 있고 제한하지 않을 수도 있는데, 말 그대로 큐의 크기에 제한을 두지 않으면 항상 여유 공간이 있는 셈이기 때문에 put 연산이 대기 상태에 들어가는 일이 발생하지 않는다.
- 142p
프로듀서가 컨슈머가 감당할 수 있는 것보다 많은 양의 작업을 만들어내면 해당 애플리케이션의 큐에는 계속해서 작업이 누적되어 결국에는 메모리 오류가 발생하게 된다. 하지만 큐의 크기에 제한을 두면 큐에 빈 공간이 생길 때까지 put 메소드가 대기하기 때문에 프로듀서 코드를 작성하기가 훨씬 간편해진다. 그러면 컨슈머가 작업을 처리하는 속도에 프로듀서가 맞춰야 하며, 컨슈머가 처리하는 야보다 많은 작업을 만들어 낼 수 없다.
- 143p
블로킹 큐는 애플리케이션이 안정적으로 동작하도록 만들고자 할 때 요긴하게 사용할 수 있는 도구이다. 블로킹 큐를 사용하면 처리할 수 있는 양보다 훨씬 많은 작업이 생겨 부하가 걸리는 상황에서 작업량을 조절해 애플리케이션이 안정적으로 동작하도록 유도할 수 있다.
- 144p
자바 클래스 라이브러리에는 BlockingQueue 인터페이스를 구현한 클래스 몇 가지가 들어 있다. LinkedBlockingQueue와 ArrayBlockingQueue는 FIFO 형태의 큐이며, 기존에 클래스 라이브러리에 포함되어 있던 LinkedList와 ArrayList에 각각 대응된다. 대신 병렬 프로그램 환경에서는 LinkedList나 ArrayList에서 동기화된 List 인스턴스를 뽑아 사용하는 것보다 성능이 좋다.
PriorityBlockingQueue 클래스는 우선순위를 기준으로 동작하는 큐이고, FIFO가 아닌 다른 순서로 큐의 항목을 처리해야 하는 경우에 손쉽게 사용할 수 있다.
- 144p
SynchronousQueue 클래스도 BlockingQueue 인터페이스를 구현하는데, 큐에 항목이 쌓이지 않으며, 따라서 큐 내부에 값을 저장할 수 있도록 공간을 할당하지도 않는다. 대신 큐에 값을 추가하려는 스레드나 값을 읽어가려는 스레드의 큐를 관리한다. 앞에서 살펴봤던 접시 닦는 작업을 예로 들자면, 프로듀서와 컨슈머 사이에 접시를 쌓아 둘 공간이 전혀 없고 프로듀서는 컨슈머의 손에 접시를 직접 넘겨주는 구조이다.
큐를 구현하는 것치고는 굉장히 이상한 모양이라고 생각할 수 있지만, 프로듀서와 컨슈머가 직접 데이터를 주고받을 때까지 대기하기 때문에 프로듀서에서 컨슈머로 데이터가 넘어가는 순간은 굉장히 짧아진다는 특징이 있다.
- 145p
프로듀서-컨슈머 패턴과 플로킹 큐는 가변 객체를 사용할 때 객체의 소유권을 프로듀서에서 컨슈머로 넘기는 과정에서 직렬 스레드 한정 기법을 사용한다. 스레드에 한정된 객체는 특정 스레드 하나만이 소유권을 가질 수 있는데, 객체를 안전한 방법으로 공개하면 객체에 대한 소유권을 이전할 수 있다. 이렇게 소유권을 이전하고 나면 이전받은 컨슈머 스레드가 객체에 대한 유일한 소유권을 가지며, 프로듀서 스레드는 이전된 객체에 대한 소유권을 완전히 잃는다. 이렇게 안전한 공개 방법을 사용하면 새로운 소유자로 지정된 스레드는 객체의 상태를 완벽하게 볼 수 있지만 원래 소유권을 갖고 있던 스레드는 전혀 상태를 알 수 없게 되어, 새로운 스레드 내부에 객체가 완전히 한정된다. 물론 새로 소유권을 확보한 스레드가 객체를 마음껏 사용할 수 있다.
객체 풀은 직렬 스레드 한정 기법을 잘 활용하는 예인데, 풀에서 소유하고 있던 객체를 외부 스레드에게 '빌려주는' 일이 본업이기 때문이다. 풀 내부에 소유하고 있던 객체를 외부에 공개할 때 적절한 동기화 작ㅇ버이 되어 있고, 그와 함께 풀에서 객체를 빌려다 사용하는 스레드 역시 빌려온 객체를 외부에 공개하거나 풀에 반납한 이후에 계속해서 사용하는 등의 일을 하지 않는다면 풀 스레드와 사용자 스레드 간에 소유권이 원활하게 이전되는 모습을 볼 수 있다.
- 147p
블로킹 메소드, 인터럽터블 메소드
스레드는 여러 가지 원인에 의해 블록 당하거나, 멈춰질 수 있다. 예를 들어 I/O 작업이 끝나기를 기다리는 경우도 있고, 락을 확보하기 위해 기다리는 경우도 있고 Thread.sleep 메소드가 끝나기를 기다리는 경우도 있고, 다른 스레드가 작업 중인 내용의 결과를 확인하기 위해 기다리는 경우도 있다. 스레드가 블록되면 동작이 멈춰진 다음 블록된 상태(BLOCKED, WAITING, TIMED_ITING) 가운데 하나를 갖게 된다. 블로킹 연산은 단순히 실행 시간이 오래 걸리는 일반 연산괴는 달리 멈춘 상태에서 특정한 신호를 받아야 계속해서 실행할 수 있는 연산을 말한다. 이와 같이 기다리던 외부 신호가 확인되면 스레드의 상태가 다시 RUNNABLE 상태로 넘어가고 다시 시스템 스케줄러를 통해 CPU를 사용할 수 있게 된다.
BlockingQueue 인터페이스의 put 메소드와 take 메소드는 Thread.sleep 메소드와 같이 InterruptedException을 발생시킬 수 있다. 특정 메소드가 InterruptedException을 발생시킬 수 있다는 것은 해당 메소드가 블로킹 메소드라는 의미이고, 만약 메소드에 인터럽트가 걸리면 해당 메소드는 대기 중인 상태에서 풀려나고자 노력한다. Thread 클래스는 해당 스레드를 중단시킬 수 있도록 interrupt 메소드를 제공하며, 해당 스레드에 인터럽트가 걸려 중단된 상태인지를 확인할 수 있는 메소드도 있다. 모든 스레드에는 인터럽트가 걸린 상태인지를 알려주는 불린 값이 있으며, 외부에 서 인터럽트를 걸면 불린 변수에 true가 설정된다. 인터럽트는 스레드가 서로 협력해서 실행하기 위한 방법이다.
- 150p
동기화 클래스
상태 정보를 사용해 스레드 간의 작업 흐름을 조절할 수 있도록 만들어진 모든 클래스를 동기화 클래스라고 한다. 블로킹 큐 역시 동기화 클래스의 역할을 충분히 맡을 수 있다. 또 다른 동기화 클래스의 예로는 세마포어, 배리어, 래치 등이 있다.
- 152p
래치
래치는 스스로가 터미널 terminal 상태에 이를 때까지의 스레드가 동작하는 과정을 늦출 수 있도록 해주는 동기화 클래스이다. 래치는 일종의 관문과 같은 형태로 동작한다. 즉 래치가 터미널 상태에 이르기 전에는 관문이 닫혀 있다고 볼 수 있으며, 어떤 스레드도 통과할 수 없다. 그리고 래치가 터미널 상태에 다다르면 관문이 열리고 모든 스레드가 통과한다.
- 152p
CountDownLatch는 위에서 소개한 모든 경우에 쉽게 적용할 수 있는 유연한 구조를 갖고 있는데, 하나 또는 둘 이상의 스레드가 여러 개의 이벤트가 일어날 때까지 대기할 수 있도록 되어 있다. 래치의 상태는 양의 정수 값으로 카운터를 초기화하며, 이 값은 대기하는 동안 발생해야 하는 이벤트의 건수를 의미한다. CountDownLatch 클래스의 CountDown 메서드는 이벤트가 발생했을 때 내부에 갖고 있는 이벤트 카운터를 하나 낮춰주고, await 메소드는 래치 내부의 카운터가 0이 될 때까지, 즉 대기하던 이벤트가 모두 발생했을 때까지 대기하도록 하는 메소드이다. 외부 스레드가 await 메소드를 호출할 때 래치 내부의 카운터가 0보다 큰 값이었다면, await 메소드는 카운터가 0이 되거나, 대기하던 스레드에 인터럽트가 걸리거나, 대기 시간이 길어 타임아웃이 걸릴 때까지 대기한다.
- 153p
public class TestHarness {
public long timeTasks(int nThreads, final Runnable task)
throws InterruptedException {
final CountDownLatch startGate = new CountDownLatch(1);
final CountDownLatch endGate = new CountDownLatch(nThreads);
for (int i = 0; i < nThreads; i++) {
Thread t = new Thread() {
public void run() {
try {
startGate.await();
try {
task.run();
} finally {
endGate.countDown();
}
} catch (InterruptedException ignored) {
}
}
};
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end - start;
}
}
TestHarness 클래스를 보면 래치를 사용하는 두 가지 경우가 나타나있다. TestHarness는 먼저 여러 개의 스레드를 만들어 각 스레드가 동시에 실행되도록 한다. 그리고 TestHarness에는 두 개의 래치가 만들어져 있는데, 하나는 시작하는 관문이고 또 하나는 종료하는 관문이다. 시작하는 관문은 내부 카운트가 1로 초기화되고, 종료하는 관문은 내부 카운트가 전체 스레드의 개수에 해당하는 값으로 초기화된다.
작업 스레드가 시작되면 가장 먼저 하는 일은 시작하는 관문이 열리기를 기다리는 일이다. 이 과정을 통해 특정 이벤트가 발생한 이후에야 각 작업 스레드가 동작하도록 제어할 수 있다. 그리고 작업 스레드가 작업을 마치고 가장 마지막에 하는 일은 종료하는 관문의 카운트를 감소시키는 일이다. 종료하는 관문의 카운트를 계속해서 감소시키다 보면 모든 작업 스레드가 끝나는 시점이 올 것이고, 이 시점이 되면 메인 스레드는 모든 작업 스레드가 작업을 마쳤다는 것을 쉽게 알 수 있으며, 작업하는 데 걸린 시간도 쉽게 확인할 수 있다.
TestHarness 클래스에서는 왜 스레드가 만들어지는 대로 바로 작업을 시작하도록 놓아두지 않았을까? n개의 스레드가 동시에 동작할 때 전체 작업 시간이 얼마나 걸리는지를 확인하고 싶었기 때문이다. 만약 단순하게 스레드를 생성하면서 바로 작업을 시작시켜버렸다면 먼저 생성된 스레드는 나중에 생성된 스레드보다 몇 발짝 앞서 출발하는 것과 같다. 따라서 전체 스레드의 개수나 동작 중인 스레드의 수가 바뀔 때마다 서로 다른 통계 값이 나타날 수 있다. 이런 상황에서 시작하는 관문을 래치로 구현해 사용하면 메인 스레드에서 모든 작업 스레드가 동시에 작업을 시작하도록 제어할 수 있으며, 종료하는 관문을 담당하는 래치가 열리기만을 기다리면 각각의 작업 스레드가 모두 끝나기를 기다릴 필요가 없다.
- 153-154p
FutureTask
FutureTask 역시 래치와 비슷한 형태로 동작하나. FutureTask가 나타내는 연산 작업은 callable 인터페이스를 구현하도록 되어 있는데, 시작 전 대기, 시작됨, 종료됨과 같은 세 가지 상태를 가질 수 있다. 종료된 상태는 정상적인 종료, 취소, 예외 상황 발생과 같이 연산이 끝나는 모든 종류의 상태를 의미한다. FutureTask가 한 번 종료됨 상태에 이르고 나면 더 이상 상태가 바뀌는 일은 없다.
- 154p
FutureTasksms Executor 프레임웍에서 비동기적인 작업을 실행하고자 할 때 사용하며, 기타 시간이 많이 필요한 모든 작업이 있을 때 실제 결과가 필요한 시점 이전에 미리 작업을 실행시켜두는 용도로 사용한다.
- 155p
세마포어
카운팅 세마포어는 특정 자원이나 특정 연산을 동시에 사용하거나 호출할 수 있는 스레드의 수를 제한하고자 할 때 사용한다.
- 157p
세마포어는 데이터베이스 연결 풀과 같은 자원 풀에서 요긴하게 사용할 수 있다. 자원 풀을 만들 때, 모든 자원을 빌려주고 남아 있는 자원이 없을 때 요청이 들어오는 경우에 단순하게 오류를 발생시키고 끝나버리는 정도의 풀은 아주 쉽게 구현할 수 있다. 하지만 일반적으로 풀을 생각할 때는 객체를 요청했지만 남은 객체가 없을 때, 다른 스레드가 확보했던 객체를 반납받아 사용할 수 있을 때까지 대기하도록 하는 방법이 옳은 방법일 것이다. 이럴 때 카운팅 세마포어를 만들면서 최초 퍼밋의 개수로 원하는 풀의 크기를 지정해보자. 그리고 풀에서 자원을 할당받아 가려고 할 때에는 먼저 acquire를 호출해 퍼밋을 확보하도록 하고, 다 사용한 자원을 반납하고 난 다음에는 항상 release를 호출해 퍼밋도 반납하도록 한다. 그러면 풀에 자원이 남아 있지 않은 경우에 acquire 메소드가 대기 상태에 들어가기 때문에 객체가 반납될 때까지 자연스럽게 대기하게 된다.
- 158p
배리어
래치는 일회성 객체이다. 즉 래치가 한 번 터미널 상태에 다다르면 다시는 이전 상태로 회복할 수가 없다.
배리어는 특정 이벤트가 발생할 때까지 여러 스레드를 대기 상태로 잡아둘 수 있다는 측면에서 래치와 비슷하다고 볼 수 있다. 래치와의 차이점은 모든 스레드가 배리어 위치에 동시에 이르러야 관문이 열리고 계속해서 실행할 수 있다는 점이다. 래치는 '이벤트'를 기다리기 위한 동기화 클래스이고, 배리어는 '다른 스레드'를 기다리기 위한 동기화 클래스이다. 배리어는 사람들이 어딘가에서 만날 약속을 하는 것과 비슷한 형태로 동작한다. 예를 들어 "모두들 오후 6시 정각에 출판사 앞에서 만나자. 일단 약속 장소에 도착하면 모두 도착할 때까지 대기하고, 모두 도착하면 어디로 이동할지는 모두 모인 이후에 생각해보자"는 것과 같다.
- 159p
CyclickBarrier 클래스를 사용하면 여러 스레드가 특정한 배리어 포인트에서 반복적으로 서로 만나는 기능을 모델링할 수 있고, 커다란 문제 하나를 여러 개의 작은 부분 문제로 분리해 반복적으로 병렬 처리하는 알고리즘을 구현하고자 할 때 적용하기 좋다. 스레드는 각자가 배리어 포인트에 다다르면 await 메소드를 호출하며, await 메소드는 모든 스레드가 배리어 포인트에 도달할 때까지 대기한다.
모든 스레드가 배리어 포인트에 도달하면 배리어는 모든 스레드를 통과시키며, await 메소드에서 대기하고 있던 스레드는 대기 상태가 모두 풀려 실행되고, 배리어는 다시 초기 상태로 돌아가 다음 배리어 포인트를 준비한다. 만약 await를 호출하고 시간이 너무 오래 지나 타임아웃이 걸리거나 await 메소드에서 대기하던 스레드에 인터럽트가 걸리면 배리어는 깨진 것으로 간주하고, await에서 대기하던 모든 스레드에 BrokenBarrierException이 발생한다. 배리어가 성공적으로 통과하면 await 메소드는 각 스레드별로 배리어 포인트에 도착한 순서를 알려주며, 다음 배리어 포인트로 반복 작업을 하는 동안 뭔가 특별한 작업을 진행할 일종의 리더를 선출하는 데 이 값을 사용할 수 있다.
- 159~160p
CeullularAutomata 클래스는 배리어를 사용해 콘웨이의 생명 게임과 같은 셀룰러 오토마타를 시뮬레이션 하는 모습을 보여준다. 시뮬레이션 과정을 병렬화할 때, 일반적으로는 항목별로 연산할 내용을 스레드 단위로 모두 분리시키는 일은 그다지 효율적이지 않다. 셀의 개수가 많은 경우가 대두분이므로 스레드 역시 굉장히 많이 만들어질 수 있기 때문이다. 이런 경우 오히려 그 많은 스레드를 관리하느라 전체적인 속도가 크게 떨어질 수 있다. 셀 단위로 처리하는 대신 전체 면적을 특정한 크기의 부분으로 나누고, 각 스레드가 전체 면적의 일부분을 처리하고, 처리가 끝난 결과를 다시 하나로 뭉쳐 전체 결과를 재구성할 수 있겠다.
CeullularAutomata 클래스는 전체 면적을 Ncpu 개의 부분으로 나누고, 각 부분에 대한 연산을 개별 스레드에게 맡긴다. 각 단계에서 작업하는 스레드는 전체 면적의 일부분에 해당하는 구역에서 각 셀의 새로운 위치를 계산한다. 작업 스레드 모두가 계산 작업을 마치고 나면 배리어 포인트에 도달하고, 배리어 작업이 그 동안 모인 부분 결과를 하나로 묶어 전체 결과를 만들어 낸다. 배리어 작업이 모두 끝나고 나면 작업 스레드는 모두 대기 상태가 풀려 다음 단계의 연산을 시작한다. 더 이상 작업할 단계가 없는 시점에 이르렀는지 확인할 때에는 isDone이라는 메소드를 사용한다.
public class CellularAutomata {
private final Board mainBoard;
private final CyclicBarrier barrier;
private final Worker[] workers;
public CellularAutomata(Board board) {
this.mainBoard = board;
int count = Runtime.getRuntime().availableProcessors();
this.barrier = new CyclicBarrier(count,
new Runnable() {
public void run() {
mainBoard.commitNewValues();
}
});
this.workers = new Worker[count];
for (int i = 0; i < count; i++)
workers[i] = new Worker(mainBoard.getSubBoard(count, i));
}
private class Worker implements Runnable {
private final Board board;
public Worker(Board board) {
this.board = board;
}
public void run() {
while (!board.hasConverged()) {
for (int x = 0; x < board.getMaxX(); x++)
for (int y = 0; y < board.getMaxY(); y++)
board.setNewValue(x, y, computeValue(x, y));
try {
barrier.await();
} catch (InterruptedException ex) {
return;
} catch (BrokenBarrierException ex) {
return;
}
}
}
}
public void start() {
for (int i = 0; i < workers.length; i++)
new Thread(workers[i]).start();
mainBoard.waitForConvergence();
}
}
배리어와 약간 다른 형태로 Exchanger 클래스를 살펴보자. Exchanger 클래스는 두 개의 스레드가 연결되는 배리어이며, 배리어 포인트에 도달하면 양쪽의 스레드가 서로 갖고 있던 값을 교환한다. Exchanger 클래스는 양쪽 스레드가 서로 대칭되는 작업을 수행할 때 유용하다.
- 160p
효율적이고 확장성 있는 결과 캐시 구현
Computable<A,V> 인터페이스는 A라는 입력 값과 V라는 결과 값에 대한 메소드를 정의하고 있다. Computable 인터페이스를 구현한 ExpensiveFunction 클래스는 결과를 뽑아 내는 데 상당한 시간이 걸린다. 이런 상황에서 Computable에 한 겹을 덧씌워 이전 결과를 기억하는 캐시 기능을 추가해보자. 이런 방법을 흔히 메모이제이션이라고 한다.
public interface Computable<A, V> {
V compute(A arg) throws InterruptedException;
}
public class ExpensiveFunction
implements Computable<String, BigInteger> {
public BigInteger compute(String arg) {
return new BigInteger(arg);
}
}
public class Memorizer1<A, V> implements Computable<A, V> {
@GuardedBy("this")
private final Map<A, V> cache = new HashMap<A, V>();
private final Computable<A, V> c;
public Memorizer1(Computable<A, V> c) {
this.c = c;
}
public synchronized V compute(A arg) throws InterruptedException {
V result = cache.get(arg);
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
Memoizer1은 이번에 만들고자 하는 캐시의 첫 번째 버전이다. 보다시피 결과를 저장해두는 저장소로는 HashMap을 사용했다.
Compute 메소드는 먼저 원하는 결과가 저장소에 저장되어 있는지를 확인하고, 저장소에 결과가 들어 있다면 그 값을 가져다 리턴해준다. 저장소에 결과가 없다면 결과를 새로 계산하고, 값을 리턴하기 전에 결과 값을 저장소에 넣어둔다.
HashMap은 스레드에 안전하지 않기 때문에 Memoizer1은 두 개 이상의 스레드가 HashMap에 동시에 접근하지 못하도록 Compute 메소드 전체를 동기화시켜 버리는 가장 단순한 정책을 취했다.
- 163~165p
Memoizer2는 Memoizer1에서 사용했던 HashMap 대신 ConcurrentHashMap을 사용하는데, Memoizer1에 비해 병렬 프로그래밍의 입장에서 엄청나게 개섡됐다. ConcurrentHashMap은 이미 스레드 안전성을 확보하고 있기 때문에 그 내부의 Map을 사용할 때 별다른 동기화 방법을 사용하지 않아도 된다. 따라서 Memoizer1에서 compute 메소드 전체를 동기화하느라 생겼던 성능상의 큰 문제점을 일거에 해소하는 셈이다.
public class Memorizer2<A, V> implements Computable<A, V> {
private final Map<A, V> cache = new ConcurrentHashMap<A, V>();
private final Computable<A, V> c;
public Memorizer2(Computable<A, V> c) { this.c = c; }
public V compute(A arg) throws InterruptedException {
V result = cache.get(arg);
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
- 165p
하지만 캐시라는 기능으로 볼 때 아직도 약간 미흡한 부분이 있다. 두 개 이상의 스레드가 동시에 같은 값을 넘기면서 compute 메소드를 호출해 같은 결과를 받아갈 가능성이 있기 때문이다. 메모이제이션이라는 측면에서 보면 이런 상황은 단순히 효율성이 약간 떨어지는 것뿐이다. 캐시는 같은 값으로 같은 결과를 연산하는 일을 두 번 이상 실행하지 않겠다는 것이기 때문이다. 훨씬 일반적인 형태의 캐시 메커니즘을 생각한다면 좀더 안좋은 상황을 생각해야 한다. 캐시할 객체를 한 번만 생성해야 하는 객체 캐시의 경우에는 똑같은 결과를 두 개 이상 만들어낼 수 있는 이런 문제점이 안정성 문제로 이어질 수도 있다.
Memoizer2의 문제는 특정 스레드가 compute 메소드에서 연산을 시작했을 때, 다른 스레드는 현재 어떤 연산이 이뤄지고 있는지 알 수 없기 때문에 그림 5.3과 같이 동일한 연산을 시작할 수 있다는 점이다. 여기에서 필요한 점은 특정 스레드가 f(27)이라는 값을 알고 싶을 때 "스레드 X가 현재 f(27)이라는 연산을 하고 있다"는 것을 알고 싶다는 것이고, f(27)을 다른 스레드가 계산하고 있을 때 f(27) 값을 얻을 수 있는 가장 효과적인 방법은 스레드 X가 작업을 모두 끝낼 때까지 대기하고 있다가 작업이 끝나면 f(27)의 결과 값으로 무엇을 얻었는가?라고 물어보는 것이다.
- 166~167p
Memoizer3은 결과를 저장하는 Map을 기존의 ConcurrentHashMap<A,V>
대신 ConcurrentHashMap<A, Future<V>>
라고 정의한다. Memoizer3은 먼저 원하는 값에 대한 연산 작업이 시작됐는지를 확인해본다. 시작된 작업이 없다면 FutureTask를 하나 만들어 Map에 등록하고, 연산 작업을 시작한다. 시작된 작업이 있었다면 현재 실행 중인 연산 작업이 끝나고 결과가 나올 때까지 대기한다. 결과 값은 원하는 즉시 찾을 수 있거나, 아직 연산이 진행 중인 경우에는 작업이 끝날 때까지 대기해야 할 수도 있다.
- 167p
MeMoizer3가 갖고 있는 허점은 Map에 결과를 추가할 때 단일 연산이 아닌 복합 연산을 사용하기 때문이며, 락을 사용해서는 단일 연산으로 구성할 수가 없다. Memoizer는 ConcurrentMap 클래스의 putIfAbsent라는 단일 연산 메소드를 사용해 결과를 저장한다. 이것으로 Memoizer3에섭 ㅏㄹ생하던 허점은 완벽하게 보완할 수 있다.
- 169p
1부 요약
- 상태가 바뀔 수 있단 말이다!
병렬성과 관련된 모든 문제점은 변경 가능한 변수에 접근하려는 시도를 적절하게 조율하는 것으로 해결할 수 있다. 변경 가능성이 낮으면 낮을수록 스레드 안전성을 확보하기가 쉽다.
- 변경 가능한 값이 아닌 변수는 모두 final로 선언하라.
- 불변 객체는 항상 그 자체로 스레드 안전하다. 불변 객체는 병렬 프로그램을 엄청나게 간편하게 작성할 수 있도록 해준다. 불변 객체는 간결하면서 안전하고, 락이나 방어적 복사 과정을 거치지 않고도 얼마든지 공유해 사용할 수 있다.
- 캡슐화하면 복잡도를 손쉽게 제어할 수 있다.
모든 값을 전역 변수에 넣어두더라도 프로그램을 스레드 안전하게 작성할 수는 있다. 하지만 도대체 무엇 때문에 그런 짓을 하는가? 데이터를 객체 내부에 캡슐화하면 값이 변경되는 자유도를 쉽게 제어할 수 있다. 객체 내부에서 동기화하는 기법을 캡슐화하면 동기화 정책을 손쉽게 적용할 수 있다.
- 변경 가능한 객체는 항상 락으로 막아줘야 한다.
- 불변 조건 내부에 들어가는 모든 변수는 같은 락으로 막아줘야 한다.
- 복합 연산을 처리하는 동안에는 항상 락을 확보하고 있어야 한다.
- 여러 스레드에서 변경 가능한 변수의 값을 사용하도록 되어 있으면서 적절한 동기화 기법이 적용되지 않은 프로그램은 올바른 결과를 내놓지 못한다.
- 동기화할 필요가 없는 부분에 대해서는 일부러 머리를 써서 고민할 필요가 없다.
- 설계 단계부터 스레드 안전성을 염두에 두고 있어야 한다. 아니면 최소한 결과물로 작성된 클래스가 스레드에 안전하지 않다고 반드시 문서로 남겨야 한다.
- 프로그램 내부의 동기화 정책에 대한 문서를 남겨야 한다.
2부 병렬 프로그램 구조 잡기
스레드에서 작업 실행
반응 속도를 훨씬 높일 수 있는 방법 가운데 하나는 요청이 들어올 때마다 새로운 스레드를 하나씩 만들어 실행시키는 방법이다. 예제 6.2의 ThreadPerTaskWebServer 클래스가 이렇게 구현되어 있다.
public class ThreadPerTaskWebServer {
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
new Thread(task).start();
}
}
private static void handleRequest(Socket connection) {
// request-handling logic here
}
}
- 작업을 처리하는 기능이 메인 스레드에서 떨어져 나온다.
- 동시에 여러 작업을 병렬로 처리할 수 있기 때문에 두 개 이상의 요청을 받아 동시에 처리할 수 있다.
- 실제 작업을 처리하는 스레드의 프로그램은 여러 클라이언트가 접속하는 경우 동시에 동작할 가능성이 매우 높기 때문에 스레드 안전성을 확보해야 한다.
- 178p
스레드를 많이 생성할 때의 문제점
- 스레드 라이프 사이클 문제: 스레드를 생성하고 제거하는 작업에도 자원이 소모된다.
- 자원 낭비: 실행중인 스레드는 시스템의 자원, 특히 메모리를 소모한다.
- 안정성 문제: 모든 시스템에는 생성할 수 있는 스레드의 개수가 제한되어 있다.
일정한 수준까지는 스레드를 추가로 만들어 사용해서 성능상의 이점을 얻을 수 있지만, 특정 수준을 넘어간다면 성능이 떨어지게 마련이다. 더군다나 스레드 하나를 계속해서 생성한다면 애플리케이션이 제대로 다운되는 모습을 보게 될 것이다. 이런 위험한 상황에서 벗어나고자 한다면 애플리케이션이 만들어낼 수 있는 스레드의 수에 제한을 두는 것이 현명한 방법이고, 애플리케이션이 제한된 수의 스레드만으로 동작할 때 용량에 넘치는 요청이 들어오는 상황에서도 자원이 고갈되어 멈추는 경우가 발생하지 않는지를 세심하게 테스트해야 한다.
- 180p
Executor 프레임웍
public interface Executor {
void execute(Runnable command);
}
Executo는 굉장히 단순한 인터페이스로 보이지만, 아주 다양한 여러 가지 종류의 작업 실행 정책을 지원하는 유연하면서도 강력한 비동기적 작업 실행 프레임웍의 근간을 이루는 인터페이스다.
Executor는 작업 등록과 작업 실행을 분리하는 표준적인 방법이며, 각 작업은 Runnable의 형태로 정의한다. Executor 인터페이스를 구현한 클래스는 작업의 라이프 사이클을 관리하는 기능도 갖고 있고, 몇 가지 통계 값을 뽑아내거나 또는 애플리케이션 작업 실행 과정을 관리하고 모니터링하기 위한 기능도 갖고 있다.
- 181p
TaskExecutionWebServer 클래스를 보면 요청 처리 작업을 등록하는 부분과 실제로 처리 기능을 실행하는 부분이 Executor를 사이에 두고 분리되어 있고, Executor를 다른 방법으로 구현한 클래스를 사용하면 비슷한 기능에 다른 특성으로 동작하도록 손쉽게 변경할 수 있다. 스레드를 직접 생성하도록 구현되어 있는 상태에서는 서버의 동작 특성을 쉽게 변경할 수 없었지만, Executor를 사용하면 Executor의 설정을 변경하는 것만으로 쉽게 변경된다.
public class TaskExecutionWebServer {
private static final int NTHREADS = 100;
private static final Executor exec
= Executors.newFixedThreadPool(NTHREADS);
public static void main(String[] args) throws IOException {
ServerSocket socket = new ServerSocket(80);
while (true) {
final Socket connection = socket.accept();
Runnable task = new Runnable() {
public void run() {
handleRequest(connection);
}
};
exec.execute(task);
}
}
private static void handleRequest(Socket connection) {
// request-handling logic here
}
}
- 182p
자바 클래스 라이브러리에서는 흔히 사용하는 여러 가지 설정 상태에 맞춰 몇 가지 종류의 스레드 풀을 제공하고 있다. 미리 정의되어 있는 스레드 풀을 사용하려면 Executors 클래스에 만들어져 있는 다음과 같은 메소드를 호출하자.
newFixedThreadPool: 처리할 작업이 등록되면 그에 따라 실제 작업할 스레드를 하나씩 생성한다. 생성할 수 있는 스레드의 최대 개수는 제한되어 있으며 제한된 개수까지 스레드를 생성하고 나면 더 이상 생성하지 않고 스레드 수를 유지한다.
newCachedThreadPool: 캐시 스레드 풀은 현재 풀에 갖고 있는 스레드의 수가 처리할 작업의 수보다 많아서 쉬는 스레드가 많이 발생할 때 쉬는 스레드를 종료시켜 훨씬 유연하게 대응할 수 있으며, 처리할 작업의 수가 많아지면 필요한 만큼 스레드를 새로 생성한다. 반면에 스레드의 수에는 제한을 두지 않는다.
newSingleThreadExecutor: 단일 스레드로 동작하는 Executor로서 작업을 처리하는 스레드가 단 하나뿐이다. 만약 작업 중에 Exception이 발생해 비정상적으로 종료되면 새로운 스레드를 하나 생성해 나머지 작업을 실행한다. 등록된 작업은 설정된 큐에서 지정하는 순서에 따라 반드시 순차적으로 처리된다.
newScheduledThreadPool: 일정 시간 이후에 실행하거나 주기적으로 작업을 실행할 수 있으며, 스레드의 수가 고정되어 있는 형태의 Executor.Timer 클래스의 기능과 유사하다.
- 185p
중단 및 종료
잠깐 실행하고 마는 애플리케이션이 아닌 이상, 예외가 발생했을 때 로그 팡리에 오류를 출력하는 간단한 기능만이라도 확보할 수 있도록 모든 스레드를 대상으로 UncaughtExceptionHandler를 활용해야 한다.
- 244p
JVM 종료
스레드는 두 가지 종류로 나눠볼 수 있는데, 하나는 일반 스레드이고 다른 하나는 데몬 스레드이다. JVM이 처음 시작할 때 main 스레드를 제외하고 JVM 내부적으로 사용하기 위해 실행하는 스레드는 모두 데몬 스레드이다. 새로운 스레드가 생성되면 자신을 생성해 준 부모 스레드의 데몬 설정 상태를 확인해 그 값을 그대로 사용하며, 따라서 main 스레드에서 생성한 스레드는 기본적으로 데몬 스레드가 아닌 일반 스레드이다.
- 247p
스레드 풀 활용
스레드 부족 데드락
스레드 풀에서 다른 작업에 의존성을 갖고 있는 작업을 실행시킨다면 데드락에 걸릴 가능성이 높다. 단일 스레드로 동작하는 Executor에서 다른 작업을 큐에 등록하고 해당 작업이 실행된 결과를 가져다 사용하는 작업을 실행하면, 데드락이 제대로 걸린다. 이전 작업이 추가한 두 번째 작업은 큐에 쌓인 상태로 이전 작업이 끝나기를 기다릴 것이고, 이전 작업은 추가된 작업이 실행되어 그 결과를 알려주기를 기다릴 것이기 때문이다. 스레드 풀의 크기가 크더라도 실행되는 모든 스레드가 큐에 쌓여 아직 실행되지 않은 작업의 결과를 받으려고 대기 중이라면 이와 동일한 상황이 발생할 수 있다.
이런 현상을 바로 스레드 부족 데드락 thread starvation deadlock이라고 하며, 특정 자원을 확보하고자 계속해서 대기하거나 풀 내부의 다른 작업이 실행돼야 알 수 있는 조건이 만족하기를 기다리는 것처럼 끝없이 계속 대기할 가능성이 있는 기능을 사용하는 작업이 풀에 등록된 경우에는 언제든지 발생할 수 있다.
- 253p
public class ThreadDeadlock {
ExecutorService exec = Executors.newSingleThreadExecutor();
public class RenderPageTask implements Callable<String> {
public String call() throws Exception {
Future<String> header, footer;
header = exec.submit(new LoadFileTask("header.html"));
footer = exec.submit(new LoadFileTask("footer.html"));
String page = renderBody();
// Will deadlock -- task waiting for result of subtask
return header.get() + page + footer.get();
}
}
- 254p
ThreadPoolExecutor를 생성할 때 작업을 쌓아둘 큐로 BlockingQueue를 지정할 수 있다. 스레드 풀에서 작업을 쌓아둘 큐에 적용할 수 있는 전략에는 세 가지가 있다. 첫 번째는 큐에 크기 제한을 두지 않는 방법이고, 두 번째는 큐의 크기를 제한하는 방법, 세 번재는 작업을 스레드에 직접 넘겨주는 방법이다. 작업을 쌓는 방법 역시 풀의 크기를 지정하는 것과 같은 여러 가지 설정과 연관되어 있다.
- 260p
TimingThreadPool 클래스는 beforeExecute, afterExecute, terminated 등의 훅 메소드를 활용해 로그를 출력하고 통계 값을 수집하는 작업을 하도록 구현된 예제이다. 작업이 얼마나 오랫동안 실행되는지를 측정할 수 있도록 beforeExecute 메소드에서 시작 시간을 기록해두고 afterExecute 메소드에서는 시작할 때 기록해뒀던 시간을 참조해 작업이 실행된 시간을 알아낸다. 이와 같은 훅 메소드 역시 작업을 실행했던 바로 그 스레드에서 호출하기 때문에 beforeExecute 메소드에서 측정한 값을 ThreadLocal에 보관해두면 afterExecute 메소드에서 안전하게 찾아낼 수 있다.
TiminingThreadPool은 실행된 총 작업의 개수와 작업을 실행하는데 얼마만큼의 시간이 걸렸는지를 두 개의 AtomicLong 변수에 보관한다. 그리고 terminated 훅 메소드에서 AtomicLong에 보관해뒀던 값을 가져와 개별 작업당 평균 실행 시간을 로그 메시지로 출력한다.
public class TimingThreadPool extends ThreadPoolExecutor {
public TimingThreadPool() {
super(1, 1, 0L, TimeUnit.SECONDS, null);
}
private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
private final Logger log = Logger.getLogger("TimingThreadPool");
private final AtomicLong numTasks = new AtomicLong();
private final AtomicLong totalTime = new AtomicLong();
protected void beforeExecute(Thread t, Runnable r) {
super.beforeExecute(t, r);
log.fine(String.format("Thread %s: start %s", t, r));
startTime.set(System.nanoTime());
}
protected void afterExecute(Runnable r, Throwable t) {
try {
long endTime = System.nanoTime();
long taskTime = endTime - startTime.get();
numTasks.incrementAndGet();
totalTime.addAndGet(taskTime);
log.fine(String.format("Thread %s: end %s, time=%dns",
t, r, taskTime));
} finally {
super.afterExecute(r, t);
}
}
protected void terminated() {
try {
log.info(String.format("Terminated: avg time=%dns",
totalTime.get() / numTasks.get()));
} finally {
super.terminated();
}
}
}
- 269~270p
재귀 함수 병렬화
void processSequentially(List<Element> elements) {
for (Element e : elements)
process(e);
}
void processInParallel(Executor exec, List<Element> elements) {
for (final Element e : elements)
exec.execute(new Runnable() {
public void run() { process(e); }
});
}
두 가지 메소드 가운데 processInParallel을 호출하면 지정된 Executor에 실행할 작업을 모두 등록하기만 하고 리턴되기 때문에 processSequentially 보다 훨씬 빨리 실행된다. 한 묶음의 작업을 한꺼번에 등록하고 그 작업들이 모두 종료될 때까지 대기하고자 한다면 ExecutorService.invokeAll 메소드를 사용해보자. 작업의 결과를 확보하는 시점에 그 결과를 알고 싶다면 199쪽의 Render에서 사용했던 CompletionService를 적용해 보는 것이 좋겠다.
특정 작업을 여러 변 실행하는 반복문이 있을 때, 반복되는 각 작업이 서로 독립적이라면 병렬화해서 성능의 이점을 얻을 수 있다. 특히 반복문 내부의 작업을 개별적인 작업으로 구분해 실행하느라 추가되는 약간의 부하가 부담되지 않을 만큼 적지 않은 시간이 걸리는 작업이라야 더 효과를 볼 수 있다.
- 271p
sequentialRecursive 메서드는 트리 구조를 대상으로 깊이 우선 탐색을 실행하면서 각 노드에서 연산작업을 처리하고 연산 결과를 컬렉션에 담도록 되어 있다. sequentialRecursive를 병렬화한 parallelRecursive 메소드 역시 깊이 우선 탐색을 진행하지만 각 노드를 방문할 때마다 필요한 결과를 계산하는 것이 아니라, 노드별 값을 계산하는 작업을 생성해 Executor에 등록시킨다.
public<T> void sequentialRecursive(List<Node<T>> nodes, Collection<T> results) {
for (Node<T> n : nodes) {
results.add(n.compute());
sequentialRecursive(n.getChildren(), results);
}
}
public<T> void parallelRecursive(final Executor exec, list<Node<T>> nodes, final Collection<T> results) {
for (final Node<T> n : nodes) {
exec.execute(new Runnable() {
public void run() {
results.add(n.compute());
}
});
parallelRecursive(exec, n.getChildren(), results);
}
}
parallelRecursive 메소드가 리턴되고 난 직후에는 트리의 모든 노드를 한 번씩 방문한 상태이며 각 노드별로 필요한 연산 작업은 지정된 Executor에 등록된 상태이다. parallelRecursive 메소드를 호출하는 스레드는 예제 8.12와 같이 parallelRecursive 메소드에서 사용할 전용 Executor를 하나 생성해 parallelRecursive 메소드를 호출한 다음, Executor의 shutdown 메소드와 awaitTermination 메소드를 하쳬로 호출해 모든 연산 작업이 마무리되기를 기다릴 수 있다.
public<T> Collection<T> getParallelResults(List<Node<T>> nodes) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
Queue<T> resultQueue = new ConcurrentLinkedQueue<T>();
parallelRecursive(exec, nodes, resultQueue);
exec.shutdown();
exec.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
return resultQueue;
}
- 272~273p
GUI 애플리케이션
스윙이나 SWT 등을 포함한 거의 모든 GUI 툴킷은 GUI 관련 작업이 모두 단일 스레드에서 일어나는 단일 스레드 서브시스템으로 구현돼 있다. 만들고자 하는 프로그램이 완벽하게 단일 스레드상에서 동작할 것이 아닌 이상에야 프로그램에서 필요한 작업 가운데 일부는 이벤트 스레드에서 실행될 것이고, 또 나머지는 일반 애플리케이션 스레드에서 실행될 것이다.
- 283p
GUI는 왜 단일 스레드로 동작하는가?
예전에는 GUI 애플리케이션이 단일 스레드로 동작했으며, GUI 이벤트는 애플리케이션의 메인 이벤트 반복문에서 처리했었다. 하지만 최근에 등장한 GUI 프레임웍은 약간 다른 구조로 만들어져 있는데, 이를테면 이벤트 처리 스레드(EDT, Event dispatch thread)에서 GUI 이벤트를 전담해서 처리하도록 돼 있다.
- 283~284p
GUI 프레임웍에서 여러 개의 스레드를 사용하고자 하는 시도는 많았지만, 대부분 경쟁 조건과 데드락 등의 문제가 계속 발생했다. 결국 대부분의 프레임웍이 이벤트 처리용 전담 스레드를 만들고, 전담 스레드는 큐에 쌓여 있는 이벤트를 가져와 애플리케이션에 준비돼 있는 이벤트 처리 메소드를 호출해 기능을 동작시키는 단일 스레드 이벤트 큐 모델에 정착한 셈이다.
- 284p
짧게 실행되는 GUI 작업
GUI 애플리케이션에서는 이벤트 스레드에서 이벤트가 시작돼 애플리케이션에 만들어져 있는 리스너에게 전파된다. 리스너가 이벤트를 받으면 화면에 뭔가가 표시하는 객체를 사용해 동작한다. 예를 들어 짧은 시간 동안만 실행되는 작업은 작업 전체가 이벤트 스레드 내부에서 실행해도 큰 문제는 없다. 하지만 오랜 시간 실행되는 작업은 이벤트 스레드가 아닌 외부의 다른 스레드에서 실행하도록 해야 맞다.
간단한 예를 보자면, 화면 표시용 객체를 이벤트 스레드 내부에서만 사용하도록 제한하는 것은 아주 당연하다. 예제 9.3에서는 클릭할 때마다 계속해서 자신의 색깔이 아무렇게나 변하도록 만든 버튼 클래스가 나타나 있다. 사용자가 버튼을 클릭하면 GUI 프레임웍은 이벤트 스레드를 통해 버튼을 클릭했을 때 발생한 ActionEvent 이벤트 클래스를 등록돼 있는 모든 동작 리스너에게 전달한다.
ActionEvent가 전달되면 그에 대한 응답으로 임의의 색깔을 하나 골라서 그 색의 버튼 배경 색으로 지정하게 돼 있다. 결국 이벤트는 맨 처음 GUI 프레임웍에서 발생해서 애플리케이션으로 전달되고, 애플리케이션은 이벤트에 해당하는 대응 작업으로 GUI 프레임웍에 포함된 내용을 변경한다. 그림 9.1에서 소개했던 것과 같이 통제권이 이벤트 스레드 내부에서만 동작하고 있다.
- 288p
진행 상태 및 완료 알림
Future 인터페이스를 활용하면 장시간 실행되는 작업을 중단하는 일도 굉장히 간단하게 구현할 수 있었다. FutureTask 클래스에 포함돼 있는 done이라는 훅 메소드를 사용하면 작업이 끝났음을 알려주는 기능도 중단 기능처럼 간단하게 구현할 수 있다. 백그라운드로 실행되던 Callable 작업이 완료되면 항상 done 메소드가 호출된다. done 메소드에서 작업이 완료됐다는 정보를 화면에 표시하는 기능을 이벤트 스레드에 등록하도록 한다면, 예제 9.7과 같이 이벤트 스레드에서 호출하는 onCompletion 메소드를 갖고 있는 BackgroundTask 클래스를 만들어 볼 수 있겠다.
- 295p
분할 데이터 모델
GUI 입장에서 보면 TableModel 이나 TreeModel과 같은 스윙 내부의 데이터 모델 클래스는 화면에 표시할 데이터를 저장하는 공식 저장소와 같은 역할을 하고 있다. 그런데 따지고 보면 TableModel이나 TreeModel 역시 애플리케이션이 관리하는 다른 데이터 저장소에 대한 '뷰'에 불과할 수도 있다. 이처럼 화면 표시 부분과 애플리케이션 부분의 데이터 모델을 구분해 사용하는 모양을 분할 데이터 모델이라고 한다.
분할 데이터 모델 환경에서 화면 표시 부분의 데이터 모델은 항상 이벤트 스레드 내부에 제한돼 있고, 흔히 공유 모델이라고 하는 애플리케이션 부분의 모델은 이벤트 스레드와 애플리케이션 스레드에서 동시에 사용할 수 있기 때문에 스레드 안전성을 고려한 구조를 갖고 있다. 화면 표시 모델은 공유 모델에 이벤트 리스너를 등록해 변경 사항이 발생했을 때 변경된 내용을 알 수 있다. 화면 표시 모델은 이벤트 공유 모델에 변경 사항이 있을 때 이벤트 리스너를 통해 공유 모델의 스냅샷을 받아와 화면에 반영할 수도 있겠고, 아니면 뭔가 바뀌었다는 이벤트만을 받은 이후 직접 공유 모델에 접근해 필요한 데이터를 뽑아 갈 수도 있다.
- 300p
3부 활동성 성능 테스트
데드락
모든 철학자가 각자 자기 왼쪽에 있는 젓가락을 집은 다음 오른쪽 젓가락을 사용할 수 있을 때까지 기다렸다가 오른쪽 젓가락을 집어서 식사를 한다면, 모든 철학자가 더 이상 먹지 못하는 상황에 다다를 수 있다. 철학자 모두가 먹지 못하는 후자의 상황은 음식을 먹는 데 필요한 자원을 모두 다른 곳에서 확보하고 놓지 않기 때문에 모두가 서로 상대방이 자원을 놓기만을 기다리는, 이른바 데드락이 걸린다.
- 306p
지향 그래프를 예로 들어보면, 그래프의 노드는 스레드 하나를 의미하고, 그래프의 에지는 '스레드 B가 확보한 독점 자원을 스레드 A가 가져가려고 대기하는 상태'를 나타낸다. 만약 이런 지향 그래프 사이에서 사이클이 나타난다면 데드락이 발생하는 것과 동일하다.
- 306p
락 순서에 의한 데드락
LeftRightDeadlock 클래스에서 발생하는 데드락의 원인은 두 개의 스레드가 서로 다른 순서로 동일한 락을 확보하려 하기 때문이다. 만약 양쪽 스레드에서 같은 순서로 락을 확보하도록 돼 있다면 종속성 그래프에서 사이클이 발생하지 않기 때문에 데드락이 생기지 않는다. 프로그램 내부의 모든 스레드에서 락 L과 M을 사용하는데, 모든 경우에 L과 M을 동일한 순서로 사용한다는 점이 확실하다면 L과 M이 원인이 되는 데드락은 발생하지 않는다.
프로그램 내부의 모든 스레드에서 필요한 락을 모두 같은 순서로만 사용한다면, 락 순서에 의한 데드락은 발생하지 않는다.
public class LeftRightDeadlock {
private final Object left = new Object();
private final Object right = new Object();
public void leftRight() {
synchronized (left) {
synchronized (right) {
doSomething();
}
}
}
public void rightLeft() {
synchronized (right) {
synchronized (left) {
doSomethingElse();
}
}
}
void doSomething() {
}
void doSomethingElse() {
}
}
- 307~308p
- 308p
동적인 락 순서에 의한 데드락
특정 계좌에 들어 있는 돈을 다른 계좌로 이체하는 코드를 살펴보자. 척 보기에는 데드락이 발생할 것이라고 예상되는 부분은 없다. 자금을 이체하기 전에 양쪽 계좌에 대한 락을 확보하고, 양쪽 계좌의 잔고를 단일 연산으로 변경하면서 "계좌의 잔고는 0보다 작을 수 없다"는 등의 조건도 확인한다.
private static final Object tieLock = new Object();
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
synchronized(fromAccount) {
synchronized(toAccount){
if (fromAccount.getBalance().compareTo(amount) <0 ) throw new InsufficientFundsException();
else {
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
}
그렇다면 transferMoney 메소드는 어떻게 데드락에 걸릴까? 모든 스레드가 락을 동일한 순서로 확보하려 할 때 데드락이 발생할 수 있는데, 여기에서 락을 확보하는 순서는 전적으로 TransferMoney 메소드를 호출할 때 넘겨주는 인자의 순서에 달렸다. 따라서 두 개의 스레드가 transferMoney 메소드를 동시에 호출하되, 한쪽 스레드는 X 계좌에서 Y 계좌로 자금을 이체하고, 다른 쪽 스레드는 Y 계좌에서 X 계좌로 자금을 이체하도록 할 때 데드락이 발생한다.
A: transferMoney(myAccount, yourAccount, 10);
B: transferMoney(yourAccount, myAccount, 20);
타이밍까지 딱 맞아 떨어진다면 A 스레드는 먼저 myAccount에 대한 락을 확보한 다음 yourAccount 락을 확보하려 할 것이고, B 스레드는 yourAccount 락을 확보한 다음 myAccount 락을 확보하려 할 것이다.
- 309p
private static final Object tieLock = new Object();
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
class Helper {
public void transfer() throws InsufficientFundsException {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
}
int fromHash = System.identityHashCode(fromAcct);
int toHash = System.identityHashCode(toAcct);
if (fromHash < toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
} else if (fromHash > toHash) {
synchronized (toAcct) {
synchronized (fromAcct) {
new Helper().transfer();
}
}
} else {
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
new Helper().transfer();
}
}
}
}
}
객체에 순서를 부여할 수 있는 방법 중에 하나는 바로 System.identityHashCode를 사용하는 방법인데, identityHashCode 메소드는 해당 객체의 Object.hashCode 메소드를 호출했을 때의 값을 알려준다. 예제 10.3에는 System.identityHashCode 메소드를 사용해 락 순서를 조절하도록 변경한 Transfer 메소드가 나타나 있다. 코드 몇 줄을 추가했을 뿐이지만, 데드락의 위험은 없어진 상태이다.
- 310p
거의 발생하지 않는 일이지만 두 개의 객체가 같은 hashCode 값을 갖고 있는 경우에는 또 다른 방법을 사용해 락 확보 순서를 조절해야 하며, 그렇지 않은 경우에는 역시 데드락이 발생할 가능성이 있다. 이와 같은 경우에 락 순서강 ㅣㄹ정하지 않을 수 있다는 문제점을 제거하려면 세 번째 타이 브레이킹 락을 사용하는 방법이 있다. 이를테면 계좌에 대한 락을 확보하기 전에 먼저 타이 브레이킹 락을 확보하는데, 타이 브레이킹 락을 확보한다는 것은 두 개의 락을 임의의 순서로 확보하는 위험한 작업을 특정 순간에 하나의 스레드에서만 할 수 있도록 막는다는 의미이다. 따라서 데드락이 발생하는 경우가 생기지 않도록 예방할 수 있다. 그런데 hashCode가 동일한 값을 갖는 경우가 자주 발생한다면 타이 브레이킹 락을 확보하는 부분이 일종의 병목으로 작용할 가능성도 있다. 하지만 System.identityHashCode 값이 충돌하는 경우는 거의 없다고 봐도 좋기 때문에 타이 브레이킹 방법을 쓰지 않더라도 최소한의 비용으로 최대의 결과를 얻을 수 있다.
- 310p
Account 클래스 내부에 계좌 번호와 같이 유일하면서 불변이고 비교도 가능한 값을 키로 갖고 있다면 한결 쉬운 방법으로 락 순서를 지정할 수 있다. ACcount 객체를 그 내부의 키를 기준으로 정렬한 다음 정렬한 순서대로 락을 확보한다면 타이 브레이킹 방법을 사용하지 않고도 전체 프로그램을 통털어 계좌를 사용할 때 락이 걸리는 순서를 일정하게 유지할 수 있다.
- 311p
택시를 배차하는 간단한 예제에서 함께 동작하는 객체들을 생각해보자. Taxi는 현재 위치와 이동 중인 목적지를 속성으로 갖는 개별 택시를 의미하는 클래스이고, Dispatcher는 한 무리의 택시를 의미한다.
두 개의 락을 모두 사용해야 하는 메소드는 하나도 없음에도 불구하고 setLocation 메소드와 getlmage 메소드를 호출하는 클래스는 두 개의 락을 사용하는 셈이 된다. 어떤 스레드가 GPS 수신기에서 받은 위치를 값을 setLocation 메소드에 넘기면 먼저 택시의 위치를 새로운 값으로 업데이트하고 원하는 목적지에 도착했는지 확인한다. 목적지에 도착했다면 Dispatcher에게 새로운 목적지를 알려달라고 요청한다. setLocation과 notifyAvailable 메소드 모두 synchronized로 묶여 있기 때문에 setLocation 메소드를 호출하는 클래스는 Taxi에 대한 락을 확보하는 셈이고, 그 다음으로 Dispatcher 락을 확보한다. 이와 비슷하게 getImage 메소드를 호출하는 스레드 역시 Dispatcher 락을 확보해야 하고, 그 다음으로 Taxi 락을 잡아야 한다(한 번에 하나씩). 그러면 LeftRightDeadlock에서 일어났던 일과 동일히게 두 개의 스레드에서 두 개의 락을 서로 다른 순서로 가져가려는 상황, 즉 데드락이 발생한다.
LeftRightDeadlock이나 transferMoney 메소드의 경우에는 메소드 내부에서 두 개의 락을 한꺼번에 사용하는지 확인할 수 있었기 때문에 데드락이 발생할 가능성을 알아보는 일이 비교적 쉬웠다. 하지만 Taxi와 Dispatcher의 경우에는 이전과 달리 데드락이 발생할 수 있는 부분을 찾아내기가 훨씬 어려워졌다. 이럴 때에는 락을 확보한 상태에서 에일리언 메소드를 호출하는지 확인하면 도움이 된다.
class Taxi {
@GuardedBy("this")
private Point location, destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
this.location = location;
if (location.equals(destination))
dispatcher.notifyAvailable(this);
}
}
class Dispatcher {
@GuardedBy("this")
private final Set<Taxi> taxis;
@GuardedBy("this")
private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public synchronized Image getImage() {
Image image = new Image();
for (Taxi t : taxis)
image.drawMarker(t.getLocation());
return image;
}
}
- 313p
Taxi와 Dispatcher 클래스는 오픈 호출을 사용하도록 쉽게 리팩토링할 수 있으며, 그 결과로 데드락의 위험을 막을 수 있다. 리팩토링 작ㅇ업에는 예제와 같이 synchronized 블록의 범위를 최대한 줄여 공유된 상태 변수가 직접 관련된 부분에서만 락을 확보하도록 하는 작업도 포함된다.
- 316p
스레드가 서로 상대방이 이미 확보하고 앞으로 놓지 않을 락을 기다리느라 서로 데드락에 빠질 수 있는 것처럼, 필요한 자원을 사용히기 위해 대기하는 과정에도 데드락이 발생할 수 있다. 예를 들어 풀에 두 개의 데이터베이스에 대한 연결과 같은 자원을 각각의 풀로 확보해 놓았다고 가정해보자. 자원 풀은 풀이 비어 있을 때 풀 내부의 자원을 달라고 요청하는 객쳬가 대기하도록 만들기 위해 일반적으로 세마포어를 사용해 구현하는 경우가 많다. 그런데 특정 작업을 실행하려면 양쪽 데이터베이스에 대한 연결이 모두 필요하고, 양쪽 데이터베이스 연결을 항상 같은 순서로 받아와 사용하지는 않는다고 해보자. 다시 말해, 스레드 A는 데이터베이스 D1에 대한 연결을 확보한 상태에서 데이터베이스 D2에 대한 연결을 확보하고자 하고, 스레드 B는 데이터베이스 D2에 대한 연결을 확보한 상태에서 D1에 대한 연결을 확보하고자 할 수 있다.
자원과 관련해 발생할 수 있는 또 다른 데드락 상힁은 스레드 부족 데드락이다.
단일 스레드로 동작하는 Executor에서 현재 실행 중인 작업이 또 다른 작업을 큐에 쌓고는 그 작업이 끝날 때까지 대기하는 데드락 상황이었다. 이런 경우에는 첫 번째 작업이 영원히 대기할 수밖에 없고, Executor에 등록돼 있던 다른 모든 작업 역시 영원히 대기하게 된다. 다른 작업의 실행 결과를 사용해야만 하는 작업이 있다면 스레드 소모성 데드락의 원인이 되기 쉽다. 크기가 제한된 풀과 다른 작업과 연동돼 동작하는 작업은 잘못 사용하면 이와 같은 문제를 일으킬 수 있다.
- 318 ~ 319p
일단 데드락이 발생했을 때는 JVM이 만들어 내는 스레드 덤프를 활용해 데드락이 발생한 위치를 확인하는 데 도움을 얻을 수 있다. 스레드 덤프에는 실행 중인 모든 스레드의 스택 트레이스가 담겨 있다. 스레드 덤프에는 락과 관련된 정보도 담겨 있는데, 각 스레드마다 어떤 락을 확보하고 있는지, 스택의 어느 부분에서 락을 확보했는지, 그리고 대기 중인 스레드가 어느 락을 확보하려고 대기 중이었는지 등에 대한 정보를 갖고 있다.
- 320p
그밖의 활동성 문제점
소모
소모 starvation 상태는 스레드가 작업을 진행하는 데 꼭 필요한 자원을 영영 할당받지 못하는 경우에 발생한다. 소모 상태를 일으키는 가장 흔한 원인은 바로 CPU이다. 자바 애플리케이션에서 소모 상황이 발생하는 원인은 대부분 스레드의 우선 순위를 적절치 못하게 올리거나 내리는 부분에 있다. 또한 락을 확보한 채로 종료되지 않는 코드를 실행할 때, 다른 스레드에서 해당 락을 가져갈 수 없기 때문에 소모 상황이 발생한다.
- 323p
라이브락
라이브락도 일종의 활동성 문제 가운데 하나인데, 대기 중인 상태가 아니었다 해도 특정 작업의 결과를 받아와야 다음 단계로 넘어갈 수 있는 작업이 실패할 수 밖에 없는 기능을 계속해서 재시도하는 경우에 쉽게 찾아볼 수 있다. 라이브락은 메시지를 제대로 전송하지 못했을 때 해당 전송 트랜잭션을 롤백하고 실패한 메시지를 큐의 맨 뒤에 쌓아두는 트랜잭션 메시지 전송 애플리케이션에서 자주 나타난다. 만약 메시지를 처리하는 핸들러에서 특정 타입의 메시지를 제대로 처리하지 몫하고 실패한 것으로 처리하는 버그가 있다면, 특정 메시지를 큐에서 뽑아 핸들러에 넘겼을 때 핸들러는 같은 결과를 내면서 계속해서 호출될 것이다.
- 325p
성능 대 확장성
병렬 프로그램 환경에서 확장성을 충분히 가질 수 있도록 애플리케이션을 설계하고 튜닝하는 방법은 기존에 해오던 일반적인 성능 최적화 방법과 다른 부분이 많다. 성능을 높이기 위해 튜닝 작업을 하는 경우에 그 목적은 어쨌건 동일한 일을 더 적은 노력으로 하고자 하는 것이다. 예를 들어 이미 계산했던 결과를 캐싱해서 실행 속도를 높이거나, O(n2)의 시간이 걸리는 알고리즘을 O(nlogn) 시간에 처리할 수 있는 알고리즘으로 바꾸는 등의 작업이 바로 성능 튜닝을 의미한다.
확장성을 목표로 튜닝을 한다면 처리해야 할 작업을 병렬화해 시스템의 가용 자원을 더 많이 끌어다 사용하면서 더 많은 일을 처리할 수 있도록 하는 방법을 많이 사용하게 된다. 이처럼 성능이라는 단어에 포함된 ‘얼마나 빠르게’ 와 얼마나 많이’ 라는 두 가지의 의미는 완전히 다른 뜻을 가지며, 심지어 어떤 경우에는 서로 화합할 수 없는 상황도 발생한다. 더 높은 확장성을 확보하거나 하드웨어의 자원을 더 많이 활용하도록 하다 보면, 앞서 큰 작업 하나를 작은 여러 개의 작업으로 분할해 처리하는 것처럼 개별 작업을 처리하는 데 필요한 작업의 양을 늘리는 결괴를 얻을 때가 많다. 우습게도 단일 스레드 애플리케이션에서 사용하던 성능 개선 방안은 대부분 확장성의 측면에서 효과적이지 않다.
- 329p
성능 트레이드 오프 측정
공학적인 모든 선택의 순간에는 항상 트레이드 오프가 존재하기 마련이다.
- 330p
성능을 최적화하는 다수의 경우에 코드의 가독성과 유지보수의 용이함을 비용으로 지불한다. 즉, 좀더 '최적화'되거나 동작하는 모습이 덜 분명한 코드일수록 이해하기가 어렵고 유지보수하기도 어렵다. 일부 최적화 기법을 사용하다 보면, 캡슐화된 구조를 깨야만 하는 것처럼 훌륭한 객체 지향적인 설계 원칙에서 벗어나야만 하는 경우도 있다.
- 331p
성능을 높이기 위해 안전성을 떨어뜨리는 것은 최악의 상황이며, 결국 안전성과 성능 둘 다를 놓치는 결과를 얻을 뿐이다. 특히 병렬 프로그램의 관점에서, 성능 문제가 어디에 존재하며 어떤 방법을 사용해야 성능을 높일 수 있다거나 확장성을 높일 수 있다고 다수의 개발자가 알고 있는 직관적인 지식 가운데 상당수가 올바르지 않다고 봐야 한다. 따라서 성능을 튜닝하는 모든 과정에서 항상 성능 목표에 대한 명확한 요구 사항이 있어야 하며, 그래야 어느 부분을 튜닝하고 어느 시점에서 튜닝을 그만 둬야 하는지 판단할 수 있다. 또한 매우 실제적인 환경에서 실제와 같은 사용자 부하의 특성을 동일하게 나타낼 수 있는 성능 측정 도구가 있어야 한다. 그리고 성능 튜닝 작업을 한 다음에는 반드시 원하는 목표치를 달성했는지 다시 한 번 측정 값을 뽑아내야 한다.
- 332p
추측하지 말고, 실제로 측정해보라.
- 332p
암달의 법칙
대부분의 병렬 프로그램에는 병렬화할 수 있는 작업과 순차적으로 처리해야 하는 작업이 뒤섞인 단위 작업의 덩어리를 갖고 있다. 암달의 법칙을 사용하면 병렬 작업과 순차 작업의 비율에 따라 하드웨어 자원을 추가로 투입했을 때 이론적으로 얼마나 빨라질지에 대한 예측 값을 얻을 수 있다. 암달의 법칙에 따르면, 순차적으로 실행돼야 하는 작업의 비율을 F라고 하고 하드웨어에 꽂혀 있는 프로세서의 개수를 N이라고 할 때, 다음의 수식에 해당하는 정도까지 속도를 증가시킬 수 있다.
N이 무한대까지 증가할수록 속도 증가량은 최고 1/F 까지 증가한다. 1/F라는 속도 증가량은 순차적으로 실행돼야 하는 부분이 전쳬 작업의 50%를 차지한다고 할때 프로세서를 아무리 많이 꽂는다 해도 겨우 두배 빨라진다는 결과이다. 그리고 순차적으로 실행해야 하는 부분이 전쳬의 10%에 해당한다면 최고 1O배까지 속도를 증가시킬 수 있다고 예측할 수 있다. 암달의 법칙을 활용하면 작업을 순차적으로 처리하는 부분이 많아질 때 느려지는 정도가 얼마만큼인지를 수치화할 수 있다. 하드웨어에 Cpu가 10개 꽂혀 있을 때, 10%의 순차 작업을 갖고 있는 프로그램은 최고 5.3배ㅐ 만큼의 속도를 증가시킬 수 있다.
- 333p
스레드와 비용
컨텍스트 스위칭
컨텍스트 스위칭에 필요한 부하와 비용은 플랫폼마다 다르지만, 대략 살펴본 바에 따르면 최근 사용되는 프로세서상에서 5,000~10,000 클럭 사이클 또는 수 마이크로 초 동안의 시간을 소모한다고 알려져 있다.
- 340p
메모리 동기화
메모리 매리어는 캐시를 플러시하거나 무효화하고, 하드웨어와 관련된 쓰기 버퍼를 플러시하고, 실행 파이프라인을 늦출 수도 있다. 메모리 배리어를 사용하면 컴파일러가 제공하는 여러 가지 최적화 기법을 제대로 사용할 수 없게 돼 간접적인 성능 문제를 가져올 수 있다. 메모리 배리어를 사용하면 명령어 재배치를 대부분 할 수 없게 되기 때문이다.
- 340p
동기화가 성능에 미치는 영향을 파악하려면 동기화 작업이 경쟁적인지, 비경쟁적인지 확인해야 한다. synchronized 키워드가 동작하는 방법은 비경쟁적인 경우에 최적화돼 있기 때문에 '빠른 경로fast-path'의 비경쟁적인 동기화 방법은 대부분의 시스템에서 20~250 클럭 사이클을 사용한다고 알려져 있다. 물론 클럭 사이클을 전혀 사용하지 않는 것은 아니지만, 전반적인 애플리케이션 성능의 측면에서 봤을 때 비경쟁적이면서 꼭 필요한 동기화 방법은 성능에 그다지 큰 영향이 없다고 할 수 있겠다.
- 340~341p
블로킹
경쟁하지 않는 상태의 동기화 작업은 전적으로 JVM 내부에서 처리할 수 있다. 하지만 경쟁 조건이 발생할 때에는 동기화 작업에 운영체제가 관여해야 할 수 있는데, 운영체제가 관여하는 부분은 모두 일정량의 자원을 소모한다. 락을 놓고 경쟁하고 있다면, 락을 확보하지 못한 스레드는 항상 대기 상태에 들어가야 한다.
JVM은 스레드를 대기 상태에 둘 때 두 가지 방법을 사용할 수 있는데, 첫 번째 방법은 스핀 대기, 즉 락을 확보할 때까지 계속해서 재시도하는 방법이고, 두 번째 방법은 운영체제가 제공하는 기능을 사용해 스레드를 실제 대기 상태로 두는 방법이다. 두 개의 방법 가운데 어느 쪽이 효율적이냐 하는 문제의 답은 컨텍스트 스위칭에 필요한 자원의 양과, 락을 확보할 때까지 걸리는 시간에 크게 좌우된다. 대기 시간을 놓고 보면, 대기 시간이 짧은 경우에는 스핀 대기 방법이 효과적이고, 대기 시간이 긴 경우에는 운영체제의 기능을 호출하는 편이 효율적이라고 한다. 일부 JVM은 이전에 실행되던 패턴을 분석한 결과를 놓고 두 가지 방법 가운데 좀 더 효괒거인 방법을 선택해 사용하기도 하지만, 대부분의 경우에는 운영체제의 기능을 호출하는 방법을 사용한다.
락을 확보하지 못했거나 I/O 관련 작업을 사용 중이라거나 기타 여러 가지 조건에 걸려 스레드가 대기 상태에 들어갈 때는 두 번의 컨텍스트 스위칭 작업이 일어나며, 이 과정에는 운영체제와 각종 캐시 등의 모듈이 연결돼 있다. 첫 번째 컨텍스트 스위칭은 실행하도록 할당된 시간 이전에 대기 상태에 들어가느라 발생하는 것이고, 두 번째는 락이나 기타 필요한 조건이 충족됐을 때 다실 실행 상태로 돌아오는 컨텍스트 스위칭이다.
- 343p
락 경쟁 줄이기
작업을 순차적으로 처리하면 확장성을 놓치고, 작업을 병렬로 처리하면 컨텍스트 스위칭에서 성능에 악영향을 준다. 그런데 락을 놓고 경쟁하는 상황이 벌어지면 순차적으로 처리함과 동시에 컨텍스트 스위칭도 많이 일어나므로 확장성과 성능을 동시에 떨어뜨리는 원인이 된다. 따라서 락 경쟁을 줄이면 줄일수록 확장성과 성능을 함께 높일 수 있다.
- 343p
락 분할 방법은 때에 따라 독립적인 객체를 여러 가지 크기의 단위로 묶어 내고 묶인 블록을 단위로 락을 나누는 방법을 사용할 수도 있는데, 이런 방법을 락 스트라이핑이라고 한다. 예를 들어 ConcurrentHashMap 클래스가 구현된 소스코드를 보면 16개의 락을 배열로 마련해두고 16개의 락 각자가 전체 해시 범위의 1/16에 대한 락을 담당한다.
- 350p
HashMap 클래스를 구현한다고 하면 맵 내부에 들어 있는 항목의 개수를 세는 size 메소드를 어떻게 구현할 것인지 선택해야 한다. 가장 간단한 방법은 size 메소드를 호출할 때마다 항목의 수를 매번 새로 계산하는 방법이다. 약간 더 최적화된 방법 가운데 흔히 사용하는 방법은 항목의 개수 카운터를 두고 항목이 추가되거나 제거될 때마다 카운트를 증가시키거나 감소시키는 방법이다. 이 방법을 사용하면 항목을 추가하거나 삭제할 때 카운트를 정확한 값으로 맞추느라 약간의 시간이 더 필요하겠지만 size 메소드의 실행 시간을 0(n)에서 0(1)으로 크게 줄일 수 있다.
항목의 개수를 따로 관리하는 방법으로 최적화해 size 메소드나 isErnpty 메소드 등의 처리 속도를 높이면 단일 스레드 애플리케이션이나 완전히 동기화된 애플리케이션에서는 문제없이 잘 동작한다. 하지만 맵 내부의 항목을 변경하는 모든 기능을 호출할 때 공유된 변수인 개수 카운터의 값을 변경해야 하기 때문에 멀티스레드 애플리케이션에서는 확장성을 높이는 일이 굉장히 어려워진다.
해시 맵을 관리하는 부분에 락 분할 방법을 사용한다 해도 카운터 변수에 접근하는 부분을 동기화해야 하므로 전체 맵을 놓고 독점적으로 락을 걸어아만 하는 상황이 생긴다. 결국 성능의 측면에서 최적화라고 생각했던 기법, 즉 맵 내부의 항목을 캐시해두는 방법이 확장성의 발목을 잡는 셈이다. 이와 같이 모든 연산을 수행할 때마다 한 번씩 사용해야 하는 카운터 변수와 같은 부분을 '핫 필드' 라고 부른다.
JDK에 포함된 ConcurrentHashMap 클래스는 전체 카운트를 하나의 변수에 두지 않고, 락으로 분배된 각 부분마다 카운터 변수를 따로 두고 관리하면서 size 메소드를 호출하면 각 카운터 변수의 합을 알려주는 방법을 사용한다. 즉 ConcurrentHashMap 은 모든 항목의 개수를 하나씩 세는 대신 각 락이 담당하는 부분마다 카운터를 두고 있으며, 해당 부분은 락으로 이미 동기화돼 있기 때문에 추가적인 락을 사용할 필요가 없다.
@ThreadSafe
public class StripedMap {
// Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
private static final int N_LOCKS = 16;
private final Node[] buckets;
private final Object[] locks;
private static class Node {
Node next;
Object key;
Object value;
}
public StripedMap(int numBuckets) {
buckets = new Node[numBuckets];
locks = new Object[N_LOCKS];
for (int i = 0; i < N_LOCKS; i++)
locks[i] = new Object();
}
private final int hash(Object key) {
return Math.abs(key.hashCode() % buckets.length);
}
public Object get(Object key) {
int hash = hash(key);
synchronized (locks[hash % N_LOCKS]) {
for (Node m = buckets[hash]; m != null; m = m.next)
if (m.key.equals(key))
return m.value;
}
return null;
}
public void clear() {
for (int i = 0; i < buckets.length; i++) {
synchronized (locks[i % N_LOCKS]) {
buckets[i] = null;
}
}
}
}
- 352p
요약
암달의 법칙에 따르면 애플리케이션의 확장성은 반드시 순차적으로 실행돼야만 하는 코드가 전체에서 얼마만큼의 비율을 차지하느냐에 달렸다고 한다. 자바 프로그램의 내부에서 순차적으로 처리해야만 하는 가장 주요한 부분은 바로 독점적인 락을 사용하는 부분이기 때문에, 락으로 동기화하는 범위를 세분화해 정밀도를 높이거나 락을 확보하는 시간을 최소한으로 줄이는 기법을 사용해 락을 최소한만 사용해야 한다. 그리고 독점적인 락 대신 독점적이지 않은 방법을 사용하거나 대기 상태에 들어가지 않는 방법을 사용하는 것도 중요하다.
- 361p
병렬 프로그램 테스트
클래스가 동작하는 형태가 설계했던 모습 그대로 움직이는지를 확인하는 안전성 테스트는 대부분 변수의 값이 정확한지를 확인하는 것부터 시작한다. 이를테면 갖고 있는 항목의 개수를 독립 변수에 캐시하고 변경 사항이 발생할 때마다 업데이트하도록 만들어진 연결 리스트를 구현하고 있다면, 갖고 있는 항목의 개수와 캐시된 변수의 값이 일치하는지를 확인하는 부분이 가장 기본적인 안전성 테스트라고 볼 수 있다.
단일 스레드 환경에서는 리스트에 들어 있는 항목이 테스트 도중에 변경될 수 없기 때문에 굉장히 쉽게 테스트할 수 있다. 하지만 다수의 스레드가 동작하는 병렬 처리 환경에서는 항목의 개수를 세는 작업과 세우진 개수가 캐시된 변수의 값과 일치하는지를 확인하는 두 가지 작업을 단일 연산으로 수행하지 않는 한 올바르지 않은 결과가 속출할 것이다. 이런 테스트를 병렬 환경에서 올바르게 진행하려면 대상 리스트를 독점적으로 사용할 수 있도록 준비해야 한다. 예를 들어 구현하고 있는 리스트 클래스에서 현재 항목의 목록에 대한 스냅샷을 뽑아주는 기능을 구현하거나, 테스트 프로그램이 값을 제대로 비교하거나 테스트 코드를 안전하게 실행할 수 있도록 '테스트 포인트'를 마련하는 방법도 있다.
- 364p
활동성 문제를 테스트하는 것은 성능 문제를 테스트하는 것과 밀접한 관련이 있다. 알다시피 성능이라는 것은 여러 가지 측면에서 수치화해 측정할 수 있다.
- 처리량(throughput): 병렬로 실행되는 여러 개의 작업이 각자가 할 일을 끝내는 속도
- 응답성(responsiveness): 요청이 들어온 이후 작업을 마치고 결과를 줄 때까지의 시간, 지연 시간이라고도 한다.
- 확장성(scalability): 자원을 더 많이 확보할 때마다 그에 따라 처리할 수 있는 작업량이 늘어나는 정도
- 364p
정확성 테스트
정확성 테스트에 대해 확실하게 이해할 수 있는 예제로 크기가 제한된 버퍼 클래스에 대한 테스트 케이스를 구현해 보자. 먼저 예제 12.1을 보면 테스트할 대상이 될 BoundedEuffer 클래스의 소스코드가 나타나 있다. 예제 12.1의 BoundedEuffer 클래스는 Semaphore를 사용해 크기를 제한하고 제한된 크기를 초과한 경우에 대기 상태에 들어가도록 하고 있다.
BoundedBuffer 클래스의 내부를 보면 배열을 기반으로 하는 큐의 형태로 구현돼 있고, 대기 상태에 들어갈 수 있는 put 메소드와 take 메소드를 갖고 있다. put과 take 메소드는 개수가 지정된 세마포어를 사용해 동기화를 맞추고 있다. availableltems라는 세마포어를 보면 현재 버퍼 내부에서 뽑아낼 수 있는 항목의 개수를 담고 있으며, 물론 버퍼를 생성한 최초 시점에는 0이라는 값을 갖는다(최초에는 버퍼가 비어 있을테니 당연하다). 이와 비슷하게 availableSpaces는 버퍼에 추가할 수 있는 항목의 개수가 몇 개인지를 담고 있고, 그 값은 버퍼가 생성되는 최초 시점에 버퍼의 크기에 맞춰져 있다.
take 메소드는 먼저 availablelItems 세마포어에서 가져갈 항목이 있는지에 대한 확인을 받아야 한다. 만약 버퍼에 항목이 하나 이상 들어있었다면 즉시 확인에 성공하고, 버퍼가 비어 있었다면 버퍼에 항목이 추가될 때까지 대기 상태에 들어간다. availableItems에서 확인을 받고 나면 버퍼의 다음 항목을 뽑아내고 availableSpaces의 값이 늘어나게 된다. put 메소드는 take 메소드와는 반대로 구성돼 있으며, 결국put 메소드나 take 메소드를 호출한 이후 리턴된 이후에는 항상 양쪽 세마포어가 갖고 있는 값의 합이 항상 버퍼의 크기와 일치한다.
@ThreadSafe
public class SemaphoreBoundedBuffer <E> {
private final Semaphore availableItems, availableSpaces;
@GuardedBy("this") private final E[] items;
@GuardedBy("this") private int putPosition = 0, takePosition = 0;
public SemaphoreBoundedBuffer(int capacity) {
if (capacity <= 0)
throw new IllegalArgumentException();
availableItems = new Semaphore(0);
availableSpaces = new Semaphore(capacity);
items = (E[]) new Object[capacity];
}
public boolean isEmpty() {
return availableItems.availablePermits() == 0;
}
public boolean isFull() {
return availableSpaces.availablePermits() == 0;
}
public void put(E x) throws InterruptedException {
availableSpaces.acquire();
doInsert(x);
availableItems.release();
}
public E take() throws InterruptedException {
availableItems.acquire();
E item = doExtract();
availableSpaces.release();
return item;
}
private synchronized void doInsert(E x) {
int i = putPosition;
items[i] = x;
putPosition = (++i == items.length) ? 0 : i;
}
private synchronized E doExtract() {
int i = takePosition;
E x = items[i];
items[i] = null;
takePosition = (++i == items.length) ? 0 : i;
return x;
}
}
- 365p
예제 12.3의 코드를 보면 대기 상태에 들어가는 메소드를 테스트하는 방법의 예를 볼 수 있다. 예제 12.3의 코드를 보면 먼저 비어 있는 버퍼의 take 메소드를 호출하는 taker 스레드를 생성하도록 돼 있다. taker 스레드가 호출한 take 메소드가 리턴된다면 taker 스레드는 오류가 발생했다는 사실을 기록해둔다. 테스트 프로그램을 실행하면 먼저 taker 스레드를 실행하고 적당량 이상 오래 기다려보고, 그 다음에는 taker 스레드에 인터럽트를 건다. 만약 taker 스레드가 take 메소드에서 정상적으로 대기 상태에 들어가 있었다면 인터럽트가 걸렸을 때 InterruptedException을 띄울 것이고, InterruptedException을 받은 catch 구문에서는 예외가 발생한 상황이 정상이라고 판단하고 스레드를 그대로 종료시킨다. 그러면 taker 스레드를 실행시켰던 테스트 프로그램은 taker 스레드가 종료될 때까지 join 메소드로 기다리고, Thread.isAlive 메소드를 사용해 join 메소드가 정상적으로 종료됐는지를 확인한다. 다시 말해, taker 스레드가 정상적으로 인터럽트에 응답했다면 join 메소드가 즉시 종료돼야 맞다.
일반적인 join 메소드 대신 타임아웃을 지정하는 join 메소드를 사용하면 take 메소드가 예상치 못한 상황에 걸려 응답하지 않는 경우에도 테스트 프로그램을 제대로 종료시킬 수 있다.
void testTakeBlocksWhenEmpty() {
final BoundedBuffer<Integer> bb = new BoundedBuffer<Integer>(10);
Thread taker = new Thread() {
public void run() {
try {
int unused = bb.take();
fail(); // if we get here, it's an error
} catch (InterruptedException success) { }
}};
try {
taker.start();
Thread.sleep(LOCKUP_DETECT_TIMEOUT);
taker.interrupt();
taker.join(LOCKUP_DETECT_TIMEOUT);
assertFalse(taker.isAlive());
} catch (Exception unexpected) {
fail();
}
- 369~371p
안전성 테스트
병렬 실행 환경에서 발생하는 오류를 확인하는 프로그램을 작성하다 보면 닭이 먼저냐 달걀이 먼저냐하는 문제에 걸리게 된다. 즉 테스트 프로그램 자체가 병렬 프로그램이 돼야 하기 때문이다. 심지어는 올바른 테스트 프로그램을 작성하는 일이 테스트 대상 클래스 자체를 구현하는 일보다 훨씬 어려운 경우도 있다.
- 371p
프로듀서-컨슈머 디자인 패턴을 사용해 동작하는 클래스에 가장 적합한 방법은 바로 큐나 버퍼에 추가된 항목을 모두 그대로 뽑아 낼 수 있는지 확인하고, 그 외에는 아무런 일도 하지 않는지 확인하는 방법이다. 이런 방법을 아무런 생각 없이 구현하려면 테스트 대상과 함께 똑같은 내용을 담을 제2의 리스트를 마련해 두고, 큐나 버퍼에 항목이 추가될 때 제2의 리스트에도 같은 항목을 추가한다. 그리고 큐나 버퍼에서 항목을 제거할 때 제2의 리스트에서도 항목을 제거하고, 큐나 버퍼에서 항목을 모두 제거했을 때 제 2의 리스트도 역시 깔끔하게 비어 있는지를 확인할 수 있겠다. 하지만 이런 방법은 제2의 리스트에 항목을 추가하고 제거하는 과정에서 스레드 동기화 작업이 필요하기 때문에 테스트 스레드의 스케줄링 부분이 약간 꼬여버릴 가능성이 있다.
- 371~372p
또 다른 방법을 보면 큐에 들어가고 나오는 항목의 체크섬을 구한 다음 순서를 유지하는 체크섬의 형태로 관리하고, 쌓인 체크섬을 비교해 확인하는 방법이 있다. 만약 체크섬을 비교해 양쪽이 동일하다면 테스트를 통과한다. 이 방법은 버퍼에 집어 넣을 항목을 생성하는 프로듀서가 하나만 동작하고 하나의 컨슈머가 버퍼의 내용을 가져다 사용하는 구조에서 가장 효과가 큰 테스트 방법이다. 올바른 항목을 뽑아내는지 테스트하는 것과 더불어 올바른 순서로 항목을 가져올 수 있는지도 테스트할 수 있기 때문이다.
- 372p
너무 똑똑한 컴파일러 때문에 발생하는 문제를 해결하려면 테스트에 사용할 데이터를 일련번호 대신 임의의 숫자를 생성해 사용해야 한다. 하지만 너무 허술한 난수 발생기를 사용하면 이 또한 테스트 결과가 잘못 나올 수도 있다. 허술한 난수 발생기는 현재 시간과 클래스 간에 종속성이 있는 난수를 생성하는 경우가 있는데, 대부분의 난수 발생기가 스레드 안전성을 확보한 상태이고 추가적인 동기화 작업이 필요하기 때문이다. 각 테스트 스레드마다 독립적인 난수 발생기 인스턴스를 사용하도록 하려면 스레드 안전성 때문에 동기화하느라 성능의 병목을 야기하는 경우를 막을 수 있다.
- 372p
이와 같은 문제를 해결하는 방법은 이미 5.5.1절에서 소개한 바 있는데, 바로 CountDownLatch를 사용해 모든 스레드가 준비될 때까지 대기하고, 또 다른 CountDownLatch를 사용해 모든 스레드가 완료할 때까지 대기하는 방법이었다. CyclicBarrier를 사용해도 이와 같은 효과를 낼 수 있는데, 전체 작업 스레드의 개수에 1을 더한 크기로 초기화해두고 작업 스레드와 테스트 프로그램이 시작하는 시점에 모두 동시에 시작할 수 있도록 대기하고, 끝나는 시점에서도 한꺼번에 끝내도록 대기하는 방법이다. 이런 류의 방법을 사용하면 모든 스레드가 생성돼 실제 작업을 시작할 준비가 끝나기 전에는 누구도 작업을 시작하지 않는다.
- 374p
자원 관리 테스트
메모리를 원하지 않음에도 계속해서 잡고 있는 경우가 있는지 확인하려면 애플리케이션이 사용하는 메모리의 상황을 들여다 볼 수 있는 힙 조사용 도구를 사용해볼 만하다.
- 378p
예제의 testLeak 메소는 힙 조사 도구가 코드를 넣을 수 있는 위치를 준비하고 있다. 해당 위치에는 힙 조사 도구가 생성한 코드가 들어가는데, 힙조사 도구가 추가한 코드는 가비지 컬렉션을 강제로 실행하고 힙 사용량과 기타 메모리 사용 현황을 불러오는 기능을 담당한다.
testLeak 메소드는 크기가 제한된 버퍼에 상당한 메모리를 차지하는 객체를 여러 개 추가하고, 추가된 객체를 제거한다. 그러면 버퍼에는 아무런 내용이 없기 때문에 2번 자리에서 측정한 메모리 사용량이 1번 위치에서 측정한 메모리 사용량과 비교할 때 거의 차이가 없어야 한다. 그런데 예를 들어 doExtract 메소드에서 뽑혀 나간 객체를 담고 있던 참조를 null로 세팅하지 않았다면 양쪽 지점에서 측정한 메모리 사용량이 분명히 다를 것이다.
class Big { double[] data = new double[100000]; }
void testLeak() throws InterruptedException {
BoundedBuffer<Big> bb = new BoundedBuffer<Big>(CAPACITY);
int heapSize1 = /* snapshot heap */ ;
for (int i = 0; i < CAPACITY; i++)
bb.put(new Big());
for (int i = 0; i < CAPACITY; i++)
bb.take();
int heapSize2 = /* snapshot heap */ ;
assertTrue(Math.abs(heapSize1-heapSize2) < THRESHOLD);
}
- 378~379p
성능 측정의 함정 피하기
가비지 컬렉션
가비지 컬렉션 때문에 테스트 결과가 올바르지 않게 나오는 경우를 막을 수 있는 두 가지 방법을 생각해보자. 먼저 테스트가 진행되는 동안 가비지 컬렉션 작업이 실행되지 않도록 하는 방법이 있을 수 있겠고, 아니면 테스트가 진행되는 동안 가비지 컬렉션이 여러 번 실행된다는 사실을 명확히 하고 테스트 결과에 객체 생성 부분이나 가비지 컬렉션 부분을 적절하게 반영하도록 하는 방법이 있겠다. 일반적으로는 후자의 방법이 많이 사용되는데, 테스트 프로그램을 훨씬 긴 시간동안 실행할 수 있으며 실제 상황에서 나타나는 성능을 좀더 가깝게 반영하기 때문이다.
- 390p
동적 컴파일
컴파일 작업이 언제 실행되는지는 알 수 없다.
- 391p
컴파일된 코드와 컴파일되지 않은 코드 때문에 성능 측정치가 올바르지 않게 나타나는 상황을 예방하는 가장 간단한 방법은 테스트 프로그램을 긴 시간 동안 실행시켜 컴파일될 부분은 모두 컴팡리 되고, 추가로 컴파일하거나 인터프리터로 실행되는 코드를 최소화하는 방법이다.
- 392p
비현실적인 코드 경로 샘플링
특정 애플리케이션에서 사용하는 시나리오 패턴만을 묘사해 테스트하는 것보다는 그와 유사한 다른 시나리오 패턴도 한데 묶어서 테스트하는 일도 중요한 부분이다.
- 393p
비현실적인 경쟁 수준
병렬 테스트 프로그램에서 실제 상황과 유사한 결과를 얻으려면 직접적으로 알고자 하는 부분, 즉 병렬 처리 작업을 조율하는 동기화 부분의 성능과 함께 스레드 내부에서 실행되는 작업의 형태도 실제 애플리케이션과 비슷한 특성을 띠고 있어야 한다.
- 394p
4부 고급 주제
명시적인 락
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException;
void unlock();
Condition newCondition();
}
- 405p
폴링과 시간 제한이 있는 락 확보 방법
tryLock 메소드가 지원하는 폴링 락 확보 방법이나 시간 제한이 있는 락 확보 방법은 오류가 발생했을 때 무조건적으로 락을 확보하는 방법보다 오류를 잡아내기에 훨씬 깔끔한 방법이라고 볼 수 있다. 암묵적인 락을 사용할 때에는 데드락이 발생하면 프로그램이 멈춰버리는 치명적인 상황에 이른다. 멈춘 프로그램을 다시 동작시키는 방법은 종료하고 다시 실행하는 방법뿐이고, 프로그램이 멈추지 않도록 하려면 올바르지 않은 락 순서를 제대로 맞춰 데드락이 발생하지 않도록 하는 수밖에 없다. 그런데 락을 확보할 때 시간 제한을 두거나 폴링을 하도록 하면 다른 방법, 즉 확률적으로 데드락을 회피할 수 있는 방법을 사용할 수 있다.
락을 확보할 때 시간 제한을 두거나 폴링 방법을 사용하면 락을 확보하지 못하는 상황에도 통제권을 다시 얻을 수 있으며, 그러면 미리 확보하고 있던 락을 해제하는 등의 작업을 처리한 이후 락을 다시 확보하도록 재시도할 수 있다.
- 407~408p
공정성
스레드 간의 경쟁이 심하게 나타나는 상황에서 락을 공정하게 관리하는 것보다 불공정하게 관리하는 방법의 성능이 훨씬 빠른 이유는 대기 상태에 있던 스레드가 다시 실행 상태로 돌아가고 또한 실제로 실행되기까지는 상당한 시간이 걸리기 때문이다. 예를 들어 스레드 A가 락을 확보하고 있는 상태에서 스레드 B가 락을 확보하고자 한다고 해보자. 락은 현재 누군가가 사용하고 있기 때문에 스레드 B는 일단 대기 상태에 들어간다. 그리고 스레드 A가 락을 해제하면 스레드 B가 대기 상태에서 풀리면서 다시 락을 확보하고자 요청한다. 그러는 동안 스레드 C가 끼어들면서 동일한 락을 확보 하고자 한다면 스레드 B 대신 스레드 C가 락을 미리 확보해버릴 확률이 꽤 높고, 더군다나 스레드 B가 본격적으로 실행되기 전에 스레드 C는 이미 실행을 마치고 락을 다시 해제시키는 경우도 가능하다. 이런 경우라면 모든 스레드가 원하는 성능을 충분히 발휘하면서 실행된 셈이다. 스레드 B는 사용할 수 있는 시점에 락을 확보할 수 있고, 스레드 C는 이보다 먼저 락을 사용할 수 있으니 처리량은 크게 늘어난다.
- 415p
공정한 방법으로 락을 관리할 때에는 락을 확보하고 사용하는 시간이 상대적으로 길거나 락 요청이 발생하는 시간 간격이 긴 견우에 유리하다. 락 사용 시간이 길거나 요청 간의 시간 간격이 길면 순서 뛰어넘기 방법으로 성능상의 이득을 얻을 수 있는 상태, 즉 락이 해제돼 있는 상태에서 다른 스레드가 해당 락을 확보하고자 대기 상태에서 깨어나고 있는 상태가 상대적으로 훨씬 덜 발생하기 때문이다.
- 415p
synchronized 또는 ReentrantLock 선택
ReentrantLock은 락 능력이나 메모리 측면에서 synchronized 블록과 동일한 형태로 동작하면서도 락을 확보할 때 타임아웃을 지정하거나 대기 상태에서 인터럽트에 잘 반응하고 공정성 여부를 지정할 수도 있으며 블록의 구조를 갖추고 있지 않은 경우에도 락을 적용할 수 있는 유연함을 갖고 있다. ReentrantLock을 사용했을 때의 성능이 synchronized를 사용했을 때보다 낫다고 판단되는데, 자바 5.0에서는 아주 큰 차이로 성능이 앞섰지만 자바 6에서는 그다지 큰 차이가 있지는 않았다. 그렇다면 synchronized를 더 이상 사용하지 말고 모든 코드에서 ReentrantLock을 사용하도록 권장해야 하지 않을까?
- 416p
암묵적인 락은 여전히 명시적인 락에 비해서 상당한 장점을 갖고 있다. 코드에 나타나는 표현 방법도 훨씬 익숙하면서 간결하고, 현재 만들어져 잇는 대다수의 프로그램이 암묵적인 락을 사용하고 있으니 암묵적인 락과 명시적인 락을 섞어 쓴다고 하면 코드를 읽을 때 굉장히 혼동될 뿐만 아니라 오류가 발생할 가능성도 더 높아진다. 분명히 ReentrantLock은 암묵적인 락에 비해 더 위험할 수도 있다. 만약 finally 블록에 unlock 메소드를 넣어 락을 해제하도록 하지 않는다면 일단 프로그램이 제대로 동작하는 듯싶다가도 어디에선가 언젠가 분명히 터지고야 말 시한 폭탄을 심어두는 셈이다. ReentrantLock은 synchronized 블록에서 제공하지 않는 특별한 기능이 꼭 필요할 때만 사용하는 편이 안전하다고 본다.
- 416p
ReentrantLock은 암묵적인 락만으로는 해결할 수 없는 복잡한 상황에서 사용하기 위한 고급 동기화 기능이다. 다음과 같은 고급 동기화 기법을 사용해야 하는 경우에만 ReentrantLock은를 사용하도록 하자. 1) 락을 확보할 때 타임아웃을 지정해야 하는 경우, 2) 폴링의 형태로 락을 확보하고자 하는 경우, 3) 락을 확보하느라 대기 상태에 들어가 있을 때 인터럽트를 걸 수 있어야 하는 경우, 4) 대기 상태 큐 처리 방법을 공정하게 해야 하는 경우, 5) 코드가 단일 블록의 형태를 넘어서는 경우. 그 외의 경우에는 synchronized 블록을 사용하도록 하자.
- 416p
자바 5.0에서는 synchronized 블록이 ReentrantLock에 비해 갖고 있는 장점이 하나 더 있다. 스레드 덤프를 떠보면 어느 스레드의 어느 메소드에서 어느 락을 확보하고 있고, 데드락에 걸린 스레드가 있는지, 어디에서 데드락에 걸렸는지도 표시해준다. 반면에 JVM 입장에서는 ReentrantLock이 어느 스레드에서 사용됐는지를 알 수 없기 때문에 동기화 관련 문제가 발생했을 때 JVM을 통해서 문제를 해결하는 데 도움이 될 정보를 얻기가 어렵다. 자바 6에서는 ReentrantLock의 이런 장점이 해소됐는데, 락이 등록할 수 있는 관리 및 모니터링 인터페이스가 추가됐다. 락을 관리 및 모니터링 인터페이스에 등록되고 나면 스레드 덤프에서 ReentrantLock의 상황을 알 수 있을 뿐만 아니라 외부의 관리나 디버깅 인터페이스를 통해 락의 움직임을 확인할 수도 있다.
이와 같은 정보를 디버깅에 활용할 수 있었다는 건 synchronized가 잠깐 동안이라도 가졌던 약간의 장점이긴 했다. 스레드 덤프에 출력되는 락 관련 정보가 없었더라면 많은 개발자가 오류를 찾지 못해 막막해 한숨만 쉬는 경우가 많았을 것이다. 암묵적인 락을 사용할 때는 항상 특정 스택 프레임에 락이 연관돼 있었지만, ReentrantLock은 블록을 벗어나는 범위에도 사용할 수 있으며 따라서 특정 스택 프레임에 연결되지 않는다.
이제 좀더 성능이 최적화되면 synchronized를 사용해도 ReentrantLock보다 성능이 더 나아지지 않을까 기대해본다.
특히 synchronized 구문은 JVM 내부에 내장돼 있기 때문에 ReentrantLock에 비해서 여러 가지 최적화를 적용하기가 쉽다. 예를 들어 스레드에 한정된 락 객체를 대상으로는 락 생략 기법을 적용할 수 있겠고, 락 확장 기법을 적용해 암묵적인 락으로 동기화된 부분에서 락을 사용하지 않도록 할 수도 있다. 자바 라이브러리에 포함된 클래스를 상 대로 이런 최적화 기법을 적용한다는 것은 실현 가능성이 떨어진다.
머지 않은 시점에 자바 5.0으로 넘어갈 예정이거나 자바 5.0에서 ReentrantLock이 제공하는 확장성을 꼭 사용해야만 하는 경우가 아니라면, 다시 말해 단순히 성능이 나아지기를 기대하면 서 synchronized 대신 ReentrantLock을 사용하는 일은 그다지 좋은 선택이 아니다.
- 417p
읽기-쓰기 락
대부분의 경우 사용하는 데이터 구조는 읽기 작업이 많이 일어난다. 즉 데이터 내용은 변경될 수 있으며 간혹 변경되기도 하지만 대다수의 작업은 데이터 변경이 아닌 데이터 읽기 작업이다. 이런 상황에서는 락의 조건을 좀 풀어서 읽기 연산은 여러 스레드에서 동시에 실행할 수 있도록 해주면 성능을 크게 높일 수 있지 않을까? 해당 데이터 구조를 사용하는 모든 스레드가 가장 최신의 값을 사용하도록 보장해주고, 데이터를 읽거나 보고 있는 상태에 서는 다른 스레드가 변경하지 못하도록 하면 아무런 문제가 없겠다.
이런 작업, 즉 읽기 작업은 여러 개를 한꺼번에 처리할 수 있지만 쓰기 작업은 혼자만 동작할 수 있는 구조의 동기화를 처리해주는 락이 바로 읽기-쓰기 락read-write lock이다.
예제 13.6에 소개돼 있는 ReadwriteLock을 보면 두 개의 Lock 객체를 볼 수 있 다. 하나는 읽기 작업용 락이고 다른 하나는 쓰기 작업용 락이다. ReadWriteLock으로 동기화된 데이터를 읽어가려면 읽기 락을 확보해야 하고, 해당 데이터를 변경하고자 한다면 쓰기 락을 확보해야 한다. 메소드 모양만 본다면 두 개의 락 객쳬가 있는 게 아 닌가 싶기도 하지만, 실제로 내부적으로는 하나의 ReadWriteLock 객쳬가 사용된다.
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
ReadWriteLock에서 구현하고 있는 동기화 정책은 이미 소개한 것처럼 여러 개의 읽기 작업은 동시에 처리할 수 있지만, 쓰기 작업은 한 번에 단 하나만 동작할 수 있다. Lock 인터페이스와 같이 ReadWriteLock 역시 성능이나 스케줄링 특성, 락 확보 방법 의 특성, 공정성 문제, 기타 다른 락 관련 의미가 서로 다르게 반영되도록 새로운 클래스를 구현할 수 있게 돼 있다. ReadWriteLock은 특정 상황에서 병렬 프로그램의 성능을 크게 높일 수 있도록 최적화된 형태로 설계된 락이다. 실제로 멀티 CPU 시스템에서 읽기 작업을 많이 사용하는 데이터 구조에 ReadWriteLock을 사용하면 성능을 크게 높일 수 있다. 하지만 ReadWriteLock은 구현상의 복잡도가 약간 높기 때문에 최적화된 상황이 아닌 곳에 적용하면 상호 배제시키는 일반적인 락에 비해서 성능이 약간 떨어지기도 한다.
- 418~419p
public class ReadWriteMap <K,V> {
private final Map<K, V> map;
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock r = lock.readLock();
private final Lock w = lock.writeLock();
public ReadWriteMap(Map<K, V> map) {
this.map = map;
}
public V put(K key, V value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
public V remove(Object key) {
w.lock();
try {
return map.remove(key);
} finally {
w.unlock();
}
}
public void putAll(Map<? extends K, ? extends V> m) {
w.lock();
try {
map.putAll(m);
} finally {
w.unlock();
}
}
public void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
public V get(Object key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
public int size() {
r.lock();
try {
return map.size();
} finally {
r.unlock();
}
}
public boolean isEmpty() {
r.lock();
try {
return map.isEmpty();
} finally {
r.unlock();
}
}
public boolean containsKey(Object key) {
r.lock();
try {
return map.containsKey(key);
} finally {
r.unlock();
}
}
public boolean containsValue(Object value) {
r.lock();
try {
return map.containsValue(value);
} finally {
r.unlock();
}
}
}
- 421p
AbstractQueuedSynchronizer
AQS 기반의 동기화 클래스가 담당하는 작업 가운데 가장 기본이 되는 연산은 바로 확보acquire와 해제release이다. 확보 연산은 상태 기반으로 동작하며 항상 대기 상태에 들어갈 가능성이 있다. 락이나 세마포어 등의 입장에서는 확보라는 연산은 락이나 퍼밋을 확보한다는 것으로 그 의미가 굉장히 명확하다. 이 연산을 사용하는 호출자는 항상 원하는 상태에 다다를 때까지 대기할 수 있다는 가능성을 염두에 둬야 한다. CountDownLatch 클래스를 놓고 보면 확보라는 연산은 "래치가 완료 상태에 다다를 때까지 대기하라"는 의미이다.
그리고 FutureTask 클래스에서는 확보가 "작업이 끝날 때까지 대기하라"는 뜻이다. 해제 연산은 대기 상태에 들어가지 않으며, 대신 확보 연산에서 대기 중인 스레드를 풀어주는 역할을 한다.
- 450~451p
특정 클래스가 상태 기반으로 동작하라면 반드시 상태 변수를 하나 이상 갖고 있어야 한다. AQS는 동기화 클래스의 상태 변수를 관리하는 작업도 어느 정도 담당하는데 getState, setState, compareAndSetState 등의 메소드를 통해 단일 Int 변수 기반의 상태 정보를 관리해준다. 이 기능만 사용해도 다양한 상태를 간단하게 표현할 수 있다. 예를 들어 ReentrantLock 클래스는 이 상태를 사용해 소속된 스레드에서 락을 몇 번이나 확보했었는지를 관리하고, Semaphore 클래스는 남아 있는 퍼밋의 개수를 관리하고, FutureTask 클래스는 작업의 실행 상태를 관리한다. 동기화 클래스는 int 상태 변수 말고도 각자 필요한 상태 변수를 추가해 관리한다. 예를 들어 ReentrantLock 클래스는 락을 다시 확보하려는 것인지(reentrant) 아니면 서로 다른 스레드가 경쟁하고 있는 상태인지(contended)를 확인할 수 있도록 현재 락을 확보하고 있는 스레드의 목록을 관리한다.
- 451p
AQS에서 확보와 해제 연산이 동작하는 구조
boolean acquire() throws InterruptedException {
while (확보 연산을 처리할 수 없는 상태이다) {
if (확보 연산을 처리할 때까지 대기하길 원한다) {
현재 스레드가 큐에 들어 있지 않다면 스레드를 큐에 넣는다
대기 상태에 들어간다
}
else
return 실패
}
상황에 따라 동기화 상태 업데이트
스레드가 큐에 들어 있었다면 큐에서 제거한다
return 성공
}
void release() {
동기화 상태 업데이트
if (업데이트된 상태에서 대기 중인 스레드를 풀어줄 수 있다)
큐에 쌓여 있는 하나 이상의 스레드를 풀어준다
}
- 452p
단일 연산 변수와 넌블로킹 동기화
락의 단점
락 확보 경쟁이 벌어지는 상황에서는 JVM 역시 운영체제의 도움을 받는다. 이런 경우 락을 확보하지 못한 스레드는 실행을 멈춰야 하며 나중에 조건이 충족되면 다시 실행싴녀야 한다. 실행을 잠시 멈추고 있던 스레드가 다시 실행하게 됐다 해도 실제 CPU를 할당 받기 전에 이미 CPU를 사용하고 있는 다른 스레드가 CPU 할당 받기 전에 이미 CPU를 사용하고 있는 다른 스레드가 CPU 할당량을 모두 사용하고 CPU 스케줄을 넘겨줄 때까지 대기해야 할 수도 있다. 이와 같이 스레드의 실행을 중단 했다가 계속해서 실행하는 작업은 상당한 부하를 발생시키며 일반적으로 적지 않은 시간 동안 작업이 중단되게 된다. 락을 기반으로 세밀한 작업을 주로 하도록 구현돼 있는 클래스는 락에 대한 경쟁이 심해질수록 실제로 필요한 작업을 처리하는 시간 대비 동기화 작업에 필요한 시간의 비율이 상당한 수치로 높아질 가능성이 있다.
- 462p
volatile 변수는 락과 비교해 봤을 때 컨텍스트 스위칭이나 스레드 스케줄링과 아무런 연관이 없기 때문에 락보다 훨씬 가벼운 동기화 방법이라고 볼 수 있다. 반면 volatile 변수는 락과 비교할 때 가시성 측면에서는 비슷한 수준을 보장하긴 하지만, 복합 연산을 하나의 단일 연산으로 처리할 수 있게 해주는 기능은 전혀 갖고 있지 않다. 따라서 하나의 변수가 다른 변수와 관련된 상태로 사용해야 하거나, 하나의 변수라도 해당 변수의 새로운 값이 이전 값과 관련이 있다면 volatile 변수를 사용할 수 없다. 이런 특성 때문에 volatile 변수로는 카운터나 뮤텍스를 구현할 수 없으며, 따라서 전체적으로 volatile 변수를 사용할 수 있는 부분이 상당히 제한된다.
- 463p
병렬 연산을 위한 하드웨어적인 지원
멀티프로세서 연산을 염두에 두고 만들어진 프로세서는 공유된 변수를 놓고 동시에 여러 작업을 해야 하는 상황을 간단하게 관리할 수 있도록 특별한 명령어를 제공한다. 초기의 프로세서는 확인하고 값 설정test-and-set, 값을 읽어와서 증가fetch-and-increment, 치환swap 등의 단일 연산을 하드웨어적으로 제공했으며, 이런 연산을 기반으로 더 복잡한 병렬 클래스를 쉽게 만드는 데 도움이 되는 뮤텍스를 충분히 구현할 수 있었다. 최근에는 거의 모든 프로세서에서 읽고-변경하고-쓰는 단일 연산을 하드웨어적으로 제공하고 있다. 예를 들어 비교하고 치환compare-and-swap, LL(load-linked), SC(store-conditional) 등의 연산 등이 있다.
- 464p
비교 후 치환
IA32나 Sparc과 같은 프로세서에서 채택하고 있는 방법은 비교 후 치환(CAS, compare-and-swap) 명령을 제공하는 방법이다. CAS 연산에는 3개의 인자를 넘겨주는데, 작업할 대상 메모리의 위치인 V, 예상하는 기존 값인 A, 새로 설정할 값인 B의 3개이다. CAS 연산은 V 위치에 있는 값이 A와 같은 경우에 B로 변경하는 단일 연산이다. 만약 이전 값이 A와 달랐다면 아무런 동작도 하지 않는다. 그리고 값을 B로 변경했건 못했건 간에 어떤 경우라도 현재 V의 값을 리턴한다. 즉 CAS 연산의 동작하는 모습을 말로 풀어보면, "V에 들어 있는 값이 A라고 생각되며, 만약 실제로 V의 값이 A라면 B라는 값으로 바꿔 넣어라. 만약 V의 값이 A가 아니라면 아무 작업도 하지 말고, V의 값이 뭔지를 알려달라"는 것이다. 앞서 소개한 것처럼 CAS 연산은 낙관적인 기법이다. 다시 말해 일단 성공적으로 치환할 수 있을 것이라고 희망하는 상태에서 연산을 실행해보고, 값을 마지막으로 확인한 이후에 다른 스레드가 해당하는 값을 변경했다면 그런 사실이 있는지를 확인이나 하자는 의미이다. 예제 15.1의 simultedCAS 클래스를 보면 CAS가 동작하는 내부 구조를 볼 수 있다.
만약 여러 스레드가 동시에 CAS 연산을 사용해 한 변수의 값을 변경하려고 한다면, 스레드 가운데 하나만 성공적으로 값을 변경할 것이고, 다른 나머지 스레드는 모두 실패한다. 대신 값을 변경하지 못했다고 해서 락을 확보하는 것처럼 대기 상태에 들어가는 대신, 이번에는 값을 변경하지 못했지만 다시 시도할 수 있다고 통보를 받는 셈이다. CAS 연산에 실패한 스레드도 대기 상태에 들어가지 않기 때문에 스레드마다 CAS 연산을 다시 시도할 것인지, 아니면 다른 방법을 취할 것인지, 아니면 아예 아무 조치도 취하지 않을 것인지를 결정할 수 있다. 이와 같은 CAS 연산의 유연성 때문에 락을 사용하면서 발생할 수밖에 없었던 여러 가지 활동성 문제를 미연에 방지할 수 있다.
CAS를 활용하는 일반적인 방법은 먼저 V에 들어 있는 값 A를 읽어내고, A 값을 바탕으로 새로운 값 B를 만들어 내고, CAS 연산을 사용해 V에 들어 있는 A 값을 B값으로 변경하도록 시도한다. 그러면 다른 스레드에서 그 사이에 V의 값을 A가 아닌 다른 값으로 변경하지 않는 한 CAS 연산이 성공하게 된다. 이처럼 CAS 연산을 사용하면 다른 스레드와 간섭이 발생했는지를 확인할 수 있기 때문엘 ㅏㄱ을 사용하지 않으면서도 읽고-변경하고-쓰는 연산을 단일 연산으로 구현해야 한다는 문제를 간단하게 해결해준다.
@ThreadSafe
public class SimulatedCAS {
@GuardedBy("this") private int value;
public synchronized int get() {
return value;
}
public synchronized int compareAndSwap(int expectedValue, int newValue) {
int oldValue = value;
if (oldValue == expectedValue)
value = newValue;
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue, int newValue) {
return (expectedValue == compareAndSwap(expectedValue, newValue));
}
}
- 465p
단일 연산 변수 클래스
단일 연산 변수atomic variable는 락보다 훨씬 가벼우면서 세밀한 구조를 갖고 있으며, 멀티프로세서시스템에서 고성능의 병렬 프로그램을 작성하고자 할 때 핵심적인 역할을 한다. 단일 연산 변수를 사용하면 스레드가 경쟁하는 범위를 하나의 변수로 좁혀주는 효과가 있으며, 이 정도의 범위는 프로그램에서 할 수 있는 가장 세밀한 범위이다.
경쟁이 없는 상태에서 단일 연산 변수의 값을 변경하는 실행 경로는 락을 확보하는 가장 빠른 코드 실행 경로보다 느릴 수 없으며, 대부분 단일 연산 변수 쪽이 더 빠르게 실행된다. 느리게 실행되는 경우를 비교해보면, 락을 사용해 구현된 부분과 같이 대기 상태에 들어가거나 스레드 스케줄링과 관런된 문제가 발생하지 않기 때문에 단일 연산 변수를 사용하는 쪽이 명백하게 더 빠르게 실행된다. 따라서 락 대신 단일 연산 변수를 기반의 알고리즘으로 구현된 프로그램은 내부의 스레드가 지연되는 현상이 거의 없으며, 스레드 간의 경쟁이 발생한다 해도 훨씬 쉽게 경쟁 상황을 헤쳐나갈 수 있다.
단일 연산 변수 클래스는 volatile 변수에서 읽고-변경하고-쓰는 것과 같은 조건부 단일 연산을 지원하도록 일반화한 구조이다.
Atomiclnteger 클래스는 Int 값을 나타내며, 일반적인 volatile 변수로 사용할 때 변수의 값을 읽거나 쓰는 연산과 동일한 기능을 하는 get 메소드와 set 메소드를 제공한다. 또한 단일 연산으로 실행되는 compareAndSet 메소드도 제공하며, 그 외의 편의 사항인 단일 연산으로 값을 더하거나, 증가시키거나, 감소시키는 등의 메소드도 제공한다. 겉으로 보기에 Atomic Integer는 Counter 클래스와 굉장히 비슷한 모습을 갖고 있다. 하지만 동기화를 위한 하드웨어의 기능을 직접적 으로 활용할 수 있기 때문에 경쟁이 발생하는 상황에서 훨씬 높은 확장성을 제공한다.
일단 12개의 단일 연산 변수 클래스가 제공되며, 대략 일반 변수, 필드 업데이터 field updater, 배열, 그리고 조합 변수의 4개 그룹으로 나눠 볼 수 있다. 가장 많이 사용 하는 형태는 바로 일반 변수의 형대를 그대로 갖고 있는 Atomic Integer, AtomicLong, AtomicBoolean, AtomicReference 클래스이다. 네 가지 모두 CAS 연산을 제공하며 AtomicInteger나 AtomicLong은 간단한 산술 기능도 제공한다.
- 469~470p
넌블로킹 알고리즘
락 기반으로 동작하는 알고리즘은 항상 다양한 종류의 가용성 문제에 직면할 위험이 있다. 락을 현재 확보하고 있는 스레드가 I/O 작업 때문에 대기 중이라거나, 메모리 페이징 때문에 대기 중이라거나, 기타 어떤 원인 때문에라도 대기 상태에 들어간다면 다른 모든 스레드가 전부 대기 상태에 들어갈 가능성이 있다. 특정 스레드에서 작업이 실패하거나 또는 대기 상태에 들어가는 경우에, 다른 어떤 스레드라도 그로 인해 실패하거나 대기 상태에 들어가지 않는 알고리즘을 대기 상태에 들어가지 않는 알고리즘, 즉 넌블로킹이non-blocking 알고리즘이라고 한다.
또한 각 작업 단계마다 일부 스레드는 항상 작업을 진행할 수 있는 경우 락 프리 lock-free 알고리즘이라고 한다. 스레드 간의 작업 조율을 위해 CAS 연산을 독점적으로 사용하는 알고리즘을 올바로 구현한 경우에는 대기 상태에 들어가지 않는 특성과 락 프리 특성을 함께 가지게 된다. 여러 스레드가 경쟁하지 않는 상황이라면 CAS 연산은 항상 성공하고, 여러 스레드가 경쟁을 한다고 해도 최소한 하나의 스레드는 반드시 성공하기 때문에 성공한 스레드는 작업을 진행할 수 있다. 넌블로킹 알고리즘은 데드락이나 우선 순위 역전priority inversion 등의 문제점이 발생하지 않는다(물론 지속적으로 재시도만 하고 있을 기능성도 있기 때문에 라이브락 등의 문제점이 발생할 가능성도 있기는 하다).
- 476p
넌블로킹 스택
대기 상태에 들어가지 않도록 구현한 알고리즘은 락 기반으로 구현한 알고리즘에 비해 상당히 복잡한 경우가 많다. 넌블로킹 알고리즘을 구성할 때 가장 핵심이 되는 부분은 바로 데이터의 일관성을 유지하면서 단일 연산 변경 작업의 범위를 단 하나의 변수로 제한하는 부분이다. 큐와 같이 연결된 구조를 갖는 컬렉션 클래스에서는 상태 전환을 개별적인 링크에 대한 변경 작업이라고 간주하고, AtomicReference로 각 연결 부분을 관리해서 단일 연산으로만 변경할 수 있도록 하면 어느 정도 구현이 가능하다.
스택은 연결 구조를 갖는 자료 구조 가운데 가장 간단한 편에 속한다. 각 항목은 각자 단 하나의 다른 항목만을 연결하고 있고, 반대로 각 항목은 단 하나의 항목에서만 참조된다. 예제 15.6의 Concurrent Stack 클래스는 단일 연산 참조를 사용해 스택을 어떻게 구현하는지를 보여주는 좋은 예이다.
@ThreadSafe
public class ConcurrentStack <E> {
AtomicReference<Node<E>> top = new AtomicReference<Node<E>>();
public void push(E item) {
Node<E> newHead = new Node<E>(item);
Node<E> oldHead;
do {
oldHead = top.get();
newHead.next = oldHead;
} while (!top.compareAndSet(oldHead, newHead));
}
public E pop() {
Node<E> oldHead;
Node<E> newHead;
do {
oldHead = top.get();
if (oldHead == null)
return null;
newHead = oldHead.next;
} while (!top.compareAndSet(oldHead, newHead));
return oldHead.item;
}
private static class Node <E> {
public final E item;
public Node<E> next;
public Node(E item) {
this.item = item;
}
}
}
스택 자체는 Node 클래스로 구성된 연결 리스트이며, 최초 항목은 top 변수에 들어 있고, 각 항목마다 자신의 값과 다음 항목에 대한 참조를 갖고 있다. push 메소드에서는 새로운 Node 인스턴스를 생성하고, 새 Node의 next 연결 값으로 현재의 top 항목을 설정한 다음, CAS 연산을 통해 새로운 Node를 스택의 top으로 설정한다. CAS 연산을 시작하기 전에 알고 있던 top 항목이 CAS 연산을 시작한 이후에도 동일한 값이었다면 CAS 연산이 성공한다. 반대로 다른 스레드에서 그 사이에 top 항목을 변경했다면 CAS 연산이 실패하며, 현재의 top 항목을 기준으로 다시 새로운 Node 인스턴스를 top으로 설정하기 위해 CAS 연산을 재시도 한다. CAS 연산이 성공하거나 실패하는 어떤 경우라 해도 스택은 항상 안정적인 상태를 유지한다.
CasCounter와 ConcurrentStack 클래스는 대기 상태에 들어가지 않는 알고리즘의 여러 가지 특성을 모두 보여주고 있다. 즉, 작업이 항상 성공하는 것은 아니며, 재시도해야 할 수도 있다. ConcurrentStack에서는 새로 추가된 항목을 나타내는 Node 인스턴스를 새로 만들 때 next 변수에 지정한 항목이 스택에 모두 연결된 이후에도 정상적인 항목으로 연결돼 있기를 기대하고 있는 셈이고, 다만 경쟁이 있는 상황이라면 그렇지 못할 수도 있으니 재시도할 준비를 하고 있을 뿐이다.
ConcurrentStack에서와 같이 대기 상태에 들어가지 않는 알고리즘은 락과 같이 compareAndSet 연산을 통해 단일 연산 특성과 가시성을 보장하기 때문에 스레드 안전성을 보장한다. 특정 스레드에서 스택의 상태를 변경했다면 상태를 변경할 때 volatile 쓰기 특성이 있는 compareAndSet 연산을 사용해야만 한다. 특정 스레드에서 스택의 값을 읽어간다면 변경을 가할 때와 동일한 AtomicReference 객체에 대해서 get 메소드를 호출하게 되며, 이는 정확하게 volatile 읽기 특성을 갖고 있다. 따라서 어느 스레드에서건 스택의 내용을 변경하면 스택의 내용을 확인하는 모든 스레드가 변경된 내용을 즉시 볼 수 있다.
- 476~477p
ABA 문제
ABA 문제 (주로 가비지 컬렉션이 없는 환경에) 노드를 재사용하는 알고리즘에서 비교 후 치환compare-and-swap 연산을 고지식하게 사용하다보면 발생할 수 있는 이상현상을 말한다. CAS 연산은 "V 변수의 값이 여전히 A인지?"를 확인하고 만약 그렇다면 값을 B로 변경하는 작업을 진행한다. 15장에서 소개했던 예제처럼 대부분의 경우에는 이 정도의 확인만으로 충분하다. 하지만 간혹 "V 변수의 값이 내가 마지막으로 A 값이라고 확인한 이후에 변경된 적이 있는지?"라는 질문의 답을 알아야 할 경우도 있다. 일부 알고리즘을 사용하다 보면 V 변수의 값이 A에서 B로 변경됐다가 다시 A로 변경된 경우 역시 변경 사항이 있었다는 것으로 인식하고 그에 해당하는 재시도 절차를 밟아야 할 필요가 있기도 하다.
이와 같은 ABA 문제는 연결 노드 객체에 대한 메모리 관리 부분을 직접 처리하는 알고리즘을 사용할 때 많이 발생한다. 연결 리스트의 머리 변수가 이전에 확인했던 그 객체를 참조하고 있다는 사실만으로는 해당 리스트의 내용이 변경되지 않았다고 확신할 수 없다. 그렇다고 해서 사용하지 않은 경결 노드를 가비지 컬렉터가 맡아서 처리하도록 하는 것만으로는 ABA 문제를 해결할 수 없으며, 대신 굉장히 쉬운 해결 방법이 있다. 즉 참조 값 하나만 변경하는 것이 아니라 참조와 버전 번호의 두 가지 값을 한꺼번에 변경하는 방법이다. 버전 번호를 관리하면 값이 A에서 B로 변경됐다가 다시 A라고 변경된 경우라고 해도 버전 번호를 보고 변경된 상태라는 점을 알 수 있다.
AtomicStampedReference 클래스는 두 개의 값에 대해 조건부 단일 연산 업데이트 기능을 제공한다.
- 484~485p
자바 메모리 모델
플랫폼 메모리 모델
시스템 구조에서 말하는 메모리 모델은 프로그램이 메모리 구조에서 어느 정도의 기능을 사용할 수 있을지에 대한 정보를 제공하고, 메모리의 내용을 서로 공유하고자 할 때 프로세서 간의 작업을 조율하기 위한 특별한 명령어(메모리 배리어 또는 펜스)로는 어떤 것들이 있으며 어떻게 사용해야 하는지에 대한 정보도 제공한다. 자바 개발자가 서로 다른 하드웨어가 갖고 있는 각자의 메모리 모델을 직접 신경 쓰지 않도록 자바는 스스로의 메모리 모델인 JMM을 구성하고 있으며, JMM과 그 기반이 되는 하드웨어 메모리 모델의 차이점은 메모리 배리어를 적절히 활용하는 방법 등으로 JVM에서 담당해 처리한다.
- 489p
재배치
2장에서 경쟁 조건과 연산의 단일성 오류를 설명하면서 제대로 동기화되지 않은 프로그램에서 스케줄러가 작업을 겹쳐 실행하는 바람에 잘못된 결과를 만들어 내는 '운 나쁜 타이밍'을 그림으로 소개했었다. 더군다나 JMM은 서로 다른 스레드가 각자의 상황에 맞는 순서로 명령어를 실행할 수 있도록 허용하고 있기 때문에 동기화가 돼 있지 않은 부분을 놓고 실행 순서를 예측하는 일이 훨씬 더 복잡해졌다. 특정 작업이 지연되거나 다른 순서로 실행되는 것처럼 보이는 문제는 '재배치'이라는 용어로 통일해서 표현한다.
- 490p
예제 16.1의 PossibleReordering 클래스는 제대로 동기화되지 않은 상태라면 아주 간단한 병렬 프로그램조차 동작할 모습을 예측하기가 어렵다는 사실을 보여준다. PossibleReordering 클래스에서 (1,0)이나 (0,1) 아니면 (1,1)의 결과 가운데 어느 것이라도 출력될 수 있다는 점은 쉽게 예측할 수 있다. 스레드 B가 시작하기도 전에 스레드 A의 작업이 마무리 될 수도 있고, 스레드 A가 시작하기 전에 스레드 B의 작업이 끝날 수도 있고, 아니면 두 개의 스레드가 섞여서 실행될 수도 있다. 하지만 이상하게 (0,0)이라는 결과도 출력될 수 있다. 각 스레드 내부에서 일어나는 작업은 다른 스레드와의 연결 관계가 없으며, 따라서 순서가 재배치된 상태로 실행될 가능성이 있다.
public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
public void run() {
a = 1;
x = b;
}
});
Thread other = new Thread(new Runnable() {
public void run() {
b = 1;
y = a;
}
});
one.start();
other.start();
one.join();
other.join();
System.out.println("( " + x + "," + y + ")");
}
}
- 491p
자바 메모리 모델을 간략하게 설명한다면
변수를 읽거나 쓰는 작업, 모니터를 잠그거나 해제하는 작업, 스레드를 시작하거나 끝나기를 기다리는 작업과 같이 여러 가지 작업에 대해 자바 메모리 모델JMM을 정의한다. JMM에서는 프로그램 내부의 모든 작업을 대상으로 미리 발생happens-before라는 부분 재배치 연산을 정의하고 있다. 작업 A가 실행될 결과를 작업 B에서 볼 수 있다는 점을 보장하기 위해 작업 A와 B 사이에는 미리 발생 관계가 갖춰져야 한다. 두 개 작업 간에 미리 발생 관계가 갖춰져 있지 않다면 JVM은 원하는 대로 해당 작업을 재배치 할 수 있게 된다.
- 492p
하나의 변수를 두 개 이상의 스레드에서 읽어가려고 하면서 최소한 하나 이상의 스레드에서 쓰기 작업을 하지만, 쓰기 작업과 읽기 작업 간에 미리 발생 관계가 갖쳐줘 있지 않은 경우에 데이터 경쟁 현상이 발생한다. 이와 같은 데이터 경쟁 현상이 발생하지 않는 프로그램을 올바르게 동기화된 프로그램이라고 말한다.
- 492p
미리 발생 현상에 대한 규칙은 다음과 같다.
- 프로그램 순서 규칙
- 모니터 잠금 규칙
- volatile 변수 규칙
- 스레드 시작 규칙
- 스레드 완료 규칙
- 인터럽트 규칙
- 완료 메소드 규칙
- 전이성
- 492~493p
'Book' 카테고리의 다른 글
[독서 기록] 애플에서는 단순하게 일합니다 (0) | 2024.07.27 |
---|---|
[독서 기록] 늦깎이 천재들의 비밀 (0) | 2024.07.26 |
[독서 기록] 컴퓨터 밑바닥의 비밀 (1) | 2024.04.14 |
[독서 기록] 웹 개발자를 위한 대규모 서비스를 지탱하는 기술 (1) | 2024.04.14 |
[독서 기록] 일류의 조건 (0) | 2024.04.13 |