동시성 문제 발생 테스트
package thread.sync;
public class WithdrawTask implements Runnable {
private BankAccount account;
private int amount;
public WithdrawTask(BankAccount account, int amount) {
this.account = account;
this.amount = amount;
}
@Override
public void run() {
account.withdraw(amount);
}
}
package thread.sync;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class BankAccountV1 implements BankAccount {
private int balance;
public BankAccountV1(int initialBalance) {
this.balance = initialBalance;
}
@Override
public boolean withdraw(int amount) {
log("거래 시작 : " + getClass().getSimpleName());
// 잔도가 출금액 보다 적으면, 진행하면 안됨
log("[검증 시작] 출금액 : " + amount + " , 잔액: " + balance);
if(balance < amount) {
log("[검증 실패] 출금액 : " + amount + " , 잔액: " + balance);
return false;
}
// 잔고가 출금액 보다 많으면, 진행
log("[검증 완료] 출금액 : " + amount + " , 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액 : " + amount + " , 잔액: " + balance);
log("거래 종료 ");
return true;
}
@Override
public int getBalance() {
return balance;
}
}
package thread.sync;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class BankMain {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccountV1(1000);
Thread t1 = new Thread(new WithdrawTask(account, 800) , "t1");
Thread t2 = new Thread(new WithdrawTask(account, 800) , "t2");
t1.start();
t2.start();
sleep(500); // 검증 완료까지 잠시 대기
log("t1 state: " + t1.getState());
log("t2 state: " + t2.getState());
t1.join();
t2.join();
log(" 최종 잔액 : " + account.getBalance());
}
}
실행 결과 1
- 실행 결과에서 시간이 완전히 같다는 사실을 통해 두 스레드가 같이 실행된 것을 대략 확인할 수 있다
- 출금된 돈은 1600 원인데 잔액이 200원이 되는 문제가 발생하였다..
실행 결과 2
- t2 가 약간 빠르게 실행되어 t1 이 그 이후 출금하여 -600원이 되었다.
- 잔액이 마이너스가 되는 문제가 발생하였다.
같은 코드지만 운영체제 스레드 시간 순서에 따라 실행결과 1 또는 2의 결과를 가진다.
공유자원
잔액 balance 은 여러 스레드가 함께 사용하는 공유 자원이다.
Critical Section(임계 영역)
멀티 스레드 환경에서 하나의 스레드만 접근할 수 있도록 보호해야 하는 코드 영역
- 여러 스레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 또 중요한 코드 부분을 뜻한다.
- 여러 스레드가 동시에 접근해서는 안 되는 공유자원을 접근하거나 수정하는 부분을 의미한다
- 예) 공유 변수나 공유 객체 수정
- 출금 로직이 바로 임계 영역이다.
- 더 자세히는 출금을 진행할 때 balance을 검증하는 단계부터 잔액의 계산을 완료할 때 까지가 임계영역이다.
- 여기서 balance는 여러 스레드가 동시에 접근해서는 안 되는 공유자원이다.
synchronized 메서드
자바의 synchronized 키워드를 사용하면 한 번에 하나의 스레드만 실행할 수 있는 코드 구간을 만들 수 있다.
package thread.sync;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class BankAccountV2 implements BankAccount {
private int balance;
public BankAccountV2(int initialBalance) {
this.balance = initialBalance;
}
@Override
public synchronized boolean withdraw(int amount) {
log("거래 시작 : " + getClass().getSimpleName());
// 잔도가 출금액 보다 적으면, 진행하면 안됨
log("[검증 시작] 출금액 : " + amount + " , 잔액: " + balance);
if(balance < amount) {
log("[검증 실패] 출금액 : " + amount + " , 잔액: " + balance);
return false;
}
// 잔고가 출금액 보다 많으면, 진행
log("[검증 완료] 출금액 : " + amount + " , 잔액: " + balance);
sleep(1000); // 출금에 걸리는 시간으로 가정
balance = balance - amount;
log("[출금 완료] 출금액 : " + amount + " , 잔액: " + balance);
log("거래 종료 ");
return true;
}
@Override
public synchronized int getBalance() {
return balance;
}
}
package thread.sync;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class BankMain {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccountV2(1000);
Thread t1 = new Thread(new WithdrawTask(account, 800) , "t1");
Thread t2 = new Thread(new WithdrawTask(account, 800) , "t2");
t1.start();
t2.start();
sleep(500); // 검증 완료까지 잠시 대기
log("t1 state: " + t1.getState());
log("t2 state: " + t2.getState());
t1.join();
t2.join();
log(" 최종 잔액 : " + account.getBalance());
}
}
synchronized 분석
모든 객체(인스턴스)는 내부에 자신만의 락(lock)을 가지고 있다.
- 모니터 락(monitor lock)이라고도 부른다.
- 객체 내부에 있고 우리가 확인하기 어렵다.
스레드가 synchronized 키워드가 있는 메서드에 진입하려면 반드시 해당 인스턴스의 락이 있어야 한다.
- 여기서 BankAccount (x001) 인스턴스의 synchronized withdraw() 메서드를 호출하므로 이 인스턴스 락이 필요하다.
스레드 t1, t2는 withdraw()를 실행하기 직전이다.
- t1 이 먼저 실행된다고 가정하겠다.
- 스레드 t1 이 먼저 synchronized 키워드가 있는 withdraw() 메서드를 호출한다.
- synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
- 락이 있으므로 스레드 t1 은 BankAccount(x001) 인스턴스에 있는 락을 획득한다.
- 스레드 t1 은 해당 인스턴스의 락을 획득했기 때문에 withdraw() 메서드에 진입할 수 있다.
- 스레드 t2 도 withdrwa() 메서드 호출을 시도한다. synchronized 메서드를 호출하려면 먼저 해당 인스턴스의 락이 필요하다.
- 스레드 t2는 BankAccount(x001) 인스턴스에 있는 락 획득을 시도한다. 하지만 락이 없으면 t2 스레드는 락을 획득할 때까지 BLOCKED 상태로 대기한다.
- t2 스레드는 상태는 BLOCKED -> BLOCKED 상태로 변하고, 락을 획득할 때까지 무한정 대기한다.
참고로 BLOCKED 상태가 되면 락을 다시 획득하기 전까지는 계속 대기하고, CPU 실행 스케줄링에 들어가지 않는다.
- t1 잔액(1000)이 출금액(800) 보다 많으므로 검증 로직을 통과한다.
t1 : 잔액 1000원에서 800원을 출금하고 계산 결과인 200원을 잔액( balance )에 반영한다.
- t2 인스턴스에 락이 반납되면 락 획득을 대기하는 스레드는 자동으로 락을 획득한다.
- 이때 락을 획득한 스레드는 BLOCKED-> RUNNABLE 상태가 되고, 다시 코드를 실행한다.
t2 : 락을 반납하면서 return 한다.
t1 은 800원 출금에 성공하지만, t2는 잔액 부족으로 출금에 실패한다. 그리고 최종 잔액은 1000원에서 200원이 돼 프로 정확하게 맞다.
이렇게 자바의 synchronized를 사용하면 한 번에 하나의 스레드만 실행하는 안전한 임계 영역 구간을 편리하게 만 들 수 있다.
참고: 락을 획득하는 순서는 보장되지 않는다.
- 만약 BankAccount(x001) 인스턴스의 withdraw()를 수많은 스레드가 동시에 호출한다면, 1개의 스레드만 락 을 획득하고 나머지는 모두 BLOCKED 상태가 된다.
- 그리고 이후에 BankAccount(x001) 인스턴스에 락을 반납하면, 해당 인스턴스의 락을 기다리는 수많은 스레드 중에 하나의 스레드만 락을 획득하고, 락을 획득한 스레드만 BLOCKED RUNNABLE 상태가 된다.
- 이때 어떤 순서로 락을 획득하는지는 자바 표준에 정의되어 있지 않다. 따라서 순서를 보장하지 않고, 환경에 따라서 순서가 달라질 수 있다.
참고: volatile를 사용하지 않아도 synchronized 안에서 접근하는 변수의 메모리 가시성 문제는 해결된다.
synchronized 동기화 정리
자바에서 동기화(synchronization)는 여러 스레드가 동시에 접근할 수 있는 자원(예: 객체, 메서드)에 대해 일관성 있고 안 전한 접근을 보장하기 위한 메커니즘이다. 동기화는 주로 멀티스레드 환경에서 발생할 수 있는 문제, 예를 들어 데이터 손상이나 예기치 않은 결과를 방지하기 위해 사용된다.
메서드 동기화 : 메서드는 synchronized로 선언해서, 메서드에 접근하는 스레드가 하나뿐이도록 보장한다.
public synchronized void synchronizedMethod() {
// 코드
}
블록 동기화 : 코드 블록을 synchronized로 감싸서, 동기화를 구현할 수 있다.
public void method() {
synchronized(this) {
// 동기화된 코드
}
}
이런 동기화를 사용하면 다음 문제들을 해결할 수 있다.
- 경합 조건 (Race condition) : 두 개 이상의 스레드가 경쟁적으로 동일한 자원을 수정할 때 발생하는 문제
- 데이터 일관성 : 여러 스레드가 동시에 읽고 쓰는 데이터의 일관성을 유지
동기화는 멀티스레드 환경에서 필수적인 기능이지만, 과도하게 사용할 경우 성능 저하를 초래할 수 있으므로 꼭 필요한 곳에 적절히 사용해야 한다.
지역변수는 스레드의 개별 저장 공간인 스택영역에 저장되기 때문에 동기화문제를 고려하지 않아도 된다.
package thread.sync;
import static util.MyLogger.log;
public class SyncTest2Main {
public static void main(String[] args) throws InterruptedException {
MyCounter myCounter = new MyCounter();
Runnable task = new Runnable() {
@Override
public void run() {
myCounter.count();
}
};
Thread thread1 = new Thread(task, "Thread-1");
Thread thread2 = new Thread(task, "Thread-2");
thread1.start();
thread2.start();
}
static class MyCounter {
public void count() {
// 스택 영역은 스레드가 가지는 별도의 메모리 영역이다. 이 메모리 공간은 다른 스레드와 공유하지 않ㅎ는다.
// 지역변수는 스레드의 개별저장 공간인 스택 영역에 생성된다.
// 따라서 지역 변수는 절대로! 다른 스레드와 공유되지않는다!
// 지역 변수를 제외한 인스턴스의 멤버 변수(필드), 클래스 변수등은 공유될 수 있다.
int localValue = 0;
for (int i = 0; i < 1000; i++) {
localValue = localValue + 1;
}
log("결과: " + localValue);
}
}
}
'JAVA' 카테고리의 다른 글
유니코드, UTF-8, 직렬화 (1) | 2024.11.18 |
---|---|
[JAVA] String intern() (1) | 2024.11.15 |
[JAVA] 스레드 Interrupted (0) | 2024.11.13 |
[JAVA] 스레드 Join (0) | 2024.11.12 |
[JAVA] Runnable 을 만드는 다양한 방법 (0) | 2024.11.11 |