프로그래밍 언어/Java

[Java] 자바 동기화 synchronized, wait(), notify()

happy_life 2022. 7. 4. 10:45

자바 쓰레드의 동기화

 

 

쓰레드의 동기화

멀티 쓰레드의 경우, 같은 프로세스 내의 자원을 공유하기 때문에 쓰레드 간 서로의 작업에 영향을 줄 수 있다. 이를 방지하기 위해 한 쓰레드가 작업 중인 것을 다른 쓰레드가 간섭하지 못하도록 막는 것쓰레드의 동기화라고 한다.

쓰레드의 동기화를 위해 공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 함으로써 쓰레드의 동기화를 할 수 있다.

 

쓰레드의 동기화 방법

 synchronized를 통해 동기화를 위한 임계영역을 설정할 수 있다.

 

코드 예제

class SynchronizedEx1_1 {
    public synchronized void showBalance(int money) {
        if (money < 0) {
            System.out.println("잘못된 입력입니다.");
        }
        try {
            Thread.sleep(1000);
        } catch (Exception e) {}
    }
}

하지만 임계영역은 이렇게 메서드 전체를 크게 지정하는 것보다, 특정 영역을 최소화 해 지정하는 것이 좋다. 임계영역이 커지면 1개의 쓰레드만 접근할 수 있는 영역이 커지는 것인데, 이는 멀티 쓰레드의 장점을 상쇄시킨다.

 

코드 예제

class SynchronizedEx1_2 {
    public void showBalance(int money) {

        synchronized (this) {
            if (money < 0) {
                System.out.println("잘못된 입력입니다.");
            }
            try {
                Thread.sleep(1000);
            } catch (Exception e) {}
        }
    }
}

위의 코드와 같이 코드의 일부를 블럭으로 감싸고 앞에 synchronized(참조변수)를 붙이는 방법도 있다.이 때 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 한다. 

 

728x90

 

쓰레드의 동기화  필요성 확인하기

실제로 동기화 코드를 사용하지 않으면 어떻게 되는지 코드로 알아보자. 아래는 계좌에서 금액을 인출하는 코드 예제이다. 잔액이 출금하는 금액보다 크면 출금하지 못하게 설정되어 있다.

 

코드 예제

public class SynchronizedEx2 {
    public static void main(String[] args) {
        Runnable r = new SynchronizedEx2_1();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account {
    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money) {
        if (balance >= money) {
            try {Thread.sleep(1000);} catch (InterruptedException e) {}
            balance -= money;
        }
    }//withdraw
}

class SynchronizedEx2_1 implements Runnable{
    Account acc = new Account();
    @Override
    public void run() {
        while (acc.getBalance() > 0) {
            int money = (int)(Math.random() * 3 + 1) * 100;
            acc.withdraw(money);
            System.out.println("잔액: " + acc.getBalance());
        }
    }//run
}

하지만 사진과 같이 -100이 출력된다.

하나의 쓰레드에서 잔액이 200일 때 양수이므로 조건을 통과했다. 이후 money가  100이 나와 withdraw에서 balance -= money를 하기 직전 다른 쓰레드가 들어와 200을 빼버린 것이다. 그러면 다시 기존의  쓰레드로 들어와 0에서 -100을 빼니 음수가 출력되는 것이다.

 

이를 해결하기 위해 메서드에 synchronized를 붙여주면 된다.

 

public synchronized void withdraw(int money) {
    if (balance >= money) {
        try {Thread.sleep(1000);} catch (InterruptedException e) {}
        balance -= money;
    }
}//withdraw

 

 

synchronized의 비효율 문제 해결

synchronized로 동기화해 공유 데이터를 보호할 수는 있지만, 특정 쓰레드가 객체의 락을 갖고 오래 있지 않도록 해야한다. 예를 들어 계좌에 돈이 부족해서 한 쓰레드가 락을 보유한 채로 돈이 입금될 때까지 기다린다면, 다른 쓰레드들도 작업을 아예 못한 채 기다릴 수밖에 없을 것이다. 이를 개선하기 위한 것이 바로 wait()와 notify()이다.

wait()

쓰레드가 락을 반납하고 기다리게 된다. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 접근해 작업을 수행할 수 있게 된다.

 

notify()

대기하던 임의의 쓰레드에게 통지를 하고 락을 부여한다.

 

notifyAll()

대기하던 모든 쓰레드에게 통지를 한다. 하지만 결국 락을 얻을 수 있는 것은 하나의 쓰레드일 뿐이고 나머지는 다시 락을 기다리는 신세가 된다. 

 

각 객체마다 "waiting pool"이라는 대기장소가 존재한다. wait()로 인해 대기하게 되면 쓰레드는 여기에서 대기하고 있게 되는 것이다. 따라서 notifyAll()을 한다고 모든 객체의 waiting pool에 있는 쓰레드 모두가 깨워지는 것은 아니다. notifyAll()이 호출된 객체의 waiting pool에 대기중인 쓰레드만 깨워지는 것이다.

 

wait()와 notify()를 사용하는 것은 마치 빵을 사려고 줄을 서는 것과 비슷하다. 자신의 차례가 되었는데 원하는 빵이 아직 굽는 중이라면, 다른 사람에게 양보해주어 그 사람이 먼저 그 사람이 원하는 빵을 먹을 수 있게 해주는 것이다.

 

예제:  요리사 1명, 손님 2명의 총 3개의 쓰레드가 있다. 요리사는 6개까지 음식을 계속 만들고, 손님들은 계속 음식을 먹는 코드이다. 테이블을 공유자원으로 사용하고 있기 때문에 synchronized를 사용한 코드이다.

 

코드 예제

public class SynchronizedEx3 {
    public static void main(String[] args) throws InterruptedException {
        Table table = new Table();

        new Thread(new Cook(table), "COOK").start();
        new Thread(new Customer(table, "donut"), "CUSTOMER1").start();
        new Thread(new Customer(table, "burger"), "CUSTOMER2").start();

        Thread.sleep(5000);
        System.exit(0);
    }
}

class Customer implements Runnable {
    private Table table;
    private String food;

