[자바 무료 강의] 동기화 - 스레드 - 코드라떼
Lesson List button
코스자바로 배우는 프로그래밍
hamburger button
강의동기화 - 스레드최종수정일 2021-11-21
아이콘약 20분

Java의 Thread는 기본적으로 Mornitor라는 방식으로 Object Key를 이용하여 임계구역에 대해 동기화를 할 수 있도록 처리 되어있습니다. Object Key 명칭대로 모든 Object가 Key가 될 수 있으며 Object Key를 소유한 Thread만이 동기화된 임계구역에 접근할 수 있습니다.

노트 강의

이번 시간에는 Thread의 동기화에 대해 배우는 강의입니다. 내용이 길고 어렵기도 한 만큼 천천히 이해해야 하는 문서형 강의가 적절하다고 판단되어 문서로 강의합니다,


목차


  1. Object key

  2. Mornitor

  3. Lock-Free 동기화 기법

  4. Thread의 Life Cycle

  5. 마치며

1. Object key


Java의 Thread는 기본적으로 Mornitor라는 방식으로 Object Key를 이용하여 임계구역에 대해 동기화를 할 수 있도록 처리 되어있습니다. Object Key 명칭대로 모든 Object가 Key가 될 수 있으며 Object Key를 소유한 Thread만이 동기화된 임계구역에 접근할 수 있습니다.

말로는 어려우니 예시 시나리오를 함꼐 살펴봅시다.


시나리오 1

java mornitor

금고가 하나 있습니다. 금고에 들어가려면 Key를 가지고 있어야 하며 Key를 이용하여 금고의 Lock을 열 수 있습니다. 또한 Lock을 열고 들어가더라도 Key를 소유한 자만 들어갈 수 있습니다.

예시로 Key가 하나이고 한 사람이 Key를 이용하여 Lock을 연다고 하더라도 동시에 두 사람이 들어갈 수 없습니다. 즉 Key 소유자만 들어갈 수 있는 금고입니다. 세 사람은 이러한 특수한 금고에서 돈을 인출하기도 하고 돈을 입금하기도 합니다. 우리는 금고를 Safe라고 하고, 금고에 들어가려는 사람을 Person, 금고에 들어갈 수 있는 Key를 MasterKey라고 하기로 합니다.

먼저 이러한 관계를 객체적으로 스케치 해봅시다.


키 - MasterKey
  1. 내용없음

금고 - Safe

속성

  1. 잔고

  2. MasterKey

행위

  1. 입금된다

  2. 출금된다

항상 금고와 키는 세트입니다. 그러므로 Safe 객체에 MasterKey를 속성으로 가지고 있습니다. 속성을 가지고 있다는 것은 MasterKey 객체에 의존한다는 얘기이므로 MasterKey객체를 먼저 생성해야 합니다.


사람 - Person

속성

  1. Safe

  2. 이름

행위

  1. 돈을 입금한다

  2. 돈을 출금한다

Person 객체는 Safe에 의존적입니다. 그러므로 Safe객체를 먼저 생성해야 합니다.



MasterKey.java

public final class MasterKey {

}

MaskterKey 객체에는 내용이 없습니다. 우리는 그냥 이 객체를 Key로 사용할 것이기 때문에 굳이 내용이 필요 없습니다.



Safe.java

public final class Safe {
    private int money;
    private final MasterKey key;

    public Safe(int money) {
        this.money = money;
        this.key = new MasterKey();
    }

    // TODO : 입금된다
    public void deposit(int money) {
        synchronized (key) {
            this.money += money;
        }
    }

    // TODO : 출금된다
    public int withDraw(int money) {
        synchronized(key) {
            // this.money < money 부분을 동기화한 이유는
            // this.money 변수를 읽을 때
            // 누군가가 key를 이용하여 deposit 메서드를 호출하면
            // 변경되지 않은 값을 읽은 상태로
            // 조건문에서 판단하는 문제가 생기기 때문입니다

            if (this.money < money) {
                System.out.println("잔고가 부족하여 출금할 수 없습니다");
                return 0;
            }
            this.money -= money;
           return money;
        }
    }

    // TODO : 잔고 확인 메서드
    public void printBalance() {
       synchronized(key) {
            System.out.printf("현재 잔고는 %d입니다\n", this.money);
        }
    }
}

Safe에는 임계구역이 있습니다. 여러 사람이 금고에 접근하여 int money 변수의 값을 변경할 예정이므로, int money 변수의 값을 변경하는 deposit(money), withDraw(money) 메서드들이 임계구역 문제를 발생시킬 수 있습니다.

그러므로 우리는 MasterKey를 동기화의 Key로 사용하여 deposit(money), withDraw(money) 메서드를 synchronized statement를 이용하여 동기화시킵니다. synchronized scope를 탈출하면 Thread는 자동으로 key를 반납하게 됩니다.



Person.java

public final class Person {
    private Safe safe;
    private String name;

    public Person(String name, Safe safe) {
        this.name = name;
        this.safe = safe;
    }

    // TODO : 입금한다
    public void deposit(int money) {
        safe.deposit(money);
        System.out.printf("%s가 %d를 입금했습니다.\n", name, money);
    }

    // TODO : 출금한다
    public void withDraw(int money) {
        int withDrawnMoney = safe.withDraw(money);
        System.out.printf("%s가 %d를 출금했습니다.\n", name, withDrawnMoney);
    }
}

Person 객체가 하는 일은 간단합니다. 금고를 이용하여 입금하고 출금하면 됩니다.



Main.java

public class Main {

    public void static main(String[] args) throws InterruptedException {
        Safe safe = new Safe(50000);

        Person personA = new Person("PersonA", safe);
        Person personB = new Person("PersonB", safe);

        // Lambda 방식으로 Runnable 익명 클래스 생성
        Thread threadA = new Thread(() -> {
             personA.withDraw(1000);
             personA.withDraw(3000);
             personA.deposit(10000);
             personA.withDraw(2000);
             personA.withDraw(3000);
       });

        threadA.start();

        // Lambda 방식으로 Runnable 익명 클래스 생성
        Thread threadB = new Thread(() -> {
             personB.withDraw(4000);
             personB.deposit(1000);
             personB.withDraw(1000);
             personB.withDraw(1000);
             personB.withDraw(1000);
       });

        threadB.start();

        threadA.join();
        threadB.join();
        // 잔고확인 출력
        safe.printBalance();
    }

}

main 메서드는 Safe 인스턴스를 생성하고 최초 잔고를 50000으로 설정합니다. 그리고 Person 객체를 생성하며 Person의 이름과, Safe 인스턴스를 전달합니다.

Thread에는 Runnable를 구현하는 익명 클래스를 생성하되, Lambda 방식의 표기법으로 작업을 실행하도록 합시다. 그러면 각 Thread는 Person의 작업을 담당하여 입금과 인출을 시행합니다. Thread가 실행되면 Person 객체를 통해 Safe 객체의 메서드를 호출하며, Object Key를 소유하려고 경쟁합니다. 먼저 Object Key를 얻은 Thread가 해당 메서드의 내용을 실행하고 메서드가 끝나면 Object Key를 반납합니다. 그리고 반납된 Object Key를 얻은 Thread가 메서드를 실행하며 반복됩니다.


