CodeGym /행동 /JAVA 25 SELF /Deadlock: 원인, 예시, 해결

Deadlock: 원인, 예시, 해결

JAVA 25 SELF
레벨 53 , 레슨 0
사용 가능

1. Deadlock 더 깊게 이해하기

Deadlock(상호 교착)은 둘 이상의 스레드가 서로를 영원히 기다리는 상황입니다. 각 스레드는 어떤 자원을 보유한 채 다른 스레드가 이미 점유한 자원을 얻으려 합니다. 그 결과 누구도 작업을 계속할 수 없고 프로그램이 “멈춥니다”. 이는 좁은 다리 위에서 자동차 두 대가 마주 선 것과 같습니다. 한 대가 뒤로 물러나지 않으면 누구도 지나갈 수 없습니다.

Java에서 데드락은 예외 (Exception)가 아니라 프로그램의 “멈춤” 상태입니다. 스레드는 종료되거나 오류로 떨어지지 않고 서로를 무한히 기다립니다. 그래서 이런 버그는 교묘합니다. 특정 조건에서만 드러나기 때문입니다.

데드락 발생의 네 가지 조건

  • 1. 상호 배제(Mutual Exclusion)
    자원은 한 번에 하나의 스레드만 점유할 수 있습니다(예: 객체 모니터, 파일).
  • 2. 보유 및 대기(Hold and Wait)
    스레드는 한 자원을 보유한 채 다른 자원을 요청합니다.
  • 3. 비선점(No Preemption)
    자원을 스레드로부터 강제로 빼앗을 수 없습니다. 오직 그 스레드만 자원을 해제할 수 있습니다.
  • 4. 순환 대기(Circular Wait)
    각 스레드가 다음 스레드가 보유한 자원을 기다리는 닫힌 고리가 존재합니다.

데드락은 이 모든 조건이 동시에 충족될 때만 가능합니다. 하나라도 깨면 상호 교착은 발생하지 않습니다.

2. 데드락 코드 예시

개념도

  • 두 개의 자원이 있습니다: lock1lock2(일반 객체).
  • 스레드 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)

필요한 자원을 미리 알 수 없을 때는 ReentrantLocktryLock을 사용해 무한 대기를 피하세요.

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” 체인을 분석하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION