본문 바로가기
java

Thread(3) - volatile, synchronize, lock

by CodingMasterLSW 2025. 1. 19.

volatile과 메모리 가시성 문제

처음 들어보는 용어다. 이게 뭘까?

 

영한님의 예시 코드를 보면서 이해를 하는 게 가장 빠르다. 살펴보자.

 

public class VolatileFlagMain {

    public static void main(String[] args) {
        MyTask task = new MyTask();
        Thread t = new Thread(task, "work");
        t.start();

        sleep(1000);
        task.runFlag = false;
        log("main 종료");
    }

    static class MyTask implements Runnable {
        boolean runFlag = true;

        @Override
        public void run() {
            log("task 시작");
            while (runFlag) {
                // runFlag가 false로 변하면 탈출
            }
            log("task 종료");
        }
    }
}

 

MyTask를 작동시킨 후 메인 스레드가 1초 동안 time_waiting 상태로 대기하다가, runFlag를 false로 바꿔 task를 종료하는 코드다.

해당 코드의 예상 출력결과는 환경에 따라 다를 수 있지만, 

task 시작  
main 종료   (task 종료)
task 종료   (main 종료)

 

이런 결과값이 나올 것이라고 예측할 수 있다. 하지만, 해당 코드를 실행하면 우리가 예상한 결괏값이 아닌 이상한 결괏값이 나온다.

task 시작
main 종료

 

task 종료 로그가 안 나오고, 프로그램은 계속해서 실행중이다. 왜 그런 걸까? 

 

실제 메모리 접근 방식이다. 스레드에는 CPU가 할당되어있고, 캐시 메모리 또한 존재한다. 초기에 runFlag 값을 true로 설정했기에,

각각의 스레드에서는 해당 runFlag= true 값을 본인들의 캐시 메모리에 별도로 저장한다. 

이후에 메인 스레드에서 runFlag = false로 바꿨다. 오류가 나는 부분은 해당 부분이다. 왜 오류가 나는지 그림을 통해 살펴보자.

 

task.runFlag = false;

메인 메모리의 runFlag = true -> false로 바뀌고, 각각의 캐시메모리들이 다시 한번 이 값을 읽어 true -> false로 바뀌는 것을 예상했지만 그렇지 않다. main스레드의 runFlag = false로 바뀌었다. 그렇다면 main memory에 runflag = false 값은 언제 반영될까?

 

모른다. 다만, context switching이 발생할 때, 값이 수정될 가능성이 높다. main memory에 값이 반영되어도 또 다른 문제점이 존재한다. work 스레드 또한 바뀐 메인 메모리의 값을 읽어와야 한다는 점이다. 이 또한 언제 반영될지 모른다.

 

이렇게 멀티스레드 환경에서 한 스레드가 바꾼 값을 다른 스레드에서 언제 보이는지에 대한 문제를 메모리 가시성 문제라고 한다.

 

이 문제를 해결하려면 어떻게 해야할까? 간단하다. 그냥 메인 메모리에 바로 접근하면 된다.

이렇게 메인 메모리에 직접 접근하려면, 해당 값에 volatile을 붙여주면 된다.

volatile boolean runFlag = true;

 

이렇게 volatile을 사용하면 메인 메모리에 직접 접근할 수 있다. 하지만, 캐시 메모리가 아닌 메인 메모리에 직접 접근하기에 속도가 느려진다. 성능 감소가 있기에 상황을 보고 꼭 필요한 상황에만 volatile을 사용하도록 하자.


Synchronized와 동시성

멀티스레드 환경에서는 동시성 문제가 발생할 수 있다. 동시성 이해를 쉽게 이해하기 위해서, 김영한 님의 예시를 통해서 이해해 보자. 

 

public interface BankAccount {

    boolean withdraw(int amount);

    int getBalance();
}

 

public class BankAccountV1 implements BankAccount {

    volatile private int balance;

    public BankAccountV1(int initialBalance) {
        this.balance = initialBalance;
    }