2. Mornitor


java Mornitor

Mornitor자바에서 사용하는 기본적인 동기화 방식입니다. Mornitor 방식은 Key를 이용하여 동기화된 임계구역에 들어갈 수 있고 동기화된 임계구역에는 Key를 가진 Thread만 접근가능합니다.

Mornitor의 기본 구성요소는 Entry-SetKey Owner가 존재하며, Wait-Set이라는 구역이 있습니다.


Key Section

공식적인 명칭은 아니지만, 이해를 쉽도록 돕기 위해 Key Section(키 구역) 이라고 명명했습니다. Key Section에는 Entry SetKey Owner, Wait-Set 구역이 존재합니다. Key Section은 Object Key와 연관이 있으며 Object Key당 Key Section이라고 볼 수 있습니다. 예시로 두 개의 Object Key가 존재한다고 가정합니다.

AKey keyA = new AKey();
BKey keyB = new BKey();
synchronized (keyA) {
    // AKey Section
}synchronized (keyB) {
    // BKey Section
}

Key가 다르면 서로 다른 Key Section이며 동일한 Key인 경우에만 동일한 Key Section이라고 볼 수 있습니다.


Entry-Set

여러 Thread들이 Key Owner에 들어가기 위한 Key를 얻기 위해 대기하는 구역입니다. Key Owner에서 특정 Thread가 진입해 있는 경우 Entry-Set에 대기하는 모든 Thread들은 다른 작업을 하지 못합니다. 이 상태를 Blocking 상태라고 하며 들어가기 위해 줄을 서고 있는것과 같습니다. 그리고 Entry-Set에서 Key를 얻기 위해 서로 경합합니다. Key Owner에 있는 Thread가 Key를 반납해야지 들어갈 수 있습니다.


Key Owner

임계구역 문제를 해결하기 위해 Key Owner 구역을 만듭니다. Key Owner 구역은 Key를 이용하여 만들어집니다.

Key Owner 구역에 들어가 있는 Thread는 크게 두 가지 행동을 할 수 있습니다. 작업을 마치고 Key를 반납 후 Key Owner 구역을 빠져나와 Key Section을 탈출하거나, 또는 Key를 반납 후 Wait-Set 구역에 들어가서 대기할 수 있습니다.

synchronized scope를 탈출하면 자동적으로 Key를 반납하고 떠나며, 만약에 synchronized scope내에서 wait() 라는 특정 메서드를 호출하면 Key를 반납하고 Wait-Set 구역에서 대기합니다.

어떤 경우든 Key Owner에는 Key를 가지고 있는 유일한 Thread만 접근 가능합니다.


Wait-Set

Wait-Set은 Key Owner에서 wait() 메서드를 호출한 경우 Wait-Set 구역에 대기합니다. 대기하는 동안은 해당 Thread는 다른 작업을 하지 못합니다. Thread가 Wait 상태가 되면 특정 Thread가 Wait Thread를 깨워줄 때 까지 계속해서 대기하고 있습니다.

이 상태를 Wating 상태라고 합니다. 그리고 특정 Thread가 notify() 메서드 또는 notifyAll() 메서드를 호출할 경우 Wait Thread는 Wait 상태를 벗어나 Key Owner 구역에 다시 들어가기 위해 기다립니다.

이 상태를 Blocking 상태라고 합니다. Blocking 상태가 되면 Entry-Set에 있는 Thread들과 Key를 소유하기 위해 서로 경합합니다.어떤 Thread가 먼저 Key를 소유할지 알 수 없습니다.

Key Owner 구역에 들어간 Thread를 대기하는 이유는 특정 목적에 의해 사용할 경우가 있기 때문입니다.


시나리오 2

java mornitor

금고가 하나 있습니다. 금고에는 여전히 Key를 소유한 한 사람만 들어갈 수 있습니다. 그리고 두 사람이 존재합니다. 다만 두 사람의 행위는 다릅니다. 한 사람은 입금만 하고 또 다른 사람은 출금만 할 수 있습니다.

여기서 생각해볼 문제는 잔고가 없는데 인출하려고 하는 경우의 문제가 발생합니다.

정상적인 시나리오일 경우 이런 상황이 되어야 합니다.

  1. 잔고는 0 이다

  2. 입금자가 10000원을 입금한다.

  3. 출금자가 5000원을 출금한다.

  4. 입금자가 2000원을 입금한다.

  5. 출금자가 7000원을 출금한다.

그러나 비정상적인 시나리오는 이렇게 발생할 수 있습니다.

  1. 잔고는 0 이다

  2. 출금자가 5000원을 출금한다.

  3. 입금자가 10000원을 입금한다.

  4. 출금자가 7000원을 출금한다.

  5. 입금자가 2000원을 입금한다.

이런 상황이 발생하면 굉장히 난감한 상황입니다. 주식의 공매도 같은 느낌입니다. 잔고가 0인데 출금자가 5천원을 출금할 수 없으나 출금을 하는 경우가 발생합니다. 실제로 이런 상황이 발생하면 전산상으로 큰일납니다. 그렇기 때문에 적어도 출금자는 원하는 출금 금액이 입금될 때 까지 기다려야 합니다. 다만 언제까지 기다릴지는 모릅니다.

먼저 이러한 관계를 객체적으로 스케치 해봅시다.


키 - MoneySafeKey
  1. 내용없음

금고 - MoneySafe

속성

  1. 잔고

  2. MoneySafeKey

행위

  1. 입금된다

  2. 출금된다

항상 금고와 키는 세트입니다. 그러므로 MoneySafe 객체에 MoneySafeKey를 속성으로 가지고 있습니다. 속성을 가지고 있다는 것은 MoneySafeKey 객체에 의존한다는 얘기이므로 MoneySafeKey객체를 먼저 생성해야 합니다.


입금만 하는 사람 - DepositPerson

속성

  1. MoneySafe

  2. 이름

행위

  1. 돈을 입금한다

DepositPerson은 돈을 입금만 하는 사람입니다. DepositPerson 객체는 MoneySafe에 의존적입니다. 그러므로 MoneySafe객체를 먼저 생성해야 합니다.


출금만 하는 사람 - WithdrawPerson

속성

  1. MoneySafe

  2. 이름

행위

  1. 돈을 출금한다

WithdrawPerson은 돈을 출금만 하는 사람입니다. WithdrawPerson 객체는 MoneySafe에 의존적입니다. 그러므로 MoneySafe객체를 먼저 생성해야 합니다.



MoneySafeKey.java

public final class MoneySafeKey {

}

시나리오 1과 동일하므로 설명은 생략하겠습니다.



MoneySafe.java

public final class MoneySafe {
    private int money;
    private final MoneySafeKey key;

    public MoneySafe(int money) {
        this.money = money;
        this.key = new MoneySafeKey();
    }

    // TODO : 입금된다
    public void deposit(int money, String name) {
        // synchronized 는 MoneySafeKey Owner 구역이다
        synchronized (key) {
            this.money += money;
            System.out.printf("%s가 %d를 입금했습니다.\n", name, money);

           // TODO : MoneySafeKey key Section의 
           // Wait-Set 구역에 있는 모든 스레드를 깨운다.
           key.notifyAll();
        }
    }

    // TODO : 출금된다
    public void withDraw(int money, String name) {
        // synchronized 는 MoneySafeKey Owner 구역이다
        synchronized(key) {
            try {
                while (this.money < money) {
                    // TODO : 잔고에 출금하려는 돈보다 적으면 일단 기다려야 한다.
                    // MoneySafeKey key Section에 있는
                    // Key Owner 구역에 있는 스레드를 Wait-Set 구역으로 이동시킨다.
                    key.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.money -= money;
            System.out.printf("%s가 %d를 출금했습니다.\n", name, withDrawnMoney);
        }
    }

    // TODO : 잔고 확인 메서드
    public void printBalance() {
       synchronized(key) {
            System.out.printf("현재 잔고는 %d입니다\n", this.money);
        }
    }
}

시나리오 1과 유사해보이지만 다른점이 있습니다.


출금을 담당하는 Thread가 withDraw() 메서드를 호출시 출금하려는 금액보다 잔고의 금액이 적은 경우 key.wait() 메서드를 호출하여 Key Owner 구역에서 Key를 반납하고 그 즉시 Wait-Set 구역으로 이동합니다.


만약에 출금을 담당하는 Thread가 Wait-Set 구역에서 notify() 메서드를 통해 Wait 상태가 아닌 Blocking 상태가 되고 다시 Key를 소유했다 하더라도 while 반복문을 통해 잔고의 금액과 출금하려는 금액을 비교후 잔고가 부족한경우 다시 Wait-Set 구역으로 이동합니다.


입금을 담당하는 Thread가 deposit() 메서드를 호출시 int money 변수를 변경 후 key.notifyAll() 메서드를 호출합니다.


notifyAll() 메서드는 Key Section의 Wait-Set 구역에 있는 모든 Thread를 깨워 Wait 상태에서 Blocking 상태로 변경합니다.


Blocking 상태가 된 Thread는 Entry-Set에 있는 Thread와 Key를 소유하기 위해 경합합니다. 위의 코드에서는 경합에서 승리하여 Key를 소유한 Thread는 key.wait() 코드 다음의 코드부터 다시 실행됩니다.

NOTE! key.wait()나, key.notifyAll() 메서드는 synchronized scope 내에서만 사용가능합니다.

DepositPerson.java

public final class DepositPerson {
    private MoneySafe safe;
    private String name;

    public Person(String name, MoneySafe safe) {
        this.name = name;
        this.safe = safe;
    }

    // TODO : 입금한다
    public void deposit(int money) {
        safe.deposit(money, name);
   }
}

DepositPerson 객체가 하는 일은 돈을 입금만 합니다.



WithdrawPerson.java

public final class WithdrawPerson {
    private MoneySafe safe;
    private String name;

    public Person(String name, MoneySafe safe) {
        this.name = name;
        this.safe = safe;
    }

    // TODO : 출금한다
    public void withDraw(int money) {
        safe.withDraw(money, name);
   }
}

WithDrawPerson 객체가 하는 일은 돈을 출금만 합니다.



Main.java

public class Main {

    public void static main(String[] args) throws InterruptedException {
        MoneySafe safe = new MoneySafe(0);

        WithdrawPerson personA = new Person("출금자", safe);
        DepositPerson personB = new Person("입금자", safe);

        // Lambda 방식으로 Runnable 익명 클래스 생성
        Thread threadA = new Thread(() -> {
             personA.withDraw(1000);
             personA.withDraw(500);
             personA.withDraw(1000);
             personA.withDraw(2000);
       });
        threadA.start();

        // Lambda 방식으로 Runnable 익명 클래스 생성
        Thread threadB = new Thread(() -> {
            personB.deposit(1000);
            personB.deposit(1000);
            personB.deposit(2000);
            personB.deposit(3000);
            personB.deposit(5000);
       });
        threadB.start();

        threadA.join();
        threadB.join();
        // 잔고확인 출력
        safe.printBalance();
    }

}

시나리오 1과 다른 부분은, MoneySafe의 잔고가 0이라는 것과 ThreadA는 출금의 행위만, ThreadB는 입금의 행위만 한다는 점입니다. 각 Thread는 순서와 상관없이 경쟁적으로 Key를 점유하려고 할 것이고 상황에 따라 출력이 변경될겁니다.


출력

입금자가 1000를 입금했습니다.
입금자가 1000를 입금했습니다.
입금자가 2000를 입금했습니다.
입금자가 3000를 입금했습니다.
입금자가 5000를 입금했습니다.
출금자가 1000를 출금했습니다.
출금자가 500를 출금했습니다.
출금자가 1000를 출금했습니다.
출금자가 2000를 출금했습니다.
현재 잔고는 7500입니다

제 컴퓨터에서는 출금 관련 Thread가 먼저 실행되나 잔고의 부족으로 key.wait() 메서드를 호출하며 Wait-Set 상태가 되고, 그 이후에 입금 관련 Thread가 Key 자원을 대부분 점유해서 출금 관련 Thread가 Key를 점유하지 못하는 상황이 됬습니다.


출력결과는 실행 상황에 따라 다른 결과를 출력할 겁니다. 다만 확실히 보증되는건, 잔고가 부족한데 출금자가 돈을 출금할 수는 없습니다.


wait()를 사용하지 않는 경우

java mornitor 2

wait()를 사용하지 않는다면 단순히 Entry-Set과 Key Owner 구역만 사용하는 것과 같습니다. Wait-Set이 꼭 필요한것은 아닙니다. 필요한 상황에 맞게 적절하게 사용하면 됩니다.


3. Lock-Free 동기화 기법


우리는 Java의 동기화 기법중 Mornitor를 이용한 동기화를 하는법을 확인했습니다. Java에는 Thread를 재우고 다시 실행시킬 수 있는 또 다른 도구를 제공합니다.

LockSupport.park();
LockSupport.unpark(thread);

Mornitor를 이용한 기법은 Key Section에서 Entry-Set, Key Owner, Wait-Set 구역으로 나뉘어있고 Object Key를 이용한 방법을 사용했었습니다.


그러나 Object Key를 사용하지 않는 다른 방법도 있습니다. 이번에 소개하는 동기화 도구는 Lock-Free 방식입니다. Lock-Free 방식을 사용하면 synchronized method, synchronized statement 방식을 사용하지 않아도 됩니다.


LockSupport 클래스에는 동기화를 위한 몇 가지 도구가 존재합니다. 그러나 로우한 메서드라서 특정상황이 아니면 직접 사용할 일은 많지 않습니다.

LockSupport 클래스를 이용하여 만들어진 Semaphore를 사용하면 됩니다. 그러므로 특정한 목적이 아니라면 Mornitor를 이용한 동기화 방식을 추천드립니다.


그래도 이런것은 있다는 것을 보여드려야 하므로 소개해드립니다.

LockSupport.park() 메서드를 호출하는 스레드는 대기 상태가 됩니다.

LockSupport.unpark(thread) 메서드는 thread 매개변수를 통해 Parked 된 Thread를 깨울 수 있습니다. 즉, 대기 상태가 된 Thread의 정보를 알아야지 깨울 수 있습니다.


시나리오 3

내용은 시나리오 2와 동일합니다. 다만 Mornitor 방식을 통한 동기화가 아닌 Lock-Free 방식을 이용하여 코드를 작성하면 다음과 같습니다.


MoneySafe.java

public final class MoneySafe {
    private int money;

    // TODO : thread를 담는 Queue
    private final Queue<Thread> threadQueue;

    public MoneySafe(int money) {
        this.money = money;
        this.threadQueue = new LinkedList<>();
    }

    // TODO : 입금된다
    public void deposit(int money, String name) {
          this.money += money;
          System.out.printf("%s가 %d를 입금했습니다.\n", name, money);

          while (!threadQueue.isEmpty()) {
              // TODO : queue에 저장된 Thread를 꺼낸 후
              // Thread를 깨운다.
              LockSupport.unpark(threadQueue.poll());
          }
    }

    // TODO : 출금된다
    public void withDraw(int money, String name) {
            while (this.money < money) {
                   // TODO : 잔고에 출금하려는 돈보다 적으면 일단 기다려야 한다.

                   // TODO : 현재 이 메서드를 실행하고 있는 Thread를
                   // thread 인스턴스를 보관하는 queue에 저장한다.
                   threadQueue.add(Thread.currentThread());

                   // TODO : 현재 실행되고 있는 Thread를 대기시킨다.
                   LockSupport.park();
            }

            this.money -= money;
            System.out.printf("%s가 %d를 출금했습니다.\n", name, withDrawnMoney);
        }
    }

    // TODO : 잔고 확인 메서드
    public void printBalance() {
        System.out.printf("현재 잔고는 %d입니다\n", this.money);
    }
}

withDraw() 메서드를 호출하는 Thread의 정보를 저장해야지 LockSupport.unpark(thread) 메서드를 호출할 수 있으므로 Thread를 저장하는 Queue를 생성합니다.


그리고 withDraw() 메서드 호출시 잔고가 부족하다면 해당 메서드를 실행하는 Thread 인스턴스를 반환하는 Thread.currentThread() 정적 메서드를 호출하여 반환 받은 Thread 인스턴스를 Queue에 저장합니다. 그리고 LockSupport.park() 메서드를 이용하여 Thread를 대기시킵니다.


deposit() 메서드를 호출하는 Thread는 Queue에 Thread 인스턴스가 저장되어 있다면 LockSupport.unpark(thread)를 호출하여 깨웁니다. 반복문을 통하여 Queue에 저장된 모든 인스턴스를 다 깨우는 역할을 합니다.


4. Thread의 Life Cycle


Thread Life Cycle

여태까지 배운 동기화 또는 Thread의 상태를 변경하는 메서드를 종합한 Metrics 입니다. Thread의 상태는 총 6가지이며, Thread의 상태를 변경하는 명시적 호출이나, timeout에 따라 상태가 변경됩니다.

  • New - new 키워드로 Thread 인스턴스가 생성되고 실제로 커널 스레드를 생성하지 않은 상태입니다.

  • Runnable - 인스턴스 Thread의 start() 메서드를 호출시 Runnable 상태가 됩니다. 즉 작업을 실행하고 있는 상태입니다.

  • TimedWating - 지정된 특정 시간까지 Wating하는 상태입니다.

  • Thread.sleep(time) 정적 메서드 호출하면 time 만큼 TimedWating 상태가 됩니다.


    Mornitor 방식의 동기화와 관련된 key.wait(time) 인스턴스 메서드 호출도 time 만큼 TimedWating 상태가 됩니다.

    th.join(timeout) 인스턴스 메서드도 내부적으로는 Thread Object 자체를 Key로 사용하기 때문에 key.wait(time) 인스턴스 메서드를 호출하여 time 만큼 TimedWating 상태가 됩니다.

  • Wating - 지정된 시간이 존재하지 않고 외부에서 깨워주어야만 Wating 상태를 벗어납니다.

  • LockSupport.park() 메서드로 Wating 상태가 될 수 있으며, 외부의 특정 스레드가 LockSupport.unpark(thread) 메서드를 호출해야지 다시 Runnable 상태가 됩니다.


    Mornitor 방식의 동기화와 관련된 key.wait() 인스턴스 메서드를 호출한 현재 스레드는 Wating 상태가 됩니다.

    th.join() 인스턴스 메서드도 내부적으로는 Thread Object 자체를 Key로 사용하기 때문에 key.wait() 인스턴스 메서드를 호출한 현재 스레드는 특정 Thread의 작업이 끝날때 까지 Wating 상태가 됩니다

  • Blocked - Mornitor 방식의 동기화에서 TImedWating, Wating 상태에서 벗어난 thread는 먼저 key를 소유하기 위해 Blocked 상태가 됩니다.

  • Key를 소유하는 경우 Runnable 상태가 됩니다.

*Thread의 상태를 변경시키는 메서드는 더 다양합니다. 그러나 이정도만 알고 있어도 충분합니다. 지금도 많습니다.


5. 마치며


Java의 동기화 도구는 생각보다 더 많습니다. 그러나 모든 동기화 도구를 지금 당장 알 필요는 없습니다. 필요하면 그때 찾아서 사용하면 되기 때문입니다. 그리고 이미 알고있는 도구로도 충분히 동기화를 할 수 있습니다. 또한 간단한 동기화는 synchronized를 사용하면 되므로 동기화 방식에 집중하기 보다는 동기화가 어떻게 이루어지는지, 임계 구역을 식별하는 눈을 가지는 것이 더 중요합니다.


변경되지 않는 공유자원은(읽기만 하는) 동기화가 필요 없습니다. 동기화로 인해 Thread가 대기하는 시간이 발생하며 작업 속도가 느려지는 병목 현상(DeadLock)이 발생할 수 있습니다. 그러므로 Thread 잘 사용하는것은 쉽지 않습니다. 많은 경험을 통해서 적재 적소에 사용하게 되므로 부단히 고민하고 또 고민하고 사용해보시길 바랍니다.

강의와 관련된 코드는 실습도구에 있으므로, 천천히 살펴보시며 눈을 코드를 바라보며 머리속으로 생각하면서 파악해보셔야 합니다.

도전자 질문
아이콘루트(2022-10-01 10:16 작성됨)
Lock-free의 경우에 스레드큐에 있는 스레드를 꺼내고 넣는 과정도 race condition이 발생할 수 있지 않나요? 그에 대한 처리도 해야되는 같은데
안해도 제대로 동작하네요 제가 이해를 잘못하고 있는걸까요?
이용약관|개인정보취급방침
알유티씨클래스|대표, 개인정보보호책임자 : 이병록
이메일 : cs@codelatte.io
사업자등록번호 : 824-06-01921
통신판매업신고 : 2021-성남분당C-0740
주소 : 경기도 성남시 분당구 대왕판교로645번길 12, 9층 24호
파일
파일파일
Root
파일

강의 내용에 있었던 시나리오입니다. 하나씩 주석을 풀어서 실행해보세요. 1. Scenario1.run(); 2. Scenario2.run(); 3. Scenario3.run();

Output
root$
Lesson List button
코스자바로 배우는 프로그래밍
hamburger button
강의동기화 - 스레드최종수정일 2021-11-21
아이콘약 20분

Java의 Thread는 기본적으로 Mornitor라는 방식으로 Object Key를 이용하여 임계구역에 대해 동기화를 할 수 있도록 처리 되어있습니다. Object Key 명칭대로 모든 Object가 Key가 될 수 있으며 Object Key를 소유한 Thread만이 동기화된 임계구역에 접근할 수 있습니다.

노트 강의

이번 시간에는 Thread의 동기화에 대해 배우는 강의입니다. 내용이 길고 어렵기도 한 만큼 천천히 이해해야 하는 문서형 강의가 적절하다고 판단되어 문서로 강의합니다,


목차


  1. Object key

  2. Mornitor

  3. Lock-Free 동기화 기법

  4. Thread의 Life Cycle

  5. 마치며

1. Object key


Java의 Thread는 기본적으로 Mornitor라는 방식으로 Object Key를 이용하여 임계구역에 대해 동기화를 할 수 있도록 처리 되어있습니다. Object Key 명칭대로 모든 Object가 Key가 될 수 있으며 Object Key를 소유한 Thread만이 동기화된 임계구역에 접근할 수 있습니다.

말로는 어려우니 예시 시나리오를 함꼐 살펴봅시다.


시나리오 1

java mornitor

금고가 하나 있습니다. 금고에 들어가려면 Key를 가지고 있어야 하며 Key를 이용하여 금고의 Lock을 열 수 있습니다. 또한 Lock을 열고 들어가더라도 Key를 소유한 자만 들어갈 수 있습니다.

예시로 Key가 하나이고 한 사람이 Key를 이용하여 Lock을 연다고 하더라도 동시에 두 사람이 들어갈 수 없습니다. 즉 Key 소유자만 들어갈 수 있는 금고입니다. 세 사람은 이러한 특수한 금고에서 돈을 인출하기도 하고 돈을 입금하기도 합니다. 우리는 금고를 Safe라고 하고, 금고에 들어가려는 사람을 Person, 금고에 들어갈 수 있는 Key를 MasterKey라고 하기로 합니다.

먼저 이러한 관계를 객체적으로 스케치 해봅시다.


키 - MasterKey
  1. 내용없음

금고 - Safe

속성

  1. 잔고

  2. MasterKey

행위

  1. 입금된다

  2. 출금된다

항상 금고와 키는 세트입니다. 그러므로 Safe 객체에 MasterKey를 속성으로 가지고 있습니다. 속성을 가지고 있다는 것은 MasterKey 객체에 의존한다는 얘기이므로 MasterKey객체를 먼저 생성해야 합니다.


사람 - Person

속성

  1. Safe

  2. 이름

행위

  1. 돈을 입금한다

  2. 돈을 출금한다

Person 객체는 Safe에 의존적입니다. 그러므로 Safe객체를 먼저 생성해야 합니다.



MasterKey.java

public final class MasterKey {

}

MaskterKey 객체에는 내용이 없습니다. 우리는 그냥 이 객체를 Key로 사용할 것이기 때문에 굳이 내용이 필요 없습니다.



Safe.java

public final class Safe {
    private int money;
    private final MasterKey key;

    public Safe(int money) {
        this.money = money;
        this.key = new MasterKey();
    }

    // TODO : 입금된다
    public void deposit(int money) {
        synchronized (key) {
            this.money += money;
        }
    }

    // TODO : 출금된다
    public int withDraw(int money) {
        synchronized(key) {
            // this.money < money 부분을 동기화한 이유는
            // this.money 변수를 읽을 때
            // 누군가가 key를 이용하여 deposit 메서드를 호출하면
            // 변경되지 않은 값을 읽은 상태로
            // 조건문에서 판단하는 문제가 생기기 때문입니다

            if (this.money < money) {
                System.out.println("잔고가 부족하여 출금할 수 없습니다");
                return 0;
            }
            this.money -= money;
           return money;
        }
    }

    // TODO : 잔고 확인 메서드
    public void printBalance() {
       synchronized(key) {
            System.out.printf("현재 잔고는 %d입니다\n", this.money);
        }
    }
}

Safe에는 임계구역이 있습니다. 여러 사람이 금고에 접근하여 int money 변수의 값을 변경할 예정이므로, int money 변수의 값을 변경하는 deposit(money), withDraw(money) 메서드들이 임계구역 문제를 발생시킬 수 있습니다.

그러므로 우리는 MasterKey를 동기화의 Key로 사용하여 deposit(money), withDraw(money) 메서드를 synchronized statement를 이용하여 동기화시킵니다. synchronized scope를 탈출하면 Thread는 자동으로 key를 반납하게 됩니다.



Person.java

public final class Person {
    private Safe safe;
    private String name;

    public Person(String name, Safe safe) {
        this.name = name;
        this.safe = safe;
    }

    // TODO : 입금한다
    public void deposit(int money) {
        safe.deposit(money);
        System.out.printf("%s가 %d를 입금했습니다.\n", name, money);
    }

    // TODO : 출금한다
    public void withDraw(int money) {
        int withDrawnMoney = safe.withDraw(money);
        System.out.printf("%s가 %d를 출금했습니다.\n", name, withDrawnMoney);
    }
}

Person 객체가 하는 일은 간단합니다. 금고를 이용하여 입금하고 출금하면 됩니다.



Main.java

public class Main {

    public void static main(String[] args) throws InterruptedException {
        Safe safe = new Safe(50000);

        Person personA = new Person("PersonA", safe);
        Person personB = new Person("PersonB", safe);

        // Lambda 방식으로 Runnable 익명 클래스 생성
        Thread threadA = new Thread(() -> {
             personA.withDraw(1000);
             personA.withDraw(3000);
             personA.deposit(10000);
             personA.withDraw(2000);
             personA.withDraw(3000);
       });

        threadA.start();

        // Lambda 방식으로 Runnable 익명 클래스 생성
        Thread threadB = new Thread(() -> {
             personB.withDraw(4000);
             personB.deposit(1000);
             personB.withDraw(1000);
             personB.withDraw(1000);
             personB.withDraw(1000);
       });

        threadB.start();

        threadA.join();
        threadB.join();
        // 잔고확인 출력
        safe.printBalance();
    }

}

main 메서드는 Safe 인스턴스를 생성하고 최초 잔고를 50000으로 설정합니다. 그리고 Person 객체를 생성하며 Person의 이름과, Safe 인스턴스를 전달합니다.

Thread에는 Runnable를 구현하는 익명 클래스를 생성하되, Lambda 방식의 표기법으로 작업을 실행하도록 합시다. 그러면 각 Thread는 Person의 작업을 담당하여 입금과 인출을 시행합니다. Thread가 실행되면 Person 객체를 통해 Safe 객체의 메서드를 호출하며, Object Key를 소유하려고 경쟁합니다. 먼저 Object Key를 얻은 Thread가 해당 메서드의 내용을 실행하고 메서드가 끝나면 Object Key를 반납합니다. 그리고 반납된 Object Key를 얻은 Thread가 메서드를 실행하며 반복됩니다.


2. Mornitor


java Mornitor

Mornitor자바에서 사용하는 기본적인 동기화 방식입니다. Mornitor 방식은 Key를 이용하여 동기화된 임계구역에 들어갈 수 있고 동기화된 임계구역에는 Key를 가진 Thread만 접근가능합니다.

Mornitor의 기본 구성요소는 Entry-SetKey Owner가 존재하며, Wait-Set이라는 구역이 있습니다.


Key Section

공식적인 명칭은 아니지만, 이해를 쉽도록 돕기 위해 Key Section(키 구역) 이라고 명명했습니다. Key Section에는 Entry SetKey Owner, Wait-Set 구역이 존재합니다. Key Section은 Object Key와 연관이 있으며 Object Key당 Key Section이라고 볼 수 있습니다. 예시로 두 개의 Object Key가 존재한다고 가정합니다.

AKey keyA = new AKey();
BKey keyB = new BKey();
synchronized (keyA) {
    // AKey Section
}synchronized (keyB) {
    // BKey Section
}

Key가 다르면 서로 다른 Key Section이며 동일한 Key인 경우에만 동일한 Key Section이라고 볼 수 있습니다.


Entry-Set

여러 Thread들이 Key Owner에 들어가기 위한 Key를 얻기 위해 대기하는 구역입니다. Key Owner에서 특정 Thread가 진입해 있는 경우 Entry-Set에 대기하는 모든 Thread들은 다른 작업을 하지 못합니다. 이 상태를 Blocking 상태라고 하며 들어가기 위해 줄을 서고 있는것과 같습니다. 그리고 Entry-Set에서 Key를 얻기 위해 서로 경합합니다. Key Owner에 있는 Thread가 Key를 반납해야지 들어갈 수 있습니다.


Key Owner

임계구역 문제를 해결하기 위해 Key Owner 구역을 만듭니다. Key Owner 구역은 Key를 이용하여 만들어집니다.

Key Owner 구역에 들어가 있는 Thread는 크게 두 가지 행동을 할 수 있습니다. 작업을 마치고 Key를 반납 후 Key Owner 구역을 빠져나와 Key Section을 탈출하거나, 또는 Key를 반납 후 Wait-Set 구역에 들어가서 대기할 수 있습니다.

synchronized scope를 탈출하면 자동적으로 Key를 반납하고 떠나며, 만약에 synchronized scope내에서 wait() 라는 특정 메서드를 호출하면 Key를 반납하고 Wait-Set 구역에서 대기합니다.

어떤 경우든 Key Owner에는 Key를 가지고 있는 유일한 Thread만 접근 가능합니다.


Wait-Set

Wait-Set은 Key Owner에서 wait() 메서드를 호출한 경우 Wait-Set 구역에 대기합니다. 대기하는 동안은 해당 Thread는 다른 작업을 하지 못합니다. Thread가 Wait 상태가 되면 특정 Thread가 Wait Thread를 깨워줄 때 까지 계속해서 대기하고 있습니다.

이 상태를 Wating 상태라고 합니다. 그리고 특정 Thread가 notify() 메서드 또는 notifyAll() 메서드를 호출할 경우 Wait Thread는 Wait 상태를 벗어나 Key Owner 구역에 다시 들어가기 위해 기다립니다.

이 상태를 Blocking 상태라고 합니다. Blocking 상태가 되면 Entry-Set에 있는 Thread들과 Key를 소유하기 위해 서로 경합합니다.어떤 Thread가 먼저 Key를 소유할지 알 수 없습니다.

Key Owner 구역에 들어간 Thread를 대기하는 이유는 특정 목적에 의해 사용할 경우가 있기 때문입니다.


시나리오 2

java mornitor

금고가 하나 있습니다. 금고에는 여전히 Key를 소유한 한 사람만 들어갈 수 있습니다. 그리고 두 사람이 존재합니다. 다만 두 사람의 행위는 다릅니다. 한 사람은 입금만 하고 또 다른 사람은 출금만 할 수 있습니다.

여기서 생각해볼 문제는 잔고가 없는데 인출하려고 하는 경우의 문제가 발생합니다.

정상적인 시나리오일 경우 이런 상황이 되어야 합니다.

  1. 잔고는 0 이다

  2. 입금자가 10000원을 입금한다.

  3. 출금자가 5000원을 출금한다.

  4. 입금자가 2000원을 입금한다.

  5. 출금자가 7000원을 출금한다.

그러나 비정상적인 시나리오는 이렇게 발생할 수 있습니다.

  1. 잔고는 0 이다

  2. 출금자가 5000원을 출금한다.

  3. 입금자가 10000원을 입금한다.

  4. 출금자가 7000원을 출금한다.

  5. 입금자가 2000원을 입금한다.

이런 상황이 발생하면 굉장히 난감한 상황입니다. 주식의 공매도 같은 느낌입니다. 잔고가 0인데 출금자가 5천원을 출금할 수 없으나 출금을 하는 경우가 발생합니다. 실제로 이런 상황이 발생하면 전산상으로 큰일납니다. 그렇기 때문에 적어도 출금자는 원하는 출금 금액이 입금될 때 까지 기다려야 합니다. 다만 언제까지 기다릴지는 모릅니다.

먼저 이러한 관계를 객체적으로 스케치 해봅시다.


키 - MoneySafeKey
  1. 내용없음

금고 - MoneySafe

속성

  1. 잔고

  2. MoneySafeKey

행위

  1. 입금된다

  2. 출금된다

항상 금고와 키는 세트입니다. 그러므로 MoneySafe 객체에 MoneySafeKey를 속성으로 가지고 있습니다. 속성을 가지고 있다는 것은 MoneySafeKey 객체에 의존한다는 얘기이므로 MoneySafeKey객체를 먼저 생성해야 합니다.


입금만 하는 사람 - DepositPerson

속성

  1. MoneySafe

  2. 이름

행위

  1. 돈을 입금한다

DepositPerson은 돈을 입금만 하는 사람입니다. DepositPerson 객체는 MoneySafe에 의존적입니다. 그러므로 MoneySafe객체를 먼저 생성해야 합니다.


출금만 하는 사람 - WithdrawPerson

속성

  1. MoneySafe

  2. 이름

행위

  1. 돈을 출금한다

WithdrawPerson은 돈을 출금만 하는 사람입니다. WithdrawPerson 객체는 MoneySafe에 의존적입니다. 그러므로 MoneySafe객체를 먼저 생성해야 합니다.



MoneySafeKey.java

public final class MoneySafeKey {

}

시나리오 1과 동일하므로 설명은 생략하겠습니다.



MoneySafe.java

public final class MoneySafe {
    private int money;
    private final MoneySafeKey key;

    public MoneySafe(int money) {
        this.money = money;
        this.key = new MoneySafeKey();
    }

    // TODO : 입금된다
    public void deposit(int money, String name) {
        // synchronized 는 MoneySafeKey Owner 구역이다
        synchronized (key) {
            this.money += money;
            System.out.printf("%s가 %d를 입금했습니다.\n", name, money);

           // TODO : MoneySafeKey key Section의 
           // Wait-Set 구역에 있는 모든 스레드를 깨운다.
           key.notifyAll();
        }
    }

    // TODO : 출금된다
    public void withDraw(int money, String name) {
        // synchronized 는 MoneySafeKey Owner 구역이다
        synchronized(key) {
            try {
                while (this.money < money) {
                    // TODO : 잔고에 출금하려는 돈보다 적으면 일단 기다려야 한다.
                    // MoneySafeKey key Section에 있는
                    // Key Owner 구역에 있는 스레드를 Wait-Set 구역으로 이동시킨다.
                    key.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.money -= money;
            System.out.printf("%s가 %d를 출금했습니다.\n", name, withDrawnMoney);
        }
    }

    // TODO : 잔고 확인 메서드
    public void printBalance() {
       synchronized(key) {
            System.out.printf("현재 잔고는 %d입니다\n", this.money);
        }
    }
}

시나리오 1과 유사해보이지만 다른점이 있습니다.


출금을 담당하는 Thread가 withDraw() 메서드를 호출시 출금하려는 금액보다 잔고의 금액이 적은 경우 key.wait() 메서드를 호출하여 Key Owner 구역에서 Key를 반납하고 그 즉시 Wait-Set 구역으로 이동합니다.


만약에 출금을 담당하는 Thread가 Wait-Set 구역에서 notify() 메서드를 통해 Wait 상태가 아닌 Blocking 상태가 되고 다시 Key를 소유했다 하더라도 while 반복문을 통해 잔고의 금액과 출금하려는 금액을 비교후 잔고가 부족한경우 다시 Wait-Set 구역으로 이동합니다.


입금을 담당하는 Thread가 deposit() 메서드를 호출시 int money 변수를 변경 후 key.notifyAll() 메서드를 호출합니다.


notifyAll() 메서드는 Key Section의 Wait-Set 구역에 있는 모든 Thread를 깨워 Wait 상태에서 Blocking 상태로 변경합니다.


Blocking 상태가 된 Thread는 Entry-Set에 있는 Thread와 Key를 소유하기 위해 경합합니다. 위의 코드에서는 경합에서 승리하여 Key를 소유한 Thread는 key.wait() 코드 다음의 코드부터 다시 실행됩니다.

NOTE! key.wait()나, key.notifyAll() 메서드는 synchronized scope 내에서만 사용가능합니다.

DepositPerson.java

public final class DepositPerson {
    private MoneySafe safe;
    private String name;

    public Person(String name, MoneySafe safe) {
        this.name = name;
        this.safe = safe;
    }

    // TODO : 입금한다
    public void deposit(int money) {
        safe.deposit(money, name);
   }
}

DepositPerson 객체가 하는 일은 돈을 입금만 합니다.



WithdrawPerson.java

public final class WithdrawPerson {
    private MoneySafe safe;
    private String name;

    public Person(String name, MoneySafe safe) {
        this.name = name;
        this.safe = safe;
    }

    // TODO : 출금한다
    public void withDraw(int money) {
        safe.withDraw(money, name);
   }
}

WithDrawPerson 객체가 하는 일은 돈을 출금만 합니다.



Main.java

public class Main {

    public void static main(String[] args) throws InterruptedException {
        MoneySafe safe = new MoneySafe(0);

        WithdrawPerson personA = new Person("출금자", safe);
        DepositPerson personB = new Person("입금자", safe);

        // Lambda 방식으로 Runnable 익명 클래스 생성
        Thread threadA = new Thread(() -> {
             personA.withDraw(1000);
             personA.withDraw(500);
             personA.withDraw(1000);
             personA.withDraw(2000);
       });
        threadA.start();

        // Lambda 방식으로 Runnable 익명 클래스 생성
        Thread threadB = new Thread(() -> {
            personB.deposit(1000);
            personB.deposit(1000);
            personB.deposit(2000);
            personB.deposit(3000);
            personB.deposit(5000);
       });
        threadB.start();

        threadA.join();
        threadB.join();
        // 잔고확인 출력
        safe.printBalance();
    }

}

시나리오 1과 다른 부분은, MoneySafe의 잔고가 0이라는 것과 ThreadA는 출금의 행위만, ThreadB는 입금의 행위만 한다는 점입니다. 각 Thread는 순서와 상관없이 경쟁적으로 Key를 점유하려고 할 것이고 상황에 따라 출력이 변경될겁니다.


출력

입금자가 1000를 입금했습니다.
입금자가 1000를 입금했습니다.
입금자가 2000를 입금했습니다.
입금자가 3000를 입금했습니다.
입금자가 5000를 입금했습니다.
출금자가 1000를 출금했습니다.
출금자가 500를 출금했습니다.
출금자가 1000를 출금했습니다.
출금자가 2000를 출금했습니다.
현재 잔고는 7500입니다

제 컴퓨터에서는 출금 관련 Thread가 먼저 실행되나 잔고의 부족으로 key.wait() 메서드를 호출하며 Wait-Set 상태가 되고, 그 이후에 입금 관련 Thread가 Key 자원을 대부분 점유해서 출금 관련 Thread가 Key를 점유하지 못하는 상황이 됬습니다.


출력결과는 실행 상황에 따라 다른 결과를 출력할 겁니다. 다만 확실히 보증되는건, 잔고가 부족한데 출금자가 돈을 출금할 수는 없습니다.


wait()를 사용하지 않는 경우

java mornitor 2

wait()를 사용하지 않는다면 단순히 Entry-Set과 Key Owner 구역만 사용하는 것과 같습니다. Wait-Set이 꼭 필요한것은 아닙니다. 필요한 상황에 맞게 적절하게 사용하면 됩니다.


3. Lock-Free 동기화 기법


우리는 Java의 동기화 기법중 Mornitor를 이용한 동기화를 하는법을 확인했습니다. Java에는 Thread를 재우고 다시 실행시킬 수 있는 또 다른 도구를 제공합니다.

LockSupport.park();
LockSupport.unpark(thread);

Mornitor를 이용한 기법은 Key Section에서 Entry-Set, Key Owner, Wait-Set 구역으로 나뉘어있고 Object Key를 이용한 방법을 사용했었습니다.


그러나 Object Key를 사용하지 않는 다른 방법도 있습니다. 이번에 소개하는 동기화 도구는 Lock-Free 방식입니다. Lock-Free 방식을 사용하면 synchronized method, synchronized statement 방식을 사용하지 않아도 됩니다.


LockSupport 클래스에는 동기화를 위한 몇 가지 도구가 존재합니다. 그러나 로우한 메서드라서 특정상황이 아니면 직접 사용할 일은 많지 않습니다.

LockSupport 클래스를 이용하여 만들어진 Semaphore를 사용하면 됩니다. 그러므로 특정한 목적이 아니라면 Mornitor를 이용한 동기화 방식을 추천드립니다.


그래도 이런것은 있다는 것을 보여드려야 하므로 소개해드립니다.

LockSupport.park() 메서드를 호출하는 스레드는 대기 상태가 됩니다.

LockSupport.unpark(thread) 메서드는 thread 매개변수를 통해 Parked 된 Thread를 깨울 수 있습니다. 즉, 대기 상태가 된 Thread의 정보를 알아야지 깨울 수 있습니다.


시나리오 3

내용은 시나리오 2와 동일합니다. 다만 Mornitor 방식을 통한 동기화가 아닌 Lock-Free 방식을 이용하여 코드를 작성하면 다음과 같습니다.


MoneySafe.java

public final class MoneySafe {
    private int money;

    // TODO : thread를 담는 Queue
    private final Queue<Thread> threadQueue;

    public MoneySafe(int money) {
        this.money = money;
        this.threadQueue = new LinkedList<>();
    }

    // TODO : 입금된다
    public void deposit(int money, String name) {
          this.money += money;
          System.out.printf("%s가 %d를 입금했습니다.\n", name, money);

          while (!threadQueue.isEmpty()) {
              // TODO : queue에 저장된 Thread를 꺼낸 후
              // Thread를 깨운다.
              LockSupport.unpark(threadQueue.poll());
          }
    }

    // TODO : 출금된다
    public void withDraw(int money, String name) {
            while (this.money < money) {
                   // TODO : 잔고에 출금하려는 돈보다 적으면 일단 기다려야 한다.

                   // TODO : 현재 이 메서드를 실행하고 있는 Thread를
                   // thread 인스턴스를 보관하는 queue에 저장한다.
                   threadQueue.add(Thread.currentThread());

                   // TODO : 현재 실행되고 있는 Thread를 대기시킨다.
                   LockSupport.park();
            }

            this.money -= money;
            System.out.printf("%s가 %d를 출금했습니다.\n", name, withDrawnMoney);
        }
    }

    // TODO : 잔고 확인 메서드
    public void printBalance() {
        System.out.printf("현재 잔고는 %d입니다\n", this.money);
    }
}

withDraw() 메서드를 호출하는 Thread의 정보를 저장해야지 LockSupport.unpark(thread) 메서드를 호출할 수 있으므로 Thread를 저장하는 Queue를 생성합니다.


그리고 withDraw() 메서드 호출시 잔고가 부족하다면 해당 메서드를 실행하는 Thread 인스턴스를 반환하는 Thread.currentThread() 정적 메서드를 호출하여 반환 받은 Thread 인스턴스를 Queue에 저장합니다. 그리고 LockSupport.park() 메서드를 이용하여 Thread를 대기시킵니다.


deposit() 메서드를 호출하는 Thread는 Queue에 Thread 인스턴스가 저장되어 있다면 LockSupport.unpark(thread)를 호출하여 깨웁니다. 반복문을 통하여 Queue에 저장된 모든 인스턴스를 다 깨우는 역할을 합니다.


4. Thread의 Life Cycle


Thread Life Cycle

여태까지 배운 동기화 또는 Thread의 상태를 변경하는 메서드를 종합한 Metrics 입니다. Thread의 상태는 총 6가지이며, Thread의 상태를 변경하는 명시적 호출이나, timeout에 따라 상태가 변경됩니다.

