
기본적으로 한 프로세스 내부에서 동작하는 여러개의 쓰레드는 동일한 리소스를 공유해서 작업하기 때문에 서로에게 영향을 준다.
A 쓰레드는 리소스에 +1를 했고, B 쓰레드는 리소스에 -1를 하고 막 작업을 진행하면 리소스의 값은..?
이런 일을 방지하기 위해 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 침범하지 못하도록 하는게 Synchronization 이다.
한 쓰레드가 작업할 때 다른 쓰레드가 진입하는 것이 금지되는 공간을 임계영역이라고 하고, 자바로는 다음과 같이 설정한다.
public synchronized void asyncSum() {
//내부 로직
}
synchronized(해당 객체의 참조변수) {
//내부 로직
}
예시
public class Main {
public static void main(String[] args) {
AppleStore appleStore = new AppleStore();
Runnable task = () -> {
while (appleStore.getStoredApple() > 0) {
appleStore.eatApple();
System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
}
};
for (int i = 0; i < 3; i++) {
new Thread(task).start();
}
}
}
class AppleStore {
private int storedApple = 10;
public int getStoredApple() {
return storedApple;
}
public void eatApple() {
synchronized (this) {
if(storedApple > 0) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
storedApple -= 1;
}
}
}
}
eatApple()에 synchronized(this)를 확인할 수 있다. 이게 없으면 사과는 0미만으로 줄어드는 오류가 발생하는데, this를 통해서 appleStore 인스턴스를 임계 구역으로 설정하여 안전하게 처리할 수 있다.
임계 영역에 진입하는 열쇠인 Lock을 반납하고 대기
wait가 실행된 쓰레드는 watiting pool로 이동하면서 lock을 반납한다.
wait이던 쓰레드가 Lock을 받고 다시 일하기
waiting pool에서 대기하던 쓰레드가 lock을 받아 임계구역으로 진입한다.
둘은 한 쌍으로 사용되는데 다음 코드를 보자
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();
}
}
}
}
각 메소드 안의 wait, notify는 서로 상호작용 하며 Lock을 반납하고, 부여하는 동작을 한다.
Synchronized 블럭을 사용하면 같은 메서드내에서만 Lock을 걸 수 있는 제약이 있다.
이를 해결하기 위해 Lock 클래스를 사용한다.
재진입이 가능하며 가장 일반적인 베타 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는 lcok2를 갖는다.
MethodB에서 MethodA를 호출한다 -> lock2를 갖는 상태에서 lock1을 소유하고자 한다.
근데 이러면 MethodA가 이미 lock1이 있고, 서로 lock을 원해서 데드락이 걸린다.
하지만 ReentrantLock을 사용하면 같은 스레드가 이미 락을 갖고 있어도 락은 유지하며 실행이 가능해서 데드락이 발생하지 않음
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class MyClass {
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
public void methodA() {
lock1.lock();
try {
System.out.println("methodA: acquired lock1");
methodB();
} finally {
lock1.unlock();
System.out.println("methodA: released lock1");
}
}
public void methodB() {
lock2.lock();
try {
System.out.println("methodB: acquired lock2");
Thread.sleep(100);
methodA();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // Handle interruption
} finally {
lock2.unlock();
System.out.println("methodB: released lock2");
}
}
public static void main(String[] args) {
MyClass myClass = new MyClass();
Runnable task1 = () -> {
try {
myClass.methodA();
} catch (Exception e) {
e.printStackTrace();
}
};
Runnable task2 = () -> {
try {
myClass.methodB();
} catch (Exception e) {
e.printStackTrace();
}
};
Thread thread1 = new Thread(task1);
Thread thread2 = new Thread(task2);
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
이런식으로 사용이 가능하다.
ReentrantLock와 달리 읽기, 쓰기용 Lock을 따로제공한다.
읽기 Lock은 다른 쓰레드들도 동시에 사용하가능하고, Lock은 베타적 Lock이다.
다만, 읽기 Lock이 걸려있는 상태에서 쓰기용 Lock은 허용되지 않는다.
ReentrantReadWriteLock+낙관적 Lock기능
낙관적 Lock : 데이터 변경전 Lock을 걸지 않는 것.
쓰기 작업이 적은 경우 사용하게 되는데, 기존 ReentrantReadWriteLock은 읽기 Lock이 걸릴시 쓰기 Lock이 허용되지 않았는데, 이 경우는 읽기 Lock은 해제된다.
wait() & notify()는 waiting pool 내부 쓰레드를 무작위 선택한다고 했는데, Condition은 이를 해결한다.
COndition은 wait, notify의 다음 버전 await, signal을 지원한다.
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(); // 임계영역 시작
try {
while(tasks.size() >= MAX_TASK) {
String name = Thread.currentThread().getName();
System.out.println(name+" is waiting.");
try {
condition1.await(); // wait(); condition1 쓰레드를 기다리게 합니다.
Thread.sleep(500);
} catch(InterruptedException e) {}
}
tasks.add(task);
condition2.signal(); // notify(); 기다리고 있는 condition2를 깨워줍니다.
System.out.println("Tasks:" + tasks.toString());
} finally {
lock.unlock(); // 임계영역 끝
}
}
}