[Java] 24. 동기화(synchronized) 그리고 교착 상태(Deadlock)


Study/Java  2020. 5. 26. 16:49

안녕하세요. 명월입니다.


이 글은 Java에서 동기화(synchronized) 그리고 교착 상태(Deadlock)에 대한 글입니다.


예전 글에서 제가 스레드에 대해서 설명했습니다.

링크 - [Java] 22. 스레드(Thread)를 사용하는 방법

링크 - [Java] 23. 스레드풀(Threadpool)를 사용하는 방법


스레드라는 것은 간단하게 이야기 해서 메인 프로세스에서 독립된 처리 영역을 생성해서 병렬 처리하는 것을 말합니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 실행 함수
  public static void main(String[] args) {
    // 스레드풀 (최대 2개 생성)
    ExecutorService service = Executors.newFixedThreadPool(2);
    // 스레드에서 사용할 람다식
    Runnable func = () -> {
      // 0부터 9까지 반복문
      for (int i = 0; i < 10; i++) {
        // 콘솔 출력
        System.out.println(Thread.currentThread().getName() + " " + i);
        // 스레드 1초 대기
        sleep();
      }
    };
    try {
      // 스레드 실행
      Future<?> f1 = service.submit(func);
      Future<?> f2 = service.submit(func);
      // 스레드 종료될 때까지 대기
      f1.get();
      f2.get();
    } catch (Throwable e) {
      e.printStackTrace();
    }
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
  }
}

위 예제의 스레드에서는 각각의 영역에서 0부터 9까지 그냥 콘솔 출력하는 예제입니다. 여기까지는 전혀 문제가 없습니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
// 테스트 클래스
class Node {
  // 멤버 변수
  public int data = 0;
  // 데이터 저장하기
  public void setData(int data) {
    // 멤버 변수 저장하기
    this.data = data;
  }
  // 변수 값 가져오기
  public int getData() {
    // 멤버 변수 가져오기
    return this.data;
  }
}
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }

  // 실행 함수
  public static void main(String[] args) {
    // 스레드풀 (최대 2개 생성)
    ExecutorService service = Executors.newFixedThreadPool(2);
    // 클로져를 위한 테스트 클래스
    final Node node = new Node();
    // 스레드에서 사용할 람다식
    Runnable func = () -> {
      // 0부터 9까지 반복한다.
      for (int i = 0; i < 10; i++) {
        // 값을 가져와서
        int data = node.getData();
        // i만큼 더한다.
        node.setData(data + i);
        // 스레드 1초 대기
        sleep();
      }
    };
    try {
      // 스레드 실행
      Future<?> f1 = service.submit(func);
      Future<?> f2 = service.submit(func);
      // 스레드 종료될 때까지 대기
      f1.get();
      f2.get();
    } catch (Throwable e) {
      e.printStackTrace();
    }
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
    // 0부터 9까지 2번 더한 값을 출력하라.
    System.out.println("Node data - " + node.getData());
  }
}

여기서 0부터 9까지 더하면 45의 값이 나오는데 스레드로 두번 실행했습니다.

그렇다면 예상값은 90이 나와야 하는데 69라는 값이 나왔습니다. 이유는 병렬 처리의 문제점입니다.

즉, Node에서 getData로 값을 가져와서 i값을 더한다. 첫번째 스레드에서 getData를 했을때 0이였고 i의 값이 1이라고 하면 setData할 때는 1이 됩니다. 그런데 두번째 스레드에서느 getData를 했을때 첫번째 스레드가 setData할 때까지 기다리지 않습니다.

즉, 두번째 스레드에서 getData했을 때 값이 0일 수도 있고 1일 수도 있는 것입니다.


스레드 안에 콘솔 출력을 넣어서 확인해 보면 아래와 같습니다.

