Liveness, Performance, and Testing
- 3.1 데드락
- 3.2 성능과 확장성
- 3.3 동시성 프로그램 테스트
프로그램이 아무리 올바르게 동기화되어 있어도, 멈춰버리거나 느려터지면 소용없잖아. 3장은 활동성, 성능, 테스트라는 세 가지 현실적인 문제를 다뤄.
데드락은 두 개 이상의 스레드가 서로가 가진 락을 기다리면서 영원히 진행하지 못하는 상태야. 가장 흔한 형태가 **락 순서 데드락(lock-ordering deadlock)**이지. 스레드 A가 락 L을 잡고 락 M을 기다리고, 스레드 B가 락 M을 잡고 락 L을 기다리는 거거든.
// 락 순서 데드락
// Thread A: transferMoney(accountA, accountB, 100)
// Thread B: transferMoney(accountB, accountA, 200)
public void transferMoney(Account from, Account to, int amount) {
synchronized (from) { // A: accountA 잠금 / B: accountB 잠금
synchronized (to) { // A: accountB 대기 / B: accountA 대기 → 데드락!
from.debit(amount);
to.credit(amount);
}
}
}
동적 락 순서 데드락은 락의 순서가 인자에 따라 달라지는 경우야. 해결 방법은 락 순서를 고정하는 거지. System.identityHashCode()로 해시 값을 비교해서 항상 해시가 작은 객체의 락을 먼저 잡으면 돼.
public void transferMoney(Account from, Account to, int amount) {
int fromHash = System.identityHashCode(from);
int toHash = System.identityHashCode(to);
if (fromHash < toHash) {
synchronized (from) { synchronized (to) { doTransfer(); } }
} else if (fromHash > toHash) {
synchronized (to) { synchronized (from) { doTransfer(); } }
} else {
synchronized (tieLock) { // 해시 충돌 시 타이브레이커 락 사용
synchronized (from) { synchronized (to) { doTransfer(); } }
}
}
}
협력 객체 간 데드락도 있어. 두 객체가 서로의 메서드를 호출하는데, 각자 자기 락을 잡은 상태에서 상대의 synchronized 메서드를 호출하면 데드락이야. 해결법은 **오픈 호출(open call)**이지. 락을 잡은 상태에서 외부 메서드를 호출하지 않는 거야. synchronized 블록을 좁혀서, 락을 풀고 나서 외부 메서드를 호출하면 돼. 리소스 데드락도 마찬가지야. 커넥션 풀 사이에서 교차 대기가 생기면 똑같이 데드락이거든.
데드락을 회피하는 기본 원칙은 세 가지야. 락 순서를 일관되게 유지하고, 오픈 호출을 사용하고, 타임아웃(Lock.tryLock(timeout))으로 일정 시간 안에 못 잡으면 포기하는 거지. 데드락이 발생하면 스레드 덤프로 진단해. jstack이나 Ctrl+Break로 스레드 덤프를 뜨면, JVM이 내장 락 데드락은 자동으로 감지해서 표시해줘.
**기아(starvation)**는 스레드가 필요한 리소스를 영원히 얻지 못하는 상태고, **라이브락(livelock)**은 블로킹되지는 않지만 계속 같은 동작을 반복하면서 진행하지 못하는 상태야. 두 사람이 좁은 복도에서 서로 비키려고 같은 방향으로 계속 움직이는 것 같은 거지. 라이브락은 랜덤 대기를 도입하면 해결돼. 이더넷의 CSMA/CD도 충돌 시 랜덤 시간만큼 기다렸다가 재전송하잖아.
이제 성능 얘기로 넘어갈게. 스레드를 더 쓴다고 무조건 빨라지지 않아. 성능에는 **처리량(throughput)**과 지연시간(latency) 두 측면이 있는데, 이 둘은 종종 트레이드오프야. 동기화 비용, 컨텍스트 스위칭, 스레드 생성 비용이 있으니까, 병렬화의 이익이 이 비용을 넘어서야 의미가 있지. "먼저 올바르게, 그 다음 빠르게" — 성능 최적화를 위해 안전성을 희생하면 절대 안 돼.
**암달의 법칙(Amdahl's Law)**이 확장성의 상한을 결정해. Speedup = 1 / (F + (1-F)/N) 인데, F가 직렬 비율이고 N이 프로세서 수야. 프로그램의 50%가 직렬이면 프로세서를 아무리 많이 투입해도 최대 2배까지만 빨라져. 실제 프로그램에서 직렬 부분이 어디냐면 바로 동기화 블록이야. synchronized 블록 안에서는 한 번에 하나의 스레드만 실행되니까, 이 부분이 직렬 구간이지. 결국 확장성을 높이려면 직렬 구간을 최소화해야 해.
프로세서 10개, 직렬 비율 10%: 최대 5.3배 향상
프로세서 100개, 직렬 비율 10%: 최대 9.2배 향상
프로세서 100개, 직렬 비율 1%: 최대 50.2배 향상
스레드가 공짜가 아닌 이유를 좀 더 들어볼게. 컨텍스트 스위칭은 5,000~10,000 클럭 사이클 정도인데, 진짜 비용은 캐시 미스에 있어. 새 스레드가 필요한 데이터가 캐시에 없으면 메인 메모리까지 가야 하거든. 메모리 동기화도 비용이야. synchronized와 volatile은 메모리 배리어를 사용하는데, 캐시를 플러시/무효화하고 컴파일러 최적화를 제한하지. 블로킹 시에는 컨텍스트 스위칭이 두 번 발생해 (블로킹될 때 + 깨어날 때). JVM은 경합이 짧을 것으로 예상되면 **스핀 대기(spin-wait)**를 시도하기도 하고.
확장성의 가장 큰 위협은 독점적 락 경합이야. 락 경합을 줄이는 방법 세 가지가 있어. 첫째, 락을 잡는 시간을 줄여(narrow lock scope). synchronized 블록을 최소한으로 줄이고, I/O나 복잡한 계산은 synchronized 밖으로 빼야 해.
// 나쁜 예: 맵 전체를 잠그고 정규표현식 매칭을 수행
synchronized (this) {
String location = attributes.get("Location");
if (Pattern.matches(pattern, location)) { ... } // 이걸 밖으로!
}
// 좋은 예
String location;
synchronized (this) { location = attributes.get("Location"); }
if (Pattern.matches(pattern, location)) { ... }
둘째, 락 분할(lock splitting). 하나의 락이 독립적인 여러 상태를 보호하고 있으면, 각 상태마다 별도 락을 쓰는 거야. 셋째, 락 스트라이핑(lock striping). 분할의 확장판이지. 데이터를 여러 파티션으로 나누고 각 파티션마다 별도 락을 써. ConcurrentHashMap이 이 기법을 사용하거든. 16개의 세그먼트로 나누면 최대 16개의 스레드가 동시에 쓸 수 있어. ReadWriteLock도 대안이야. 읽기가 쓰기보다 훨씬 많은 경우 여러 스레드가 동시에 읽을 수 있게 하지. ConcurrentHashMap의 size()가 근사값을 반환하는 이유도 핫 필드 경합을 피하기 위해서야.
마지막으로 테스트. 동시성 버그는 재현이 어렵고, 테스트를 통과했다고 버그가 없다고 확신할 수 없어. 그래서 더 체계적인 접근이 필요하지. 기본 단위 테스트는 여전히 중요해. 먼저 단일 스레드에서 기본 기능이 동작하는지 확인하고, 그다음 블로킹 동작 테스트를 해. 별도 스레드에서 take()를 호출하고 WAITING 상태인지 확인하는 식이야. 안전성 테스트는 여러 스레드가 동시에 put/take를 수행하고, 넣은 것과 꺼낸 것의 체크섬이 일치하는지 확인하는 거야.
// 안전성 테스트 아이디어
AtomicInteger putSum = new AtomicInteger(0);
AtomicInteger takeSum = new AtomicInteger(0);
// 여러 스레드가 동시에 put/take 수행
// 마지막에 putSum.get() == takeSum.get() 확인
성능 테스트에서는 처리량과 응답성(특히 99퍼센타일)을 측정하는데, 워밍업을 충분히 해야 해. JVM의 JIT 컴파일러가 핫 코드를 최적화하는 데 시간이 걸리거든. 함정도 많아. 가비지 컬렉션이 중간에 터지면 결과가 왜곡되고, JIT 컴파일이 측정을 흔들고, **죽은 코드 제거(dead code elimination)**로 JIT가 결과를 사용하지 않는 연산을 아예 없애버릴 수 있지. 계산 결과를 어딘가에 출력하거나 비교해서 JIT가 코드를 제거하지 못하게 해야 해.
테스트만으로 동시성 버그를 다 잡을 수는 없어. 코드 리뷰가 필수야. "이 상태 변수가 어떤 락으로 보호되나?" 같은 질문을 다른 사람의 눈이 던져줄 수 있거든. 정적 분석 도구(FindBugs/SpotBugs)도 불일치한 동기화, Thread.run() 직접 호출, 해제 안 된 락 같은 패턴을 자동으로 탐지해줘. 실무에서는 코드 리뷰 + 정적 분석 + 다양한 환경에서의 스트레스 테스트가 가장 현실적이야.
정리
3장 읽고 기억할 거 세 가지:
- 락 순서를 일관되게 유지하고 오픈 호출을 쓰면 데드락을 예방할 수 있어. 라이브락은 랜덤 대기로 풀고, 기아는 스레드 우선순위를 건드리지 않는 게 최선이야
- 암달의 법칙에 의해 직렬 구간(동기화 블록)이 확장성의 상한을 결정해. 락 경합을 줄이려면 락 범위를 좁히고, 락 분할/스트라이핑으로 경합을 분산시켜야 해
- 동시성 테스트는 정확성과 성능을 분리하고, GC/JIT/죽은 코드 제거 함정을 조심해야 해. 테스트만으로 부족하니까 코드 리뷰와 정적 분석 도구를 반드시 병행하고