본문 바로가기
Java/Java

Deadlock 상황 만들어 VisualVM으로 분석하고, 해결하기

by oneny 2023. 9. 3.

데드락(Deadlock)

운영체제 또는 소프트웨어의 잘못된 자원 관리로 인하여 둘 이상의 프로세스 또는 스레드들이 아무것도 진행하지 않는 상태로 서로 영원히 대기하는 상황을 말한다. 주로 한정된 자원을 여러 프로세스 및 스레드에서 동시에 사용하는 환경에서 서로 상대방이 사용 중인 자원을 쓰기 위해 대기하는 상황, 그러니깐 A가 B를 기다리고, B가 A를 기다릴 때 발생한다.

 

발생조건

  1. 상호 배제(Mutual Exclusion): 한 번에 한 스레드만 단독으로 리소스에 액세스할 수 있다. 즉, 리소스 자체를 동시에 쓸 수 없으며 한 번에 하나의 스레드만이 해당 리소스를 사용할 수 있다.
  2. 점유 상태로 대기(Hold and wait): 최소 하나의 스레드가 리소스를 점유하면서 다른 리소스에 대기를 해야 한다.
  3. 선점 불가(No preemption): 스레드가 사용완료할 때까지 다른 스레드에서는 리소스를 사용할 수 없다. 즉, 다른 스레드의 리소스를 뺏을 수 없다. 리소스를 점유하고 있는 스레드가 끝날 때까지 기다려야 한다.
  4. 순환성 대기(Circular wait): 한 스레드가 리소스A를 점유하며 다른 스레드가 점유한 리소스B를 기다리고, 다른 스레드는 리소스A를 기다리고 있는 상황을 말한다. 

네 가지 조건이 모두 충족했을 때 데드락이 발생하는 것은 시간문제이다. 따라서 네 가지 조건 중 하나라도 충족하지 않게 만들면 해결할 수 있다.

 

데드락 상황 만들기

TrainA와 TranB가 서로의 교차로을 지나간다고 가정해보자. TrainA가 길에 들어서면 TrainB는 당연히 TrainA가 지나가기까지 기다린 후 움직일 수 있다. 반대로 얘기하면 지나가기전까지는 움직이지 못하는 상태가 된다. 이러한 상황에 순환 종속성이 발생하기 쉽다. 먼저, 위 상황에 대한 TrainA와 TrainB, 그리고 Intersection을 만들어보자.

 

TrainA

public class TrainA implements Runnable {

  private Intersection intersection;
  private Random random = new Random();

  public TrainA(Intersection intersection) {
    this.intersection = intersection;
  }

  @Override
  public void run() {
    while (true) {
      long sleepingTime = random.nextInt(5);
      try {
        Thread.sleep(sleepingTime);
      } catch (InterruptedException e) {}

      intersection.takeRoadA();
    }
  }
}

TrainA는 Intersection을 생성자 매개변수로 받고, 스레드가 실행되면 0~4초 TIMED_WAITING 상태였다가 takeRoadA() 메서드가 실행된다.

 

TrainB

public class TrainB implements Runnable {

  private Intersection intersection;
  private Random random = new Random();

  public TrainB(Intersection intersection) {
    this.intersection = intersection;
  }

  @Override
  public void run() {
    while (true) {
      long sleepingTime = random.nextInt(5);
      try {
        Thread.sleep(sleepingTime);
      } catch (InterruptedException e) {}

      intersection.takeRoadB();
    }
  }
}

TrainB는 Intersection을 생성자 매개변수로 받고, 스레드가 실행되면 0~4초 TIMED_WAITING 상태였다가 takeRoadB() 메서드가 실행된다.

 

Intersection

public class Intersection {

  private Object roadA = new Object();
  private Object roadB = new Object();

  public static void main(String[] args) {
    Intersection intersection = new Intersection();
    Thread trainAThread = new Thread(new TrainA(intersection));
    Thread trainBThread = new Thread(new TrainB(intersection));

    trainAThread.setName("trainAThread");
    trainBThread.setName("trainBThread");

    trainAThread.start();
    trainBThread.start();
  }

  public void takeRoadA() {
    synchronized (roadA) {
      System.out.println("Road A is locked by thread - " + Thread.currentThread().getName());

      synchronized (roadB) {
        System.out.println("Train is passing through road A");

        try {
          Thread.sleep(1);
        } catch (InterruptedException e) {
        }
      }
    }
  }

  public void takeRoadB() {
    synchronized (roadB) {
      System.out.println("Road B is locked by thread - " + Thread.currentThread().getName());

      synchronized (roadA) {
        System.out.println("Train is passing through road B");

        try {
          Thread.sleep(1);
        } catch (InterruptedException e) {}
      }
    }
  }
}