보시면 i가 1일 때 첫번째 스레드에서 getData하면 1이 나오고 두번째 스레드에서는 getData할 때 2가 나옵니다. 그러나 i가 2일 때는 첫번째, 두번째 모두 4가 나오네요.

즉 이 두 스레드 간의 동기화가 되지 않았기 때문에 이런 문제가 발생하는 것입니다.


동기화는 간단합니다. 우리는 Node 인스턴스를 사용하니 이 Node 인스턴스에 관해 lock를 설정하겠습니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
// 테스트 클래스
class Node {
  // 멤버 변수
  public int data = 0;
  // 데이터 저장하기
  public void setData(int data) {
    // 멤버 변수 저장하기
    this.data = data;
  }
  // 변수 값 가져오기
  public int getData() {
    // 멤버 변수 가져오기
    return this.data;
  }
}
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 실행 함수
  public static void main(String[] args) {
    // 스레드풀 (최대 2개 생성)
    ExecutorService service = Executors.newFixedThreadPool(2);
    // 클로져를 위한 테스트 클래스
    final Node node = new Node();
    // 스레드에서 사용할 람다식
    Runnable func = () -> {
      // 0부터 9까지 반복한다.
      for (int i = 0; i < 10; i++) {
        // node 인스턴스에 락을 건다.
        synchronized (node) {
          // 값을 가져와서
          int data = node.getData();
          // i만큼 더한다.
          node.setData(data + i);
          // 콘솔 출력
          System.out.println(Thread.currentThread().getName() + " i = " + i + " node.getData() = " + node.getData());
        }
        // 스레드 1초 대기
        sleep();
      }
    };
    try {
      // 스레드 실행
      Future<?> f1 = service.submit(func);
      Future<?> f2 = service.submit(func);
      // 스레드 종료될 때까지 대기
      f1.get();
      f2.get();
    } catch (Throwable e) {
      e.printStackTrace();
    }
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
    // 0부터 9까지 2번 더한 값을 출력하라.
    System.out.println("Node data - " + node.getData());
  }
}

Runnable의 람다식 안에 synchronized 키워드를 사용했습니다.

synchronized는 병렬 처리되는 스레드에서 특정 Object를 동기화 시켜주는 키워드입니다. 즉, node 인스턴스로 설정된 synchronized의 스택 영역에 들어가게 되면 다른 node 인스턴스로 설정된 synchronized에서는 synchronized가 끝나기 전까지 기다리는 역할을 하게 됩니다.

즉, synchronized 영역안의 getData와 setData그리고 콘솔 출력까지는 synchronized 키워드로 동기화가 되서 getData, setData가 직렬 처리가 되어 버립니다.

위 결과처럼 90의 데이터를 가져오게 됩니다.


이 동기화(synchronized)는 멀티 스레드 환경에서 값을 무결하게 공유할 수 있는 기능이 있지만, 잘못된 설계로 두 개의 lock이 서로 간의 리소스를 대기하는 상태의 교착상태(Deadlock)에 빠질 수도 있습니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
// 테스트 클래스
class Node {
  // 멤버 변수
  public int data = 0;
  // 데이터 저장하기
  public void setData(int data) {
    // 멤버 변수 저장하기
    this.data = data;
  }
  // 변수 값 가져오기
  public int getData() {
    // 멤버 변수 가져오기
    return this.data;
  }
}
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 합산 함수
  private static void sum(Node n1, Node n2) {
    // 0부터 9까지 반복한다.
    for (int i = 0; i < 10; i++) {
      // n1에 lock을 건다.
      synchronized (n1) {
        // n2에 lock을 건다.
        synchronized (n2) {
          // 값을 가져와서
          int data = n1.getData();
          // i만큼 더한다.
          n1.setData(data + i);
          // 값을 가져와서
          data = n2.getData();
          // i만큼 더한다.
          n2.setData(data + i);
        }
      }
      // 콘솔 출력
      System.out.println(Thread.currentThread().getName() + " i = " + i);
      // 스레드 1초 대기
      sleep();
    }
  }
  // 실행 함수
  public static void main(String[] args) {
    // 스레드풀 (최대 2개 생성)
    ExecutorService service = Executors.newFixedThreadPool(2);
    // 두개의 값
    final Node node1 = new Node();
    final Node node2 = new Node();
    try {
      // 스레드 실행
      Future<?> f1 = service.submit(() -> {
        sum(node1,node2);
      });
      Future<?> f2 = service.submit(() -> {
        sum(node2,node1);
      });
      // 스레드 종료될 때까지 대기
      f1.get();
      f2.get();
    } catch (Throwable e) {
      e.printStackTrace();
    }
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
    // 0부터 9까지 2번 더한 값을 출력하라.
    System.out.println("Node data - " + node1.getData());
    System.out.println("Node data - " + node2.getData());
  }
}

