이 포스팅은 이전 포스팅인 쓰레드 상태와 제어 메서드1 (sleep, interrupt), 쓰레드 제어 메서드 2 (join, yield, synchoronized)에 이어집니다.
※ 쓰레드를 기다리게 하고 깨워주는 기능인 wait()와 notify()는 한 쌍으로 항상 같이 쓰인다.
wait() : 다른 쓰레드의 침범을 막기 위해 synchoronized()로 임계 영역을 설정했다가 더 이상 작업을 진행할 상황이 아니어서 Lock을 풀고 싶을 때에는 wait()을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있다.
이후에 다른 작업 가능한 쓰레드에게 락을 주어 해당 부분에 대한 작업을 수행하게 하거나 추후에 작업 진행이 다시 가능해지면 notify()를 통해 작업을 중단시켰던 쓰레드에게 다시 Lock을 줄 수도 있다.
wait()으로 호출되어 대기 상태인 쓰레드는 해당 객체의 waiting pool(대기실)에서 통지(notify( ))를 기다린다.
import java.util.ArrayList;
import java.util.List;
public class Main {
public static String[] itemList = {
"MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
};
public static AppleStore appleStore = new AppleStore();
public static final int MAX_ITEM = 5;
public static void main(String[] args) {
// 가게 점원
Runnable StoreClerk = () -> {
while (true) {
int randomItem = (int) (Math.random() * MAX_ITEM);
appleStore.restock(itemList[randomItem]);
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
}
}
};
// 고객
Runnable Customer = () -> {
while (true) {
try {
Thread.sleep(77);
} catch (InterruptedException ignored) {
}
int randomItem = (int) (Math.random() * MAX_ITEM);
appleStore.sale(itemList[randomItem]);
System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
}
};
new Thread(StoreClerk, "StoreClerk").start();
new Thread(Customer, "Customer1").start();
new Thread(Customer, "Customer2").start();
}
}
class AppleStore {
private List<String> inventory = new ArrayList<>();
public void restock(String item) {
synchronized (this) {
while (inventory.size() >= Main.MAX_ITEM) {
System.out.println(Thread.currentThread().getName() + " Waiting!");
try {
wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 재입고
inventory.add(item);
notify(); // 재입고 되었음을 고객에게 알려주기
System.out.println("Inventory 현황: " + inventory.toString());
}
}
public synchronized void sale(String itemName) {
while (inventory.size() == 0) {
System.out.println(Thread.currentThread().getName() + " Waiting!");
try {
wait(); // 재고가 없기 때문에 고객 대기중
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
while (true) {
// 고객이 주문한 제품이 있는지 확인
for (int i = 0; i < inventory.size(); i++) {
if (itemName.equals(inventory.get(i))) {
inventory.remove(itemName);
notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
return; // 메서드 종료
}
}
// 고객이 찾는 제품이 없을 경우
try {
System.out.println(Thread.currentThread().getName() + " Waiting!");
wait();
Thread.sleep(333);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 출력 결과
/*
Inventory 현황: [IPhone]
Inventory 현황: [IPhone, iMac]
Customer2 Waiting!
Customer1 Waiting!
Inventory 현황: [IPhone, iMac, AirPods]
Customer2 Waiting!
Inventory 현황: [IPhone, iMac, AirPods, IPhone]
Customer1 Waiting!
Inventory 현황: [IPhone, iMac, AirPods, IPhone, Mac mini]
Customer2 Waiting!
StoreClerk Waiting!
*/
wait()으로 쓰레드의 실행을 잠시 중단시키고 notify()로 쓰레드의 실행을 재개시키는 것을 위 예시 코드로 확인할 수 있다.
하지만 출력 부분의 마지막 부분을 보면 쓰레드들이 계속 실행상태임에도 Customer2와 StorkCLerk2가 계속 Waiting중인 것을 확인할 수 있다 .
이는 병목 현상이 발생했기 때문이다. Customer2가 찾는 물품이 들어가야 하는데 점원이 새 물품을 인벤토리에 넣기 위해서는 Inventory에서 물품이 하나 빠져야 하는데 빠지지 않아 Customer2와 StorkClerk2 둘 다 무한히 기다리고 있기 때문에 아무 작업도 일어나지 못하고 있다.
이러한 병목 현상 때문에 wait()와 notify()를 사용할 때에는 주의가 필요하다.
wait()와 notify()를 쓸 때에는 어떤 쓰레드를 대기시키고 어떤 쓰레드를 깨울 것인지에 대해 명시가 필요하다.
Lock: synchronized 블럭으로 동기화하면 자동으로 Lock을 걸고 풀 수 있지만 같은 메서드 내에서만 Lock을 걸 수 있다는 제약이 존재한지만 이런 제약을 해결하기 위해 사용하는 것이 Lock 클래스이다.
ReentrantLock: 재진입 가능한 Lock으로 가장 일반적인 Lock이다. 특정 조건으로 Lock을 풀고 추후에 다시 Lock을 얻어 임계영역으로 재진입이 가능하다.
public class MyClass {
private Object lock1 = new Object();
private Object lock2 = new Object();
public void methodA() {
synchronized (lock1) {
methodB();
}
}
public void methodB() {
synchronized (lock2) {
// do something
methodA();
}
}
}
위의 예시 코드를 보면 methodA는 lock1을 가지고, methodB는 lock2를 가진 상태에서 methodB가 methodA를 호출하기 때문에 쉽게 말해 lock2를 가지고 lock1에 접근한다. 하지만 이러면 methodA에서는 이미 lock1을 가지고 있기 때문에 lock2를 기다리는 상태가 되어 데드락이 발생할 수 있다.
하지만 이 때 ReentrantLock을 사용하면 같은 쓰레드가 이미 lock2을 가지고 있더라도 락을 유지하며 계속 실행할 수 있어 데드락이 발생하지 않아 코드의 유연성을 높일 수 있다.
ReentrantReadWriteLock: 읽기를 위한 Lock과 쓰기를 위한 Lock을 각각 제공한다. 이 때 읽기는 공유적이고 공개적이지만 쓰기는 lock이 배타적이고 폐쇄적이다.
읽기 Lock이 걸려 있으면 다른 쓰레들도 마찬가지로 읽기 Lock을 걸고 중복으로 읽기를 수행할 수 있고 읽기 Lock이 걸려 있으면 쓰기 Lock을 거는 것은 허용되지 않는다는 특징이 존재한다.
stampedLock: ReentrantReadWriterLock에 데이터를 변경할 때에만 Lock을 거는 낙관적인 Lock의 기능을 추가한 Lock으로 데이터 변경 때 충돌이 일어날 가능성이 적은 상황에서 사용된다.
낙관적인 Lock을 사용하면 읽기와 쓰기 모두 빠르게 처리가 가능하기 때문에 쓰기 작업이 빈번하지 않은 경우에는 낙관적인 Lock을 사용하면 더 빨리 처리할 수 있다.
읽기 Lock은 쓰기 Lock에 의해 즉시 해제가 가능하며 무조건 읽기 Lock을 걸기보다는 쓰기와 읽기가 충돌할 때에만 쓰기 후 읽기 Lock을 사용하는 것이 좋다.
바로위에서 다룬 wait()와 nofity()의 문제점인 wiating pool 내의 쓰레드를 구분하지 못한다는 못한다는 것을 해결한 것이 바로 Condition이다.
Condition: waiting pool 내의 쓰레드가 구분이 안 된다는 문제점을 해결하기 위해 JDK 5부터 도입된 인터페이스로, Condition을 사용하면waiting pool 내의 쓰레드를 분리하여 특정 조건이 만족될 대에만 깨우도록 설정하는 것이 가능하며 ReentrantLock 클래스와 같이 사용된다.
import java.util.ArrayList;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class Main {
public static final int MAX_TASK = 5;
private ReentrantLock lock = new ReentrantLock();
// lock으로 condition 생성
private Condition condition1 = lock.newCondition();
private Condition condition2 = lock.newCondition();
private ArrayList<String> tasks = new ArrayList<>();
// 작업 메서드
public void addMethod(String task) {
lock.lock(); // lock 걸어서 임계영역 시작
try {
while(tasks.size() >= MAX_TASK) { // task가 꽉 차 있으면 아무것도 안 한다
String name = Thread.currentThread().getName();
System.out.println(name+" is waiting.");
try {
condition1.await(); // wait(); condition1 쓰레드를 기다리게 합니다.
Thread.sleep(500);
} catch(InterruptedException e) {}
}
// task가 비어 있으면 추가한다.
tasks.add(task);
condition2.signal(); // notify(); 기다리고 있는 condition2를 깨워줍니다.
System.out.println("Tasks:" + tasks.toString());
} finally {
lock.unlock(); // 임계영역 끝
}
}
}
이제 wait()와 notify() 대신 Condition의 await()와 signal()을 사용하면 된다.
로직 자체는 wait() & notify() 부분과 비슷하다. 하지만 wait() 대신에 awiat()을 사용하여 task가 꽉 차 있으면 condition1(점원)을 명시하여 기다리게 한다.
이후 물품을 추가하면 기다리고 있는 condition2(고객)을 명시하여 notify() 대신에 notify 역할하는 signal()로 다시 실행을 재개한다.
즉 wait() & notify() 대신에 awiat() & signal()을 사용하면 어떤 쓰레드들을 기다리게 하고 다시 실행시키지를 정할 수 있다.
마지막에는 unlokc()으로 임계영역을 해제한다.