    @Override
    public boolean withdraw(int amount) {
        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;
    }
}

 

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,000원으로 설정했다. 우리가 짠 코드대로라면, 로그는 다음과 같이 나와야 할 것이다.

 

[검증 시작] 출금액: 800, 잔액: 1000
[검증 완료] 출금액: 800, 잔액: 1000
[출금 완료] 출금액: 800, 잔액: 200
[검증 시작] 출금액: 800, 잔액: 200
[검증 실패] 출금액: 800, 잔액: 200
최종 잔액: 200

 

위와 같은 로그로 안 나올텐데, 출금 하나는 성공하고, 하나는 실패해 최종 잔액 200원인 로그가 나올 것이라고 예상할 수 있다.

하지만... 멀티스레드 환경에서는 동시성 문제가 생겨 예상한 값이 나오지 않는다. 이유를 알아보자.

결과부터 말하자면, -600, 200이라는 값이 나온다.

 

 

if (balance < amount) {
    log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
    return false;
  }

 

case 1) -600이 나오는 경우

 

t1 스레드가 t2 스레드보다 아주 미세하게 먼저 실행된다고 가정하자. 

 

 

문제는 다음과 같다. balance에서 잔고를 빼기 전에 thread2가 if 조건문을 검증한다는 것이다.

그렇기 때문에, 아직 잔고에서 amount를 빼지 않아 if 조건을 thread 1,2가 전부 충족하고, balance = -600이라는 결괏값이 나오는 것이다.

 

case 2) 200이 나오지만, 검증 실패 로그가 나오지 않은 경우

이 경우는 thread 1,2가 동시에 실행이 된 경우다.

balance = balance - amount; 이 시점에서 thread 1, 2 둘다 balance = 1000, amount = 600이다. 즉, amount는 총 800*2 = 1600원이 빠져나갔지만, balance는 200원이라고 나오는 기괴한 상황이 발생한다.

 

이러한 문제들을 동시성 문제라고 한다. 동시성 문제를 해결하기 위해서는 여러 방법이 있지만, 주제에 맞게 Synchronized를 먼저 알아보자.

 

적용 방법은 매우 간단하다.

public synchronized boolean withdraw(int amount) {

 

그냥 synchronized만 붙여주면 동시성 문제가 해결된다. 매우 쉽다

 

그럼 내부적으로 어떻게 관리를 하기에 synchronized만 붙여주면 되는걸까?

 

모든 객체들은 lock을 가지고 있고, 스레드가 해당 객체에 접근하기 위해서는 lock을 소유하고 있어야 한다.

 

 

thread-1은 withdraw()의 lock()을 획득하고, 작업을 실행한다.

thread-2 또한 작업을 실행하려 했지만, 이미 thread-1이 lock을 보유하고 있기에 Blocked 상태가 된다.

이전 포스팅에서 언급했던 스레드의 상태 중 일부인 BLOCKED가 바로 이 상태다.

thread-1이 작업을 완료 후 lock을 반납한다. 이후에 thread-2는 lock이 반납될 때까지 기다리다가, lock 획득 후 withdraw() 메서드를 실행한다.

 

실행 결과

[       t1] [검증 시작] 출금액: 800, 잔액: 1000
[     main] t1 state: TIMED_WAITING
[     main] t2 state: BLOCKED
[       t1] [출금 완료] 출금액: 800, 잔액: 200
[       t1] 거래 종료
[       t2] [검증 시작] 출금액: 800, 잔액: 200
[       t2] [검증 실패] 출금액: 800, 잔액: 200
[     main] 최종 잔액: 200

 

우리가 예상한 결괏값이 이제 나오는 것을 확인할 수 있다. 

 

메서드 앞에 synchronized를 적어주는 건 간편하지만, 어떻게 보면 효율성이 좋지 않다.

 

    @Override
    public synchronized boolean withdraw(int amount) {
        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }
        sleep(1000); // 출금에 걸리는 시간으로 가정
        balance = balance - amount;
        log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        log("거래 종료");
        return true;
    }

 