해당 클래스는 TrainA와 TrainB가 들어서는 교차로이라고 생각하면 된다. 위에서 주시해야할 메서드는 당연히 takeRoadA()와 takeRoadB() 메서드이다. 교차로는 두 개의 기차(TrainA, TrainB)가 교차로를 통과하려고 시도할 때 가로지르기 위한 잠금을 사용하여 동시에 한 번에 하나의 기차만 도로를 통과할 수 있도록 만들었다.

takeRoadA() 메서드가 실행되면 roadA에 대한 잠금을 설정하여 RoadA가 잠금되었다는 출력을 한 후, roadB를 통과하기 위해 다시 roadB를 잠금 설정하는 것을 확인할 수 있다. takeRoadB() 메서드도 takeRoadA()와 동작하는 것을 비슷하지만 잠금을 설정하는 순서가 반대로 동작한다.

 

Deadlock이 발생하는 원인

TrainAThread     TrainBThread
1. lock(A)
                            2. lock(B)
                            3. lock(A) -- A에 대한 잠금은 TrainAThread가 가지고 있음 -> 대기
4. lock(B) -- B에 대한 잠금은 TrainBThread가 가지고 있음  -> 대기

먼저, TrainAThread에서 takeRoadA가 실행되어 roadA에 대한 잠금을 설정했다. 그리고나서 TrainBThread도 마찬가지로 takeRoadB가 실행되어 roadB에 대한 잠금을 얻었다. 이제 TrainAThread가 roadA에 대한 잠금을 보유하고, TrainBThread가 roadB에 대한 잠금을 보유한 상태에서 두 스레드 모두 서로 다른 잠금을 얻으려고 대기 상태에 있다.

TrainAThread가 roadB에 대한 잠금을 얻으려고 시도하지만, 이 잠금은 이미 TrainBThread가 보유하고 있기 때문에 TrainAThread는 대기 상태에 남고, TrainBThread도 roadA에 대한 잠금을 얻으려고 시도하지만 해당 잠금은 이미 TrainAThread가 보유하고 있기 떄문에 대기 상태에 남게 된다.

이렇게 서로의 자원을 쓰기 위해 대기하는 상황을 데드락이라하고, 두 스레드 모두 무한히 대기하는 상태가 된다.

 

실행 및 VisualVM 분석

몇 번은 운이 좋게 roadA가 roadB까지 획득하면서 데드락이 발생하지 않을 수 있지만 그것도 잠시 TrainAThread와 TrainBThread가 서로의 락이 해제되기를 기다리는 데드락 상황에 빠진 것을 확인할 수 있다. 이 때, VisualVM의 Thread 탭을 확인해보면 "Deadlock detected!"라고 데드락이 발생한 것을 알려주면서 Thread Dump를 하라고 권유하는 것을 확인할 수 있다. 그럼 Thread Dump를 떠보자.

 

Thread Dump

ThreadDump를 떠서 확인해본 결과 아주 친절하고 자세히 데드락이 발생한 스레드가 무엇인지 알려주는 것을 확인할 수 있다. 데드락이 발생할 수 있는 trainAThread와 trainBThread가 모두 BLOCKED 상태이고, 서로 락이 풀릴때까지 대기하기 때문에 데드락이 하나 발견됐다는 결과를 확인할 수 있다.

 

참고: 스레드 상태 - BLOCKED
BLOCKED는 사용하고자 하는 객체의 락이 풀릴 때까지 기다리는 상태를 말한다.

 

해결 방법

데드락을 피하기 위해서는 잠금을 얻는 순서를 통일하거나, 타임아웃을 설정하여 대기를 중단하는 방법 등 다양한 해결책이 있다.

 

순환 대기 피하기

public void takeRoadB() {
  synchronized (roadA) {
    System.out.println("Road B is locked by thread - " + Thread.currentThread().getName());

    synchronized (roadB) {
      System.out.println("Train is passing through road B");

      try {
        Thread.sleep(1);
      } catch (InterruptedException e) {}
    }
  }
}

동일한 순서로 공유 리소스를 잠그고, 모든 코드에 해당 순서를 유지하면 된다. 위 예시를 보면 두 스레드에서 리소스 A와 B가 다른 순서로 잠긴 것을 확인할 수 있다. 따라서 두 스레드 중 하나를 수정해서 순서를 동일하게 바꾸면 순환 종속성이 없어지므로 데드락에 빠지지 않게 된다. 위 takeRoadB를 takeRoadA와 락킹 순서를 통일하게 되면 데드락이 발생하지 않는 것을 확인할 수 있다.

 

출처

나무 위키 - 데드락

Java 멀티스레딩, 병행성 및 성능 최적화 - 전문가 되기