1. Deadlock 더 깊게 이해하기
Deadlock(상호 교착)은 둘 이상의 스레드가 서로를 영원히 기다리는 상황입니다. 각 스레드는 어떤 자원을 보유한 채 다른 스레드가 이미 점유한 자원을 얻으려 합니다. 그 결과 누구도 작업을 계속할 수 없고 프로그램이 “멈춥니다”. 이는 좁은 다리 위에서 자동차 두 대가 마주 선 것과 같습니다. 한 대가 뒤로 물러나지 않으면 누구도 지나갈 수 없습니다.
Java에서 데드락은 예외 (Exception)가 아니라 프로그램의 “멈춤” 상태입니다. 스레드는 종료되거나 오류로 떨어지지 않고 서로를 무한히 기다립니다. 그래서 이런 버그는 교묘합니다. 특정 조건에서만 드러나기 때문입니다.
데드락 발생의 네 가지 조건
- 1. 상호 배제(Mutual Exclusion)
자원은 한 번에 하나의 스레드만 점유할 수 있습니다(예: 객체 모니터, 파일). - 2. 보유 및 대기(Hold and Wait)
스레드는 한 자원을 보유한 채 다른 자원을 요청합니다. - 3. 비선점(No Preemption)
자원을 스레드로부터 강제로 빼앗을 수 없습니다. 오직 그 스레드만 자원을 해제할 수 있습니다. - 4. 순환 대기(Circular Wait)
각 스레드가 다음 스레드가 보유한 자원을 기다리는 닫힌 고리가 존재합니다.
데드락은 이 모든 조건이 동시에 충족될 때만 가능합니다. 하나라도 깨면 상호 교착은 발생하지 않습니다.
2. 데드락 코드 예시
개념도
- 두 개의 자원이 있습니다: lock1과 lock2(일반 객체).
- 스레드 A는 먼저 lock1을 획득한 뒤 lock2를 시도합니다.
- 스레드 B는 먼저 lock2를 획득한 뒤 lock1을 시도합니다.
예제(실무에서는 이렇게 하지 마세요!)
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
// 스레드 1
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("스레드 1: lock1을 획득함");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("스레드 1: lock2 획득 시도");
synchronized (lock2) {
System.out.println("스레드 1: lock2를 획득함");
}
}
});
// 스레드 2
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("스레드 2: lock2를 획득함");
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
System.out.println("스레드 2: lock1 획득 시도");
synchronized (lock1) {
System.out.println("스레드 2: lock1을 획득함");
}
}
});
thread1.start();
thread2.start();
}
}
실행 시 무엇이 일어날까요?
- 스레드 1은 lock1을, 스레드 2는 lock2를 획득합니다.
- 둘 다 100밀리초 동안 “잠들어” 첫 번째 락을 확보합니다.
- 이후 각자는 두 번째 락을 시도하지만, 이미 다른 스레드가 보유 중입니다.
- 두 스레드는 서로를 무한히 기다리게 되어 데드락이 발생합니다.
콘솔 출력 예:
스레드 1: lock1을 획득함
스레드 2: lock2를 획득함
스레드 1: lock2 획득 시도
스레드 2: lock1 획득 시도
왜 데드락이 발생하는가? 자세한 분석
- 상호 배제: 각 락은 동시에 하나의 스레드만 보유할 수 있습니다.
- 보유 및 대기: 스레드 1은 lock1을 보유하고 lock2를 기다리며, 스레드 2는 lock2를 보유하고 lock1을 기다립니다.
- 비선점: 외부에서 락을 강제로 빼앗을 수 없고, 오직 synchronized 블록을 빠져나와야 해제됩니다.
- 순환 대기: 두 스레드 사이의 대기가 원형으로 연결됩니다.
3. 데드락을 피하고 예방하기
자원을 항상 동일한 순서로 획득하기
핵심 규칙: 여러 스레드가 여러 자원을 획득해야 한다면 — 항상 동일한 순서로 수행하세요. 예: 모든 코드에서 먼저 lock1, 그다음 lock2.
public void doSomething() {
Object firstLock = lock1;
Object secondLock = lock2; // 일관된 순서 "lock1 -> lock2"
synchronized (firstLock) {
synchronized (secondLock) {
// 두 자원과 작업
}
}
}
순서를 통일하면 순환 대기가 불가능해져 데드락 조건이 깨집니다.
타임아웃이 있는 tryLock 사용 (ReentrantLock)
필요한 자원을 미리 알 수 없을 때는 ReentrantLock과 tryLock을 사용해 무한 대기를 피하세요.
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;
public class TryLockDemo {
private static final ReentrantLock lock1 = new ReentrantLock();
private static final ReentrantLock lock2 = new ReentrantLock();
public void doWork() {
try {
if (lock1.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lock2.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 임계 구역
} finally {
lock2.unlock();
}
} else {
System.out.println("lock2를 획득하지 못함, 롤백합니다");
}
} finally {
lock1.unlock();
}
} else {
System.out.println("lock1을 획득하지 못함, 롤백합니다");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
장점: 작업을 롤백하고 나중에 다시 시도할 수 있습니다. 단점: 코드는 더 복잡해지지만 데드락은 방지됩니다.
락 보유 시간을 최소화하기
락은 가능한 한 짧게 보유하세요. synchronized/lock 내부에서는 꼭 필요한 작업만 수행하고, 나머지는 임계 구역 밖으로 빼십시오.
중첩 락을 피하기
중첩된 synchronized/lock이 적을수록 상호 교착 위험이 낮습니다. 가능하다면 연관된 자원에 단일 락을 사용하세요.
검증된 thread-safe 자료구조 사용
표준 라이브러리는 이미 많은 문제를 해결했습니다. ConcurrentHashMap 같은 컬렉션과 java.util.concurrent의 다른 클래스는 데드락 위험을 줄이고 코드를 단순화합니다.
4. 데드락 진단
Thread Dump와 jstack
Thread Dump는 JVM 내 모든 스레드 상태의 스냅샷입니다.
콘솔에서 가져오기:
jstack <pid>
여기서 <pid>는 Java 프로세스의 식별자이며(jps로 확인 가능), 해당 값을 사용합니다.
IDE의 디버그 패널에는 보통 “Thread Dump” 버튼이 있습니다.
덤프에서 다음을 확인하세요:
- BLOCKED 또는 WAITING 상태.
- waiting to lock ... 및 locked ... 형태의 메시지.
- JVM 문구: "Found one Java-level deadlock:" — 상호 교착을 발견했을 때 출력됩니다.
덤프 일부 예:
"Thread-1":
waiting to lock monitor 0x000000001e4000, (object 0x7f8a5c00, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x000000001e3000, (object 0x7f8a5c10, a java.lang.Object),
which is held by "Thread-1"
이것이 바로 데드락입니다. 스레드들이 서로를 붙잡고 놓지 않는 상황이죠.
VisualVM 및 기타 도구
- VisualVM — 무료 스레드 분석 및 데드락 탐지 도구.
- Java Mission Control / Flight Recorder — 고급 모니터링 및 프로파일링.
VisualVM에서는 스레드 트리와 상태를 확인하고, 상호 교착 알림을 받을 수 있습니다.
표: “데드락에 빠지지 않는 법”
| 데드락 원인 | 예방 방법 |
|---|---|
| 자원을 서로 다른 순서로 획득함 | 항상 동일한 자원 획득 순서를 유지 |
| 중첩된 synchronized | 중첩 최소화, 가능하면 단일 락 사용 |
| 긴 락 보유 시간 | 임계 구역 내 작업을 줄이기 |
| 동시에 여러 락 사용 | tryLock에 타임아웃을 두고 롤백 처리 |
| 비 thread-safe 컬렉션 | 경쟁 컬렉션 사용(ConcurrentHashMap 등) |
5. 데드락과 관련된 흔한 실수
오류 1: 자원을 서로 다른 순서로 획득함. 가장 흔한 원인입니다 — 스레드들이 락을 서로 다른 순서로 잡습니다. “그게 더 빠르다” 하더라도 순서를 통일하세요. 그렇지 않으면 순환 대기가 보장됩니다.
오류 2: 불필요한 synchronized 중첩. 불필요한 중첩은 데드락 위험을 높이고 코드를 복잡하게 만듭니다. 락 모델을 단순화하세요.
오류 3: tryLock과 타임아웃을 무시함. synchronized는 스레드를 무한 대기시키게 만듭니다. 확신이 없다면 tryLock과 타임아웃, 그리고 롤백 로직을 사용하세요.
오류 4: 락 내부에서 느린 작업을 수행함. 네트워크 요청, I/O, 무거운 계산을 락 아래에서 수행하면 문제 가능성이 급격히 높아집니다. 이를 임계 구역 밖으로 이동하세요.
오류 5: Thread Dump를 분석하지 않음. Thread Dump는 데드락을 찾을 때 최고의 도구입니다. jstack을 사용하고 BLOCKED/WAITING 상태, “holding/waiting to lock” 체인을 분석하세요.
GO TO FULL VERSION