  • New - new 키워드로 Thread 인스턴스가 생성되고 실제로 커널 스레드를 생성하지 않은 상태입니다.

  • Runnable - 인스턴스 Thread의 start() 메서드를 호출시 Runnable 상태가 됩니다. 즉 작업을 실행하고 있는 상태입니다.

  • TimedWating - 지정된 특정 시간까지 Wating하는 상태입니다.

  • Thread.sleep(time) 정적 메서드 호출하면 time 만큼 TimedWating 상태가 됩니다.


    Mornitor 방식의 동기화와 관련된 key.wait(time) 인스턴스 메서드 호출도 time 만큼 TimedWating 상태가 됩니다.

    th.join(timeout) 인스턴스 메서드도 내부적으로는 Thread Object 자체를 Key로 사용하기 때문에 key.wait(time) 인스턴스 메서드를 호출하여 time 만큼 TimedWating 상태가 됩니다.

  • Wating - 지정된 시간이 존재하지 않고 외부에서 깨워주어야만 Wating 상태를 벗어납니다.

  • LockSupport.park() 메서드로 Wating 상태가 될 수 있으며, 외부의 특정 스레드가 LockSupport.unpark(thread) 메서드를 호출해야지 다시 Runnable 상태가 됩니다.


    Mornitor 방식의 동기화와 관련된 key.wait() 인스턴스 메서드를 호출한 현재 스레드는 Wating 상태가 됩니다.

    th.join() 인스턴스 메서드도 내부적으로는 Thread Object 자체를 Key로 사용하기 때문에 key.wait() 인스턴스 메서드를 호출한 현재 스레드는 특정 Thread의 작업이 끝날때 까지 Wating 상태가 됩니다

  • Blocked - Mornitor 방식의 동기화에서 TImedWating, Wating 상태에서 벗어난 thread는 먼저 key를 소유하기 위해 Blocked 상태가 됩니다.

  • Key를 소유하는 경우 Runnable 상태가 됩니다.

*Thread의 상태를 변경시키는 메서드는 더 다양합니다. 그러나 이정도만 알고 있어도 충분합니다. 지금도 많습니다.


5. 마치며


Java의 동기화 도구는 생각보다 더 많습니다. 그러나 모든 동기화 도구를 지금 당장 알 필요는 없습니다. 필요하면 그때 찾아서 사용하면 되기 때문입니다. 그리고 이미 알고있는 도구로도 충분히 동기화를 할 수 있습니다. 또한 간단한 동기화는 synchronized를 사용하면 되므로 동기화 방식에 집중하기 보다는 동기화가 어떻게 이루어지는지, 임계 구역을 식별하는 눈을 가지는 것이 더 중요합니다.


변경되지 않는 공유자원은(읽기만 하는) 동기화가 필요 없습니다. 동기화로 인해 Thread가 대기하는 시간이 발생하며 작업 속도가 느려지는 병목 현상(DeadLock)이 발생할 수 있습니다. 그러므로 Thread 잘 사용하는것은 쉽지 않습니다. 많은 경험을 통해서 적재 적소에 사용하게 되므로 부단히 고민하고 또 고민하고 사용해보시길 바랍니다.

강의와 관련된 코드는 실습도구에 있으므로, 천천히 살펴보시며 눈을 코드를 바라보며 머리속으로 생각하면서 파악해보셔야 합니다.

도전자 질문
아이콘루트(2022-10-01 10:16 작성됨)
Lock-free의 경우에 스레드큐에 있는 스레드를 꺼내고 넣는 과정도 race condition이 발생할 수 있지 않나요? 그에 대한 처리도 해야되는 같은데
안해도 제대로 동작하네요 제가 이해를 잘못하고 있는걸까요?
이용약관|개인정보취급방침
알유티씨클래스|대표, 개인정보보호책임자 : 이병록
이메일 : cs@codelatte.io|운영시간 09:00 - 18:00(평일)
사업자등록번호 : 824-06-01921|통신판매업신고 : 2021-성남분당C-0740
주소 : 경기도 성남시 분당구 대왕판교로645번길 12, 9층 24호(경기창조혁신센터)
파일
파일파일
Root
파일

강의 내용에 있었던 시나리오입니다. 하나씩 주석을 풀어서 실행해보세요. 1. Scenario1.run(); 2. Scenario2.run(); 3. Scenario3.run();

Output
root$