✔️ Synchronization
: 여러 스레드가 한 리소스를 사용하려 할 때 사용하려는 스레드 하나를 제외한 나머지 스레드들은 리소스를 사용하지 못하도록 막는 것
class UnsafeAccount {
private int balance;
public int getBalance() {
return balance;
}
// 잔액 500원 일때
public void deposit(int val) {
balance += val;
}
public void withdraw(int val) {
if(balance >= val) {
try {
Thread.sleep(500);
} catch (InterruptedException e) {}
balance -= val;
}
System.out.println("name : " + Thread.currentThread().getName() + ", balance = " + this.getBalance());
}
}
public class UnsafeAccountSample {
public static void main(String[] args) {
// 공유자원: 객체 하나를 여러개의 쓰레드가 같이 씀
final UnsafeAccount account = new UnsafeAccount();
account.deposit(5000);
Runnable withdrawRun = new Runnable() {
@Override
public void run() {
// 10번 돌면서 500원씩 차감
for(int i = 0 ; i < 10; i ++) {
account.withdraw(500);
}
}
};
Thread t1 = new Thread(withdrawRun);
Thread t2 = new Thread(withdrawRun);
t1.start();
t2.start();
}
}
name : Thread-0, balance = 4000
name : Thread-1, balance = 4000
name : Thread-1, balance = 3500
name : Thread-0, balance = 3500
name : Thread-1, balance = 3000
name : Thread-0, balance = 2500
name : Thread-1, balance = 2000
name : Thread-0, balance = 1500
name : Thread-0, balance = 1000
name : Thread-1, balance = 500
name : Thread-1, balance = 0
name : Thread-1, balance = -500
name : Thread-1, balance = -500
name : Thread-1, balance = -500
name : Thread-1, balance = -500
name : Thread-0, balance = -500
name : Thread-0, balance = -500
name : Thread-0, balance = -500
name : Thread-0, balance = -500
name : Thread-0, balance = -500
if문을 통과하며 -500원이 출력되고 있는 것을 확인할 수 있다.
이렇듯, 멀티 스레드 프로그램에서는 여러 스레드들이 객체를 공유해서 작업하는 경우가 있다.
이때, 여러 스레드에서 동시에 한 객체에 접근하게 되면, 한 스레드에 의해 변경된 객체의 상태가 다른 스레드의 작업에 영향을 미쳐서 의도치 않은 결과를 낼 수 있다.
이러한 상황을 방지하기 위해 한 스레드가 사용 중인 객체를 다른 스레드에서 동시에 접근할 수 없도록 잠금을 걸어둘 수 있는 기능이 필요한데, 이것이 동기화(synchronized)이다.
✔️ 멀티 스레드 프로그램에서 하나의 스레드 외에는 동시에 실행할 수 없는 코드 영역
public void withdraw(int val) {
... 객체 상태 변경 => 임계 영역
}
자바는 임계 영역(Critical Section)을 지정하기 위해 동기화 메서드와 동기화 블록을 제공한다.
❗️ 상호배제(Mutex)
: 하나가 일을 끝마치기 전까지 다른 하나는 건드릴 수 없다.
메서드의 제어자에 synchronized
키워드를 작성하여 임계영역을 설정할 수 있다.
synchronized
메서드가 호출되면 해당 메서드의 객체는 호출한 쓰레드에게 Lock Flag를 전달하며 해당 쓰레드 외에 공유객체를 필요로 하는 쓰레드는 Running 상태가 될 수 없으며 Lock Flag 가 반납될 때까지 Waiting Pool의 Lock pool에서 대기하게 된다.
✔️ 순서 요약
한 스레드가 동기화 메서드 및 동기화 블록에 들어감 ➡️ 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역의 코드를 실행하지 못하도록 함 ➡️ 해당 스레드가 동기화 메소드를 종료하면 즉시 잠금 해제됨 ➡️ 다른 스레드 실행
동기화 메서드는 메서드 선언 시, synchronized
키워드를 붙여서 만들 수 있다.
synchronized
키워드는 인스턴스와 정적 메소드 어디든 붙일 수 있다.
public synchronized void method() {
//임계 영역
}
메서드의 일부 영역만 임계 영역으로 만들고 싶다면 다음과 같이 동기화 블록을 만들면 된다.
public void method() {
//여러 스레드가 실행 가능한 영역
synchronized(공유객체) {
//임계 영역
}
}
동기화 블록의 외부 코드들은 여러 스레드가 동시에 실행할 수 있지만, 동기화 블록의 내부 코드는 임계 영역이므로 한 번에 한 스레드만 실행할 수 있다.
동기화 메서드와 동기화 블록이 여러 개인 경우, 한 스레드가 이들 중 하나를 실행 중일 때, 다른 스레드는 해당 메서드는 물론이고 다른 동기화 메서드 및 블록도 실행할 수 없다.
class SyncAccount {
private int balance;
public synchronized int getBalance() {
return balance;
}
public synchronized void deposit(int val) {
balance += val;
}
public synchronized void withdraw(int val) {
if(balance >= val) {
try {
// t1이 TIMED_WATING에 있을 동안 t2은 Blocked가 된다. 키를 t1이 갖고있기 때문. 둘다 실행 상태가 잠깐 비게 되는 것.
Thread.sleep(500);
} catch (InterruptedException e) {}
balance -= val;
}
System.out.println("name : " + Thread.currentThread().getName() + ", balance = " + this.getBalance() );
}
}
public class SyncAccountSample {
public static void main(String[] args) {
final SyncAccount account = new SyncAccount();
account.deposit(5000);
Runnable withdrawRun = new Runnable() {
@Override
public void run() {
for(int i = 0 ; i < 10; i ++) {
account.withdraw(500);
}
}
};
Thread t1 = new Thread(withdrawRun);
Thread t2 = new Thread(withdrawRun);
t1.start();
t2.start();
}
}
name : Thread-0, balance = 4500
name : Thread-0, balance = 4000
name : Thread-0, balance = 3500
name : Thread-0, balance = 3000
name : Thread-0, balance = 2500
name : Thread-0, balance = 2000
name : Thread-0, balance = 1500
name : Thread-0, balance = 1000
name : Thread-0, balance = 500
name : Thread-0, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
name : Thread-1, balance = 0
account (this) 자체의 LOCK이 있다.
객체의 상태에 접근하는 녀석들을 같이 묶어 놓는다.
class Some {
public synchronized void todo1() {
try {
System.out.println("inside todo1");
Thread.sleep(5000);
System.out.println("t2 state: " + ThreadEx.t2.getState());
System.out.println("done : todo1");
} catch (Exception e) {
e.printStackTrace();
}
}
public synchronized void todo2() {
try {
System.out.println("inside todo2");
Thread.sleep(5000);
System.out.println("t1 state: " + ThreadEx.t1.getState());
System.out.println("done : todo2");
} catch (Exception e) {
e.printStackTrace();
}
}
}
public class ThreadEx {
public static Thread t1;
public static Thread t2;
public static void main(String[] args) {
final Some s = new Some();
t1 = new Thread() {
@Override
public void run() {
s.todo1();
}
};
t2 = new Thread() {
@Override
public void run() {
s.todo2();
}
};
t1.start();
t2.start();
}
}
inside todo1
t2 state: BLOCKED
done : todo1
inside todo2
t1 state: TERMINATED
done : todo2
t1이 Lock을 지고 들어간다 ➡️ 메서드에 들어가서 잠든다. ➡️ t2이 들어가게 되는데 t1이 나오질 않아 BLOCKED
에 빠진다. ➡️ t1이 메서드에서 나가고 Lock이 빠지면 그때서야 t2가 실행되게 되고 상태는 TERMINATED
로 전환된다.
✔️ 병목구간을 최소화 할 수 있고 구간 설정이 가능하다.
✔️ Lock 검사 대상을 설정할 수 있다. (서로 다른 멤버변수에 대한 연산)
인스턴스의 block단위로 lock을 건다. 이때, Lock객체를 지정해줘야한다.
❓ 사용 이유
동기화가 반드시 메서드 전체에 대해 이루어져야 하는 것은 아니다.
종종 메서드의 특정 부분에 대해서만 동기화하는 편이 효율적인 경우가 있다.
이럴 때는 메서드 안에 동기화 블록을 만들 수 있다.
public void add(int value) {
synchronized(this) {
this.count += value;
}
}
동기화 블록이 괄호 안에 한 객체를 전달받고 있다.
예제에서는 this
(모니터 객체) 가 사용되었다. 이는 이 add()
메서드가 호출된 객체를 의미한다.
❗️ 모니터 객체(monitor object)
: 동기화 블록 안에 전달된 객체
이 코드는 이 모니터 객체를 기준으로 동기화가 이루어짐을 나타내고 있다.
동기화된 인스턴스 메서드는 자신(메서드)을 내부에 가지고 있는 객체를 모니터 객체로 사용한다.
같은 모니터 객체를 기준으로 동기화된 블록 안의 코드를 오직 한 스레드만이 실행할 수 있다.
public class MyClass {
public synchronized void log1(String msg1, String msg2) {
log.writeln(msg1);
log.writeln(msg2);
}
public void log2(String msg1, String msg2) {
synchronized(this) {
log.writeln(msg1);
log.writeln(msg2);
}
}
}
한 스레드는 한 시점에 두 동기화된 코드 중 하나만을 실행할 수 있다.
여기서 두 번째 동기화 블록의 괄호에 this
대신 다른 객체를 전달한다면, 동기화 기준이 달리지므로 스레드는 한 시점에 각 메서드를 실행할 수 있다.
두 메서드는 각 메서드를 가지고 있는 클래스 객체를 동기화 기준으로 잡는다.
public class MyClass {
public static synchronized void log1(String msg1, String msg2) {
log.writeln(msg1);
log.writeln(msg2);
}
public static void log2(String msg1, String msg2) {
synchronized(MyClass.class) {
log.writeln(msg1);
log.writeln(msg2);
}
}
}
같은 시점에 오직 한 스레드만 이 두 메서드 중 어느 쪽이든 실행 가능하다.
두 번째 동기화 블록의 괄호에 MyClass.class
가 아닌 다른 객체를 전달한다면, 쓰레드는 동시에 각 메서드를 실행할 수 있다.
✔️ 인스턴스가 아닌 클래스 단위로 Lock이 발생한다.
✔️ 동일한 클래스의 static synchronized 메서드 내에서 하나의 스레드만 실행
public static MyStaticCounter {
private static int count = 0;
public static synchronized void add(int value) {
count += value;
}
}
동기화된 static 메서드가 속한 클래스의 클래스 개체에서 동기화된다.
Java VM에는 클래스당 하나의 클래스 객체만 존재하므로 동일한 클래스의 static synchronized 메서드 내에서 하나의 스레드만 실행할 수 있다.
public static MyStaticCounter {
private static int count = 0;
public static synchronized void add(int value) {
count += value;
}
public static synchronized void subtract(int value) {
count -= value;
}
}
클래스에 둘 이상의 static synchronized 메서드가 포함된 경우, 메서드 내에서 동시에 하나의 스레드만 실행할 수 있다.
즉, 스레드 A가 subtract()
를 실행 중인 경우, 스레드 B는 스레드 A가 종료될 때까지 add()
를 실행할 수 없다.
✔️ wait(), notifyAll() 응용
멀티스레드 환경에서는 synchronized 키워드를 사용하여 동기화를 하더라도 고려해야 할 것들이 많이 있다.
그중 하나가 producer-consumer 문제이다.
예를들어 상품(MyBox)를 공급해주는 생산자(producer)와 물건을 소비하는 소비자(consumer)가 있을 경우, 생산자는 상품이 이미 마켓에 가득 찬 경우에 추가로 물건을 공급할 수 없기 때문에 문제가 생길 수 있고, 소비자는 마켓에 물건이 하나도 없을 경우에 상품을 소비할 수 없기 때문에 문제가 생길수 있다.
이러한 경우에 적절한 동기화 처리를 하기 위해서 wait()
와 notify()
, notifyAll()
메서드가 사용될 수 있다.
✔️ 갖고 있던 고유 Lock을 해제하고, 스레드를 잠들게 함
Lock을 소유한 Thread가 자신의 제어권을 양보하고 WAITING
또는 TIMED_WAITING
(일시 정지)상태에서 대기하기 위해서 사용된다.
✔️ notify()
: 잠들어 있던 스레드 중 임의로 하나를 골라 깨움
✔️ notifyAll()
: 호출로 잠들어 있던 스레드 모두 깨움
wait상태에 빠져있는 다른 Thread를 다시 RUNNABLE
상태(실행 대기 상태)로 변경시키는 역할을 한다.
호출하는 스레드가 반드시 고유 Lock을 갖고 있어야 한다. 다시 말해, synchronized 블록 내에서 호출되어야 한다.
고유 Lock을 획득하지 않은 상태에서 위 메서드들 중 하나를 호출하면 IllegalMonitorStateException
가 발생한다.
또한 유의해야 하는 점은 이들은 Thread의 메서드가 아니라 Object의 메서드라는 점이다.
📌 상품이 가득 찬 경우
- 마켓에 물건이 가득 찼을 경우에는 생산자(producer)가 더이상 생산하지 않고 기다리게 한다.
➡️wait()
- 소비자(consumer)가 물건을 소비한 후에 생산자(producer)에게 알려준다.
➡️notifyAll()
📌 상품이 없을 경우
- 마켓에 물건이 없을 경우에는 소비자(consumer)가 더이상 소비하지 못하고 기다리게 한다.
➡️wait()
- 생산자(producer)가 물건을 생산한 후에 소비자(consumer)에게 알려준다.
➡️notifyAll()
class MyBox {
private int contents;
private boolean isEmpty = true;
public synchronized int get() {
while(isEmpty) {
try {
wait();
} catch (InterruptedException e) {}
}
isEmpty = !isEmpty;
notifyAll();
System.out.println(Thread.currentThread().getName() + " : 소비 " + contents);
return contents;
}
public synchronized void put(int value) {
while(!isEmpty) {
try {
wait();
} catch (InterruptedException e) {}
}
contents = value;
System.out.println(Thread.currentThread().getName() + " : 생산 " + value) ;
isEmpty = !isEmpty;
notifyAll();
}
}
class Consumer extends Thread {
private MyBox box;
public Consumer(MyBox c) {
box = c;
}
public void run() {
int value = 0;
for(int i = 0; i < 10; i++) {
box.get();
try {
sleep(100);
} catch (InterruptedException e) {}
}
}
}
class Producer extends Thread {
private MyBox box;
public Producer(MyBox box) {
this.box = box;
}
public void run() {
for(int i = 0; i < 20; i++) {
box.put(i);
try {
sleep(100);
} catch (InterruptedException e) {}
}
}
}
public class ProducerConsumer {
public static void main(String[] args) {
MyBox c = new MyBox();
Producer p1 = new Producer(c);
Consumer c1 = new Consumer(c);
Consumer c2 = new Consumer(c);
p1.start();
c1.start();
c2.start();
}
}
References
: https://yolojeb.tistory.com/11
: https://jenkov.com/tutorials/java-concurrency/synchronized.html