이 코드에서 공유자원을 건드리기에 반드시 따로따로 실행되어야 하는 코드는 어느 시점일까?

if (balance < amount) {
            log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
            return false;
        }
sleep(1000); 
balance = balance - amount;
log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);

 

이 부분 아닐까? 이렇게 공유자원에 여러 스레드가 동시에 접근했을 때 문제가 되는 부분을 임계영역이라고 한다.

 

synchornized는 한 번에 한 개의 스레드만 실행시킬 수 있다. 그렇기에, 꼭 필요한 부분인 임계영역에만 synchronized를 걸어주는 게 좋다. 현재 메서드에서는 임계영역이 아닌 부분에도 synchronized가 걸려있어 성능을 떨어트린다. 임계영역에만 synchronized를 사용하고 싶다면, 다음과 같이 리팩토링을 할 수 있다.

 

    @Override
    public boolean withdraw(int amount) {
        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        synchronized (this) {
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }
            sleep(1000); // 출금에 걸리는 시간으로 가정
            balance = balance - amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        }
        log("거래 종료");
        return true;
    }

 

이렇게 synchronized (this) {}를 사용해 임계영역을 묶어줄 수 있다. 이러면 꼭 필요한 임계영역만 lock을 걸 수 있어, 성능 향상을 조금이라도 더 낼 수 있다.

 

또한, synchornized를 사용하면 메모리 가시성 문제 또한 해결할 수 있다. 모든 스레드는 임계영역에 들어가기 전에 메모리 상태를 일관되게 반영한다. 즉, synchronized 블록에 들어가면, 해당 스레드는 이전에 수행된 모든 변경사항을 메인 메모리에서 다시 읽고, synchronozed 블록을 탈출할 때 모든 변경사항을 다시 메인 메모리에 저장한다.

 

다만 synchornized에는 한계점이 존재한다.

 

1) 스레드가 무기한 BLOCKED 상태에 빠진다.

2) Lock이 반납된 이후, 어떤 스레드가 Lock을 획득할지 모른다

 

1번이 큰 문제점인데, 김영한 선생님은 이 문제를 맛집에 줄을 서면 10시간이든 100시간이든 밥을 먹을 때까지 강제적으로 계속 기다려야 하는 것이라고 비유를 통해 설명해 주셨다.

 

 

Synchronized는 간편하게 임계영역을 보호할 수 있다는 장점이 있다. 하지만 조금 구체적으로 lock을 관리하고 싶다면 ReenterantLock을 사용해야 한다.


ReenterantLock

Synchronized의 문제점을 ReentrantLock을 이용해 해결할 수 있다. 

 

ReentrantLock을 간단하게 설명하자면 lock 인터페이스의 구현체다. 제공해 주는 기능들에 대해 알아보고 넘어가자.

 

void lock()

- 락을 획득한다. 다른 스레드가 Lock을 사용 중이라면, Waiting 상태로 대기한다. (Blocked 상태 X),

이 메서드는 인터럽트에 응답하지 않는다.

 

void lockInterruptibly()

- 락 획득을 시도하되, 사용중이라면 waiting 상태로 대기한다. 이 메서드는 인터럽트에 응답한다.

 

boolean tryLock()

- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 다른 스레드가 락을 가지고 있는 상태라면 false, 그렇지 않다면 락을 획득하고 true를 반환한다.

 

boolean tryLock(long time, TimeUnit unit)

- 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 true, 그렇지 않다면 false를 반환한다. 대기 중 interrupt가 발생하면, 락 획득을 포기한다.

 

void unlock()

- 락을 해제한다. 락을 획득한 스레드가 호출해야 한다.

 

 

Synchronized를 사용한 코드를 lock을 사용하도록 바꿔보자.

 