저는 i가 3에서 교착 상태(Deadlock)에 빠져 프로그램이 멈춰 버렸습니다.

첫번째 스레드에서 sum함수에 node1과 node2를 넣었고 두번째 스레드 node2와 node1를 넣었습니다.

이러면 어떤 현상이 발생하면 첫번째 스레드에서 synchronized(node1)에 lock을 걸고 그와 동시에 두번째 스레드에서 synchronized(node2)에 lock이 걸립니다. 다음 스탭의 첫번째 스레드에서 synchronized(node2)로 들어가려니 두번째 스레드에서 lock이 걸려 있기 때문에 대기(wait)상태로 빠집니다.

두번째 스레드에서는 synchronized(node1)로 들어가려니 첫번째 스레드에서 lock이 걸려 있기 때문에 대기(wait)상태로 빠집니다.

즉, 첫번째나 두번째는 서로가 lock이 풀리지 않는 이상 영원히 기다리는 교착 상태에 빠지게 되는 것입니다.


이런 교착 상태에 빠지지 않기 위해서는 먼저 synchronized안에 synchronized를 만들면 안됩니다. 그러나 이게 쉽지는 않습니다. 위 예제는 제가 이해하기 쉽게 하기 위해 synchronized 안에 바로 synchronized를 작성했지만 함수 안에 함수로 작성된다고 하면 이게 구분하기 어렵습니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
// 테스트 클래스
class Node {
  // 멤버 변수
  public int data = 0;
  // 데이터 저장하기
  public void setData(int data) {
    // 멤버 변수 저장하기
    this.data = data;
  }
  // 변수 값 가져오기
  public int getData() {
    // 멤버 변수 가져오기
    return this.data;
  }
}
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 더하기 함수
  private static void add(Node n1, Node n2, int i) {
    // n2에 lock을 건다.
    synchronized (n2) {
      // 값을 가져와서
      int data = n1.getData();
      // i만큼 더한다.
      n1.setData(data + i);
      // 값을 가져와서
      data = n2.getData();
      // i만큼 더한다.
      n2.setData(data + i);
    }
  }
  // 합산 함수
  private static void sum(Node n1, Node n2) {
    // 0부터 9까지 반복한다.
    for (int i = 0; i < 10; i++) {
      // n1에 lock을 건다.
      synchronized (n1) {
        add(n1, n2, i);
      }
      // 콘솔 출력
      System.out.println(Thread.currentThread().getName() + " i = " + i);
      // 스레드 1초 대기
      sleep();
    }
  }
  // 실행 함수
  public static void main(String[] args) {
    // 스레드풀 (최대 2개 생성)
    ExecutorService service = Executors.newFixedThreadPool(2);
    // 두개의 값
    final Node node1 = new Node();
    final Node node2 = new Node();
    try {
      // 스레드 실행
      Future<?> f1 = service.submit(() -> {
        sum(node1, node2);
      });
      Future<?> f2 = service.submit(() -> {
        sum(node2, node1);
      });
      // 스레드 종료될 때까지 대기
      f1.get();
      f2.get();
    } catch (Throwable e) {
      e.printStackTrace();
    }
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
    // 0부터 9까지 2번 더한 값을 출력하라.
    System.out.println("Node data - " + node1.getData());
    System.out.println("Node data - " + node2.getData());
  }
}