    Customer(Table table, String food) {
        this.table = table;
        this.food = food;
    }

    @Override
    public void run() {
        while (true) {
            try {Thread.sleep(10);} catch (InterruptedException e) {}
            // catch
            String name = Thread.currentThread().getName();
            if (eatFood()) {
                System.out.println(name + " ate a " + food);
            } else {
                System.out.println(name + " fail to eat.");
            }
        }//while
    }
    boolean eatFood() {
        return table.remove(food);
    }
}

class Cook implements Runnable {
    private Table table;

    Cook(Table table) {
        this.table = table;
    }

    @Override
    public void run() {
        while (true) {
            // 임의의 요리를 선택해 table에 추가
            int idx = (int) (Math.random() * table.dishNum()); //0 1 2
            table.add(table.dishNames[idx]);
            try {Thread.sleep(1);} catch (InterruptedException e) {}
        }//while
    }//run

}

class Table {
    String[] dishNames = {"donut", "burger", "donut"}; // donut이 더 자주 나온다.
    final int MAX_FOOD = 6; // 테이블에 놓을 수 있는 최대 음식 개수

    private ArrayList<String> dishes = new ArrayList<>();

    public synchronized void add(String dish) {
        // 테이블에 음식이 가득 찼으면, 테이블에 음식 추가 X
        if (dishes.size() >= MAX_FOOD) {
            return;
        }
        dishes.add(dish);
        System.out.println("Dishes: " + dishes);
    }

    public boolean remove(String dishName) {
        synchronized (this) {
            while (dishes.size() == 0) {
                String name = Thread.currentThread().getName();
                System.out.println(name + " is waiting");
                try {Thread.sleep(500);}catch (InterruptedException e) {}
            }
            for (int i = 0;i < dishes.size(); i++) {
                if (dishName.equals(dishes.get(i))) {
                    dishes.remove(i);
                    return true;
                }
            }
        }//synchronized
        return false;
    }
    public int dishNum() {
        return dishNames.length;
    }
}

 

출력 결과

 

하지만 위와 같이 customer1이 도넛을 먹고난 다음 Customer2이 음식에 접근했을 때 dishes.size()가 0이라서 계속 락을 쥐고 있다. 그렇기 때문에 요리사가 add에 접근할 수 없어 음식을 만들 수 없다. 그러면 계속 dishes.size()가 0이 되어 waiting이 계속 출력되는 것이다.  

 

wait(), notify()로 문제를 해결한 코드 예제

이전예제에 wait()와 notify()를 추가하고, 음식이 없을 때뿐 아니라, 원하는 음식이 없을 때도 손님이 기다리도록 코드를 짰다.

public class SynchronizedEx3 {
    public static void main(String[] args) throws InterruptedException {
        Table table = new Table();

        new Thread(new Cook(table), "COOK").start();
        new Thread(new Customer(table, "donut"), "CUSTOMER1").start();
        new Thread(new Customer(table, "burger"), "CUSTOMER2").start();

        Thread.sleep(5000);
        System.exit(0);
    }
}

class Customer implements Runnable {
    private Table table;
    private String food;

