1. 개요
본 포스팅은 김영한 강사님의 인프런 강의 "자바 고급 1편" 중 동시성 문제를 해결하기 위한 synchronized, ReentrantLock에 대해 정리한 포스팅입니다.
2. 동시성 문제?
다음 클래스가 있다 가정하자.
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("[검증 실패]");
return false;
}
//잔고가 출금액보다 많으면 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); //출금에 걸리는 시간으로 가정
balance -= amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
log("거래 종료");
return true;
}
@Override
public int getBalance() {
return balance;
}
}
은행 계정 클래스이다. 초기 자금 balance를 생성하고, withdraw() 함수를 통해 amount 만큼 출금할 수 있다. 만약 금액이 부족할 경우 false를 반환하고 출금을 하지 않는다.
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);
}
}
그리고 계정에서 일정 금액만큼 출금할 수 있는 Job이다.
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());
}
}
초기 자금 1000원부터 시작하여 두 개의 스레드에서 800원씩 출금하는 로직이다. 이를 실행하게 되면 최종 잔고가 -600원까지 내려가는 현상이 발생한다.
왜 이런 현상이 발생하는 것일까? 위 로직은 다음의 과정을 거쳐 진행된다.
- 스레드의 스택 프레임으로 변수의 값을 읽어들인다.
- 출금 연산을 수행한다.
- 연산 결과를 반영한다.
하지만 두 개의 스레드가 이를 실행한다면 다음의 결과가 나타날 수 있다.
- 1번 스레드가 잔액을 읽는다(1000), 1번 스레드는 검증 로직을 통과한다.
- 1번 스레드가 출금을 진행하는 동안 2번 스레드가 잔액을 읽는다(1000). 2번 스래드는 검증 로직을 통과한다.
- 1번 스레드에서 출금을 한다(1000-800=200)
- 2번 스레드에서 출금을 한다(200-800=-600)
위 연산에서 2번 스레드는 1번 스레드의 출금 연산이 진행되는 중에 잔액을 읽어들여 검증 로직을 통과하게 된다.
3. 해결?
위 문제를 해결하기 위해서는 해당 로직이 동작하는 동안 하나의 스레드만 접근이 가능해야 한다. 이 구간을 critical section이라 한다.
public boolean withdraw(int amount) {
log("거래 시작: " + getClass().getSimpleName());
synchronized (this) {
log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
//잔고가 출금액보다 적으면 진행 불가
if (balance < amount) {
log("[검증 실패]");
return false;
}
//잔고가 출금액보다 많으면 진행
log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
sleep(1000); //출금에 걸리는 시간으로 가정
balance -= amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
}
log("거래 종료");
return true;
}
새 출금 로직이다. synchronized 블록 내부의 영역은 오직 하나의 스레드만 접근이 가능하다.
간단히 보면 이렇게 생겼다. 모든 인스턴스는 내부에 모니터 락이라 불리는 락을 가지고 있다. 이는 다음과 같이 동작한다.
- t1이 withdraw를 실행하고, synchronized 코드에 진입하기 위해 락을 획득 후 진입한다.
- t2가 lock을 획득하려 하나 t1이 가지고 있으므로 실패하고, BLOCKED 상태로 변하여 락을 얻을 때 까지 대기한다.
- t1이 종료되면 t2는 락을 획득하고 로직을 실행한다.
물론 위 스레드의 실행 순서는 보장되지 않는다.
4. 또 다른 문제
위 방식에서 한 스레드가 락을 획득하여 로직을 수행하는 동안 나머지 스레드는 락을 얻기 위해 무한정 대기하는 상황이 발생할 수 있다. 이를 해결하기 위해 자바 1.5부터 java.util.concurrent라는 라이브러리 패키지가 추가된다.
- LockSupport.park(): 스레드를 WAITING 상태로 변경한다.
- LockSupport.parkNanos(nanos): nanos 나노초 동안 WAITING 상태로 변경한다.
- LockSupport.unpark(): WAITING->RUNNABLE 상태로 변경한다.
위 함수에서 WAITING 상태는 synchronized에서 락을 획득하지 못한 BLOCKED와 달리 인터럽트로 해제할 수 있다. 이를 더 고수준으로 개발하기 위해 Lock 인터페이스와 이를 구현한 ReentrantLock가 있다. 여기서의 락은 모니터 락이 아닌 기능이다.
public interface Lock {
void lock(); //락 획득을 시도한다. 성공할 때 까지 기다린다.
void lockInterruptibly() throws InterruptedException; //락 획득을 시도한다. 인터럽트가 발생하면 이를 포기한다.
boolean tryLock(); //락 획득을 시도한다. 실패 시 바로 포기한다.
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; //락 획득을 time 만큼만 시도한다.
void unlock(); //락을 해제한다.
Condition newCondition(); //스레드의 조건을 반환한다.
}
이를 구현한 ReentrantedLock이 존재하는 데, 두 모드가 존재한다.
// 비공정 모드 락: lock을 아무나 획득할 수 있다. 성능은 뛰어나지만 특정 스레드가 락을 오랫동안 얻지 못할 수 있다.
private final Lock nonFairLock = new ReentrantLock();
// 공정 모드 락: lock을 요청한 순서대로 제공한다. 성능은 떨어지지만 모든 스레드가 공평하게 락을 획득할 수 있다.
private final Lock fairLock = new ReentrantLock(true);
'Java' 카테고리의 다른 글
[Java] BlockingQueue로 생산자-소비자 문제 해결하기 (0) | 2024.09.10 |
---|---|
Java Record (0) | 2024.08.31 |
Java volatile - 메모리 가시성 (0) | 2024.08.29 |