private final Lock lock = new ReentrantLock();

 

    @Override
    public boolean withdraw(int amount) {
        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        lock.lock();
        try {
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }
            sleep(1000); // 출금에 걸리는 시간으로 가정
            balance = balance - amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        } finally {
            lock.unlock();
        }
        log("거래 종료");
        return true;
    }

 

간단하다. 해당 코드에서 try-finally를 사용했다. 이유가 뭘까?

 

만약 상단의 try-catch 문을 통해 exceptiondl 발생한다면 unlock() 즉, 스레드가 lock 반납을 하지 못하게 된다. 그럼 다른 스레드들은 무한 대기... 락 획득을 못 하게 된다. 이 경우에 lock.lock()이 아닌, 일정 시간 동안 waiting 하도록 바꿀 순 있지만, 그래도 lock을 가지고 있는 스레드가 예외가 터져도 lock 반납을 하고 가는 게 이상적이다. 그렇기에 finally 구문을 통해 반드시 어떤 경우라도 lock 반납을 하도록 코드를 작성하셨다.

 

한 가지 알고 가야 할 점은, 스레드의 상태가 BLOCKED가 아닌, WAITING 상태라는 점이다. 그림을 통해 간단히 이해해 보자.

Synchronized와 마찬가지로 lock을 획득한다. 신경 써야 할 부분은 객체가 가지고 있는 Lock 이 아닌, ReentrantLock에서 제공해 주는 Lock을 사용한다는 점이다.

thread-2는 lock이 없어 대기해야 한다.

 

이때, Synchronized와 다르게, ReentrantLock에서 제공하는 대기 큐에 들어가 waiting 상태로 lock 획득을 기다린다.

unlock()이 호출되었을 때의 과정이다. 우선 thread-1이 lock을 반납하고, 대기큐에 있는 thread-2를 깨운다. 조금 더 정확하게 말하자면, lock.unlock()을 호출할 때, LockSupport.unpark(thread)가 호출되는데, 자세한 내용은 김영한 선생님 강의를 참고하거나, LockSupport에 대해 따로 찾아보는 것을 추천한다.

 

이후에 Waiting -> Runnable 상태로 변경되며, lock을 획득한다.

 

 

lock을 획득한 이후에는 임계영역 코드를 실행한다.

 

lock을 사용하면 synchronized보다 다양하게, lock을 다룰 수 있다. 위에서 설명했다시피, 일정시간만 기다릴 거면 

lock.tryLock(100, TimeUnit.MILLISECONDS)

 

이런 식으로 lock 설정을 해줘도 된다. 

 

추가로, ReentrantLock은 fair, unfair 모드를 제공해 준다. Synchronized에서 언급한 공정성 문제를 해결해 주는 것이다.

사용법은, 

Lock lock = new ReentrantLock(true);

 

이런 식으로 lock 객체를 생성할 때 true 값을 넣어주면 공정모드, false를 넣어주면 비공정모드다. 기본값은 false이므로, 매개변수에 아무 값도 넣지 않는다면 자동으로 false로 설정된다.

 

사실 ReentrantLock은 대기시점에 Queue를 이용하기에, 어느 정도의 공정성을 보장해 준다. 다만, 완벽하지는 않다. 그렇기에 Lock 획득 순서가 정말 공정하게 이루어져아한다면 공정모드를 사용하되, 이런 경우가 아니라면 그냥 비공정 모드를 사용하자. (공정 모드는 성능이 느려질 수도 있음)

 

Lock을 사용하면 Synchronized의 한계점을 극복할 수 있다. 단점이라고 하면 음... Synchronized보다는 조금 사용법이 어렵다.

구체적으로 lock을 다루고 싶다면 ReentrantLock을 사용하자!

'java' 카테고리의 다른 글

Stream  (0) 2025.02.25
함수형 인터페이스  (0) 2025.02.25
Thread(2) - 스레드의 상태  (6) 2025.01.15
Thread(1) - java의 메모리 구조, 프로세스의 메모리 구조  (4) 2025.01.14
Java 컬렉션 정리 for 코딩테스트 - Queue  (9) 2024.12.25