sum함수에서 add함수를 분리한 것 뿐인데도 synchronized 중첩인게 확 눈에 띄지 않습니다. 또 하나의 방법은 synchronized의 중첩을 피할 수 없으면 synchronized의 인스턴스를 하나로 통일하는 방법입니다.

즉, static 변수 하나 만들어서 lock 용 인스턴스로 사용하는 방법입니다. 이 방법은 좀 세밀한 동기화가 되지 않기 때문에 성능이 많이 떨어질 수 있습니다. 그러나 성능은 떨어질지언정 데드락은 확실히 피할 수 있습니다.


synchronized를 넓게 잡는다고 성능이 느려지는 것은 아닙니다만, synchronized영역의 처리가 느리면 성능이 떨어집니다. 그러므로 확실해 데이터 동기화가 필요한 부분만 인스턴스가 아닌 함수 형식으로 동기화하는 것도 데드락을 피하는 방법입니다.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
// 테스트 클래스
class Node {
  // 멤버 변수
  public int data = 0;
  // 데이터 저장하기
  public void setData(int data) {
    // 멤버 변수 저장하기
    this.data = data;
  }
  // 변수 값 가져오기
  public int getData() {
    // 멤버 변수 가져오기
    return this.data;
  }
}
public class Example {
  // 스레드 sleep 함수 (sleep 함수의 Exception 제거용)
  private static void sleep() {
    try {
      // 스레드 1초 대기
      Thread.sleep(1000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
  // 더하기 함수 - 확실히 동기화가 필요한 부분만 synchronized를 사용, synchronized 함수 안에서는 또 다른 synchronized가 없도록 주의
  private static synchronized void add(Node n1, Node n2, int i) {
    // 값을 가져와서
    int data = n1.getData();
    // i만큼 더한다.
    n1.setData(data + i);
    // 값을 가져와서
    data = n2.getData();
    // i만큼 더한다.
    n2.setData(data + i);
  }
  // 합산 함수
  private static void sum(Node n1, Node n2) {
    // 0부터 9까지 반복한다.
    for (int i = 0; i < 10; i++) {
      add(n1, n2, i);
      // 콘솔 출력
      System.out.println(Thread.currentThread().getName() + " i = " + i);
      // 스레드 1초 대기
      sleep();
    }
  }
  // 실행 함수
  public static void main(String[] args) {
    // 스레드풀 (최대 2개 생성)
    ExecutorService service = Executors.newFixedThreadPool(2);
    // 두개의 값
    final Node node1 = new Node();
    final Node node2 = new Node();
    try {
      // 스레드 실행
      Future<?> f1 = service.submit(() -> {
        sum(node1, node2);
      });
      Future<?> f2 = service.submit(() -> {
        sum(node2, node1);
      });
      // 스레드 종료될 때까지 대기
      f1.get();
      f2.get();
    } catch (Throwable e) {
      e.printStackTrace();
    }
    // 스레드풀 안의 스레드가 모두 정상 종료되면 스레드풀 종료하기
    service.shutdown();
    // 0부터 9까지 2번 더한 값을 출력하라.
    System.out.println("Node data - " + node1.getData());
    System.out.println("Node data - " + node2.getData());
  }
}

add 함수에 synchronized를 선언하여 node1과 node2를 동기화하였습니다. 여기서 주의할 점은 add 함수 안에 또 다른 synchronized가 발생하지 않게 하는 것입니다.

이러면 데드락이 발생하지 않고 성능에도 크게 영향이 없습니다.


여기까지 Java에서 동기화(synchronized) 그리고 교착 상태(Deadlock)에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.