본문 바로가기

Java

[Java] 동시성 문제 해결하기

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. 스레드의 스택 프레임으로 변수의 값을 읽어들인다.
  2. 출금 연산을 수행한다.
  3. 연산 결과를 반영한다.

하지만 두 개의 스레드가 이를 실행한다면 다음의 결과가 나타날 수 있다.

  1. 1번 스레드가 잔액을 읽는다(1000), 1번 스레드는 검증 로직을 통과한다.
  2. 1번 스레드가 출금을 진행하는 동안 2번 스레드가 잔액을 읽는다(1000). 2번 스래드는 검증 로직을 통과한다.
  3. 1번 스레드에서 출금을 한다(1000-800=200)
  4. 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 블록 내부의 영역은 오직 하나의 스레드만 접근이 가능하다. 

간단히 보면 이렇게 생겼다. 모든 인스턴스는 내부에 모니터 락이라 불리는 락을 가지고 있다. 이는 다음과 같이 동작한다.

  1. t1이 withdraw를 실행하고, synchronized 코드에 진입하기 위해 락을 획득 후 진입한다.
  2. t2가 lock을 획득하려 하나 t1이 가지고 있으므로 실패하고, BLOCKED 상태로 변하여 락을 얻을 때 까지 대기한다.
  3. 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