    Customer(Table table, String food) {
        this.table = table;
        this.food = food;
    }

    @Override
    public void run() {
        while (true) {
            try {Thread.sleep(100);} catch (InterruptedException e) {}
            // catch
            String name = Thread.currentThread().getName();

            table.remove(food);
            System.out.println(name + "ate a " + food);
        }//while
    }
}

class Cook implements Runnable {
    private Table table;

    Cook(Table table) {
        this.table = table;
    }

    @Override
    public void run() {
        while (true) {
            // 임의의 요리를 선택해 table에 추가
            int idx = (int) (Math.random() * table.dishNum()); //0 1 2
            table.add(table.dishNames[idx]);
            try {Thread.sleep(10);} catch (InterruptedException e) {}
        }//while
    }//run

}

class Table {
    String[] dishNames = {"donut", "burger", "donut"}; // donut이 더 자주 나온다.
    final int MAX_FOOD = 6; // 테이블에 놓을 수 있는 최대 음식 개수

    private ArrayList<String> dishes = new ArrayList<>();

    public synchronized void add(String dish) {
        // 테이블에 음식이 가득 찼으면, 테이블에 음식 추가 X
        while (dishes.size() >= MAX_FOOD) {
            String name = Thread.currentThread().getName();
            System.out.println(name + " is waiting.");
            try {
                wait(); // COOK 쓰레드를 기다리게 함
            } catch (InterruptedException e) {}
        }
        dishes.add(dish);
        notify(); // 기다리는 CUSTOMER 깨우기
        System.out.println("Dishes: " + dishes);
    }

    public void remove(String dishName) {
        synchronized (this) {
            String name = Thread.currentThread().getName();
            while (dishes.size() == 0) {
                System.out.println(name + " is waiting");
                try {
                    wait(); // CUSTOMER 쓰레드 대기
                    Thread.sleep(500);
                    }catch (InterruptedException e) {}
            }
            while (true) {
                for (int i = 0;i < dishes.size(); i++) {
                    if (dishName.equals(dishes.get(i))) {
                        dishes.remove(i);
                        notify(); //COOK을 깨우기 위해
                        return;
                    }
                }//for
                try {
                    System.out.println(name + " is waiting");
                    wait(); // 원하는 음식 없으면 CUSTOMER 대기
                    Thread.sleep(500);
                } catch (InterruptedException e) {}
            }//while
        }//synchronized
    }
    public int dishNum() {
        return dishNames.length;
    }
}

출력 결과

 

코드 설명

dishes가 가득차면 wait() 메서드를 통해 COOK 쓰레드를 기다리게 하고 임의의 CUSTOMER 쓰레드에 락을 부여한다. 이후 락을 넘겨받은 CUSTOMER 쓰레드는 remove()메서드를 통해  원하는 음식이 있으면 ate a "음식"을 출력하고 notify()로 COOK을 호출한다.만약 dishes의 size가 0이거나, 원하는 음식이 없다면 wait()로 대기상태에 들어가게 된다.

 

한계

테이블 객체의 waiting pool에 COOK, 과 CUSTOMER가 같이 기다린다. 그래서 notify()가 호출되면 COOK과 CUSTOMER 중 누가 통지 받을 지 알 수 없다.

 

 

기아 현상과 경쟁 상태

운이 안좋으면 COOK 쓰레드는 계속 통지를 받지 못해 기다리게 될 수있는데, 이를 기아(Starvation)현상이라고 한다. 이 현상을 막기 위해선 notifyAll()을 통해 모든 쓰레드에게 통지해야 한다. 이렇게 되면, 손님 쓰레드는 다시 waiting pool에 들어가기 때문에 COOK이 락을 얻어 작업을 할 수 있다.

 

notifyAll()로 기아현상은 막을 수 있지만, CUSTOMER와 COOK이 모두 락을 얻기 위해 경쟁하는 상태가 된다. 이를 경쟁상태라고 한다. 이러한 문제를 해결하기 위해서는 Lock 과 Condition을 이용해야 한다.