JAVA

ReentrantLock

경딩 2025. 1. 7. 22:41

ReentrantLock - 이론

 

자바는 1.0부터 존재한 synchronized와 BLOCKED 상태를 통한 임계 영역 관리의 한계를 극복하기 위해 자바 1.5부터 Lock 인터페이스와 ReentrantLock 구현체를 제공한다.

 

synchronized 단점 

  • 무한 대기 : BLOCKED 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
    • 특정 시간까지만 대기하는 타임아웃 X 
    • 중간에 인터럽트 X
  • 공정성 : 락이 돌아왔을 때 ` BLOCKED ` 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있다.

 

 package java.util.concurrent.locks;
 public interface Lock {
     void lock();
     void lockInterruptibly() throws InterruptedException;
     boolean tryLock();
     boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
     void unlock();
     Condition newCondition();
 }

Lock  인터페이스는 동시성 프로그래밍에서 쓰이는 안전한 임계 영역을 위한 락을 구현하는데 사용된다.

 

 

Lock 인터페이스는 다음과 같은 메서드를 제공한다. 대표적인 구현체로는 ReentrantLock 이 있다.

 

  • void lock()
    • 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 현재 스레드는 대기(WAITTING) 한다. 이 메서드는 인터럽트에 응답하지 않는다.
    • 예 ) 맛집에 한번 줄을 서면 끝까지 기다린다. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기하지 않고 기다린다.

 

주의!

여기서 사용하는 락은 객체 내부에 있는 모니터락이 아니다! Lock 인터페이스와 ReentreantLock 이 제공하는 기능이다!
모니터 락과 BLOCKED 상태는 synchronized 에서만 사용된다.

 

  • void lockInterruptibly()
    • 락 획득을 시도하되, 다른 스레드가 인터럽트 할 수 있도록 한다, 만약 다른 스레드가 이미 락을 획득했다면, 현재 스레드는 락을 획득할 때까지 대기한다. 대기 중에 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
    • 예) 맛집에 한번 줄을 서서 기다린다. 다만 친구가 다른 맛집을 찾았다고 중간에 연락하면 포기한다

 

  • boolean tryLock() 
    • 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 false를 반환하고, 그렇지 않으면 락을 획득하고 true를 반환한다.
    • 예) 맛집에 대기 줄이 없으면 바로 들어가고, 대기 줄이 있으면 즉시 포기한다.

 

  • boolean  tryLock(long time, TimeUnit unit) 
    • 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면  true 를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false 를 반환한다. 이 메서드는 대기 중에 인터럽트가 발생하면 InterruptedException  이 발생하며 락 획득을 포기한다.

 

  •  void unlock() 
    • 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다.
    • 락을 획득한 스레드가 호출해야 하며, 그렇지 않으면  IllegalMonitorStateException 이 발생할 수 있다.
    • 예) 식당안에 있는 손님이 밥을 먹고 나간다. 식당에 자리가 하나 난다. 기다리는 손님께 이런 사실을 알려주어야 한다. 기다리던 손님 중 한 명이 식당에 들어간다.

 

  • Condition newCondition()
    • Condition 객체를 생성하여 반환한다. Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을 기다리거나 신호를 받을 수 있도록 한다. 이는 Object 클래스의 wait, notify, notifyAll 메서드와 유사한 역할을 한다.

 

 

이 메서드들을 사용하면 고수준의 동기화 기법을 구현할 수 있다. Lock 인터페이스는 synchronized 블록보다 더 많은 유연성을 제공하며, 특히 락을 특정 시간만큼만 시도하거나 인터럽트 가능한 락을 사용할 때 유용하다.

이 메서드들을 보면 알겠지만 다양한 메서드를 통해 synchronized 의 단점인 무한 대기문제도 깔끔하게 해결할 수 있다.

 

참고 : lock() 메서드는 인터럽트에 응하지 않는다고 되어있다. 이 메서드의 의도는 인터럽트가 발생해도 무시하고 락을 기다리는 것이다.

앞서 대기( WAITING ) 상태의 스레드에 인터럽트가 발생하면 대기 상태를 빠져나온다고 배웠다. 그런데 lock() 메서드의 설명을 보면 대기(WAITING) 상태인 테 인터럽트에 응하지 않는다고 되어있다. 어떻게 된 것일까?

lock()을 호출해서 락을 얻기 위해 대기 중인 스레드에 인터럽트가 발생하면 순간 대기 상태를 빠져나오는 것은 맞다.

그래서 아주 짧지만 WAITING -> RUNNABLE 이 된다. 그런데 lock() 메서드 안에서 해당 스레드를 다시 WAITING 상태로 강제로 변경해 버린다. 이런 원리로  인터럽트를 무시하는 것이다. 참고로 인터럽트가 필요하면 lockInterruptibly()를 사용하면 된다. 새로운 Lock 은 개발자에게 다양한 선택권을 제공한다.

 

 

공정성

Lock 인터페이스가 제공하는 다양한 기능 덕분에 synchronized의 단점인 무한대기 문제가 해결되었다.

그런데 공정성에 대한 문제가 남아있다.

 

synchronized 단점

  • 공정성 : 락이 돌아왔을 때 BLOCKED 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있다.

 

 

Lock  인터페이스의 대표적인 구현체로 ReentrantLock 이 있는데, 이 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다.

 

package thread.sync;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockEx {

    // 비공적 모드 락
    private final Lock nonFairLock = new ReentrantLock();
    // 공정 모드 락
    private final Lock fairLock = new ReentrantLock(true);

    public void nonFairLockTest() {
        nonFairLock.lock();
        try {
            // 임계 영역
        } finally {
            nonFairLock.unlock();
        }
    }

    public void fairLockTest() {
        fairLock.lock();
        try {
            // 임계 영역
        }  finally {
            fairLock.unlock();
        }
    }

}

 

Reentrant 락은 공정성 (fairness) 모드와 비공정 (non-fair) 모드로 설정할 수 있으며, 이 두 모드는 락을 획득하는 방식에서 차이가 있다.

 

비공정 모드(Non-fair mode)

비공정 모드는 ReentrantLock의 기본 모드이다. 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다. 락을 풀었을 때, 대기 중인 스레드 중 아무나 락을 획득할 수 있다. 이는 락을 빨리 획득할 수 있지만, 특정 스레드가 장기간 락을 획득하지 못할 가능성도 있다.

 

비공정 모드 특징

  • 성능 우선 : 락을 획득하는 속도가 빠르다.
  • 선점 가능 : 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다.
  • 기아 현상 가능성 : 특정 스레드가 계속해서 락을 획득하지 못할 수 있다.

 

공정 모드 (Fair mode)

생성자에서 true를 전달하면 된다.  예)  new ReentrantLock(true) 

공정 모드는 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다. 이는 먼저 대기한 스레드가 먼저 락을 획득하게 되어 스레드 간의 공정성을 보장한다. 그러나 이로 인해 성능이 저하될 수 있다.

 

공정 모드 특징

  • 공정성 보장 : 대기 큐에서 먼저 대기한 스레드가 락을 먼저 획득한다.
  • 기아 현상 방지 : 모든 스레드가 언젠가 락을 획득할 수 있게 보장된다.
  • 성능 저하: 락을 획득하는 속도가 느려질 수 있다.

 

정리 ` Lock` 인터페이스와 ` ReentrantLock` 구현체를 사용하면 ` synchronized` 단점인 무한 대기와 공정성 문제를 모두 해결할 수 있다.

 

ReentrantLock - 활용

앞서 작성한 ` BankAccountV3` 예제를 ` synchronized` 대신에 ` Lock` , ` ReentrantLock` 을 사용하도록 변경해 보자.

 

 

package thread.sync;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV4 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

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

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        lock.lock(); // ReentrantLock 이용하여 lock을 걸기
        try {
            log("[검증 시작] 출금액: " + amount + ", 잔액: " + balance);
            if (balance < amount) {
                log("[검증 실패] 출금액: " + amount + ", 잔액: " + balance);
                return false;
            }

            // 잔고가 출금액 보다 많으면, 진행
            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000); // 출금에 걸리는 시간으로 가정
            balance = balance - amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock 해제
        }
        log("거래 종료");
        return true;
    }

    @Override
    public int getBalance() {
        lock.lock(); // ReentrantLock 이용하여 lock을 걸기
        try {
            return balance;
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock 해제
        }
    }
}

 

  • private final Lock lock = new ReentrantLock()을 사용하도록 선언한다.
  • synchronized(this) 대신에 lock.lock()을 사용해서 락을 건다
    • lock() -> unlock() 까지는 안전한 임계영역이 된다.
  • 임계 영역이 끝나면 반드시! 락을 반납해야 한다. 그렇지 않으면 대기하는 스레드가 락을 얻지 못한다.
  • lock() -> unlock()  까지는 안전한 임계 영역이 된다.
  • 임계 영역이 끝나면 반드시 락! 을 반납해야 한다. 그렇지 않으면 대기하는 스레드가 락을 얻지 못한다.
    • 따라서 lock.unlock() 은 반드시 finally 블록에 작성해야 한다. 이렇게 하면 검증에 실패해서 중간에 return을 호출해도 또는 중간에 예상치 못한 예외가 발생해도 lock.unlock() 이 반드시 호출된다.

주의! 

여기서 사용하는 락은 내부에 있는 모니터 락이 아니다. Lock 인터페이스와 ReentrantLock 이 제공하는 기능이다.

모니터 락과 BLOCKED 상태는 synchronized에서만 사용된다.

public class BankMain {
 public static void main(String[] args) throws InterruptedException {

     BankAccount account = new BankAccountV4(1000);
     }
 }

 

  • BankMain에서 BankAccountV4를 실행하도록 코드를 변경하자.

실행 결과

 

 

20:11:38.778 [       t1] 거래 시작: BankAccountV4
20:11:38.778 [       t2] 거래 시작: BankAccountV4
20:11:38.786 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
20:11:38.786 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
20:11:39.265 [     main] t1 state: TIMED_WAITING
20:11:39.265 [     main] t2 state: WAITING
20:11:39.796 [       t1] [출금 완료] 출금액: 800, 잔액: 200
20:11:39.797 [       t1] 거래 종료
20:11:39.798 [       t2] [검증 시작] 출금액: 800, 잔액: 200
20:11:39.800 [       t2] [검증 실패] 출금액: 800, 잔액: 200
20:11:39.801 [     main] 최종 잔액: 200

 

 

실행결과 분석

 

 

 

 

  • t1 , t2 가 출금을 시작한다. 여기서 t1이 약간 먼저 실행된다고 가정하겠다.
  • ReenterantLock 내부에는 락과 락을 얻지 못해 대기하는 스레드를 관리하는 대기 큐가 존재한다.
  • 여기서 이야기하는 락은 객체 내부에 있는 모니터 락이 아니다. ReentrantLock 이 제공하는 기능이다.

t1 : ReenterantLock에 있는 락을 획득한다.

락을 획득하는 경우 RUNNABLE 상태가 유지되고, 임계영역의 코드를 실행할 수 있다.

 

 

t1 : 임계 영역의 코드를 실행한다.

 

 

t2 : ReenterantLock 에 있는 락의 획득을 시도한다. 하지만 락이 없다.

 

 

  • t2 : 락을 획득하지 못하면 WAITING 상태가 되고, 대기 큐에서 관리된다.
    • LockSupport.park()가  내부에서 호출된다.
  • 참고로 tryLock(long time, TimeUnit unit)와 같은 시간 대기 기능을 사용하면 TIMED_WAITING 이  되고, 대기 큐에서 관리된다.

 

 

 

t1 : 임계 영역의 수행을 완료했다. 이때 잔액은 balance = 200 이 된다.

 

 

t1 : 임계 영역을 수행하고  나면 lock.unlock()을 호출한다.

  • 1. t1: 락을 반납한다.
  • 2. t1 : 대기 큐에 스레드를 깨운다. LockSupport.unpark(thread)가 내부에서  호출된다.
  • 3. t2 : RUNNABLE 상태가 되면서 깨어난 스레드는 락획득을 시도한다.
    • 이때 락을 획득하면 lock.lock()을 빠져나오면서 대기큐에서도 제거된다.
    • 이때 락을 획득하지 못하면 다시 대기상태가 되면서 대기 큐에 유지된다.
    • 참고로 락 획득을 시도하는 잠깐 사이에 새로운 스레드가 락을 먼저 가져갈 수 있다.
    • 공정모드의 경우 대기 큐에 먼저 대기한 스레드가 먼저 락을 가져간다.

  • t2  : 락을 획득한  t2  스레드는 RUNNABLE  상태로 임계 영역을 수행한다.

 

t2  : 잔액[200] 이 출금액 [800] 보다 적으므로 검증 로직을 통과하지 못한다. 따라서 검증 실패이다. return false 가 호출된다. 

이때 finally 구문이 있으므로  finally 구문으로 이동한다.

 

t2 : lock.unlock()을 호출해서 락을 반납하고, 대기 큐의 스레드를 하나 깨우려고 시도한다. 대기 큐에 스레드가 없으므로 이때는 깨우지 않는다.

완료 상태

 

참고 : volatile를 사용하지 않아도 Lock을 사용할 때 접근하는 변수의 메모리 가시성 문제는 해결된다.

 

ReentrantLock - 대기 중단

ReentrantLock을 사용하면 락을 무한 대기하지 않고, 중간에 빠져나오는 것이 가능하다. 심지어 락을 얻을 수 없다면 기다리지 않고 즉시 빠져나오는 것이 가능하다. 다음 기능들을 어떻게 활용하는지 알아보자.

 

 

boolean tryLock()

  • 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 그렇지 않으면 락을 획득하고  true를 반환한다.
  • 예) 맛집에 대기 줄이 없으면 바로 들어가고, 대기 줄이 있으면 즉시 포기한다.

 

boolean tryLock(long time, TimeUnit unit)

  • 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 락을 획득하지 못한 경우 true를 반환한다.
  • 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다
  • 이 메서드는 대기 중 인터럽트가 발생하면  InterruptedException 이 발생하며 락 획득을 포기한다. 
  • 예) 맛집에 줄을 서지만 특정 시간만큼만 기다린다. 특정 시간이 지나도 계속 줄을 서야 한다면 포기한다. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기한다.

 

tryLock() 예시

package thread.sync;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV5 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

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

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        if (!lock.tryLock()) {
            log("[진입 실패] 이미 처리중인 작업이 있습니다.");
            return false;
        }

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

            // 잔고가 출금액 보다 많으면, 진행
            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000); // 출금에 걸리는 시간으로 가정
            balance = balance - amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock 해제
        }
        log("거래 종료");
        return true;
    }

    @Override
    public int getBalance() {
        lock.lock(); // ReentrantLock 이용하여 lock을 걸기
        try {
            return balance;
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock 해제
        }
    }
}
  • lock.tryLock()을 사용한다. 락을 획득할 수 없으면 바로 포기하고 대기하지 않는다.
    • 락을 획득할 수 없다면 false를 반환한다.
22:25:59.476 [       t1] 거래 시작: BankAccountV5
22:25:59.476 [       t2] 거래 시작: BankAccountV5
22:25:59.479 [       t2] [진입 실패] 이미 처리중인 작업이 있습니다.
22:25:59.484 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
22:25:59.484 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
22:25:59.964 [     main] t1 state: TIMED_WAITING
22:25:59.964 [     main] t2 state: TERMINATED
22:26:00.493 [       t1] [출금 완료] 출금액: 800, 잔액: 200
22:26:00.494 [       t1] 거래 종료
22:26:00.494 [     main] 최종 잔액: 200

 

 

tryLock(시간) 예시

package thread.sync;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class BankAccountV6 implements BankAccount {

    private int balance;

    private final Lock lock = new ReentrantLock();

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

    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

        try {
            if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
                log("[진입 실패] 이미 처리중인 작업이 있습니다.");
                return false;
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

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

            // 잔고가 출금액 보다 많으면, 진행
            log("[검증 완료] 출금액: " + amount + ", 잔액: " + balance);
            sleep(1000); // 출금에 걸리는 시간으로 가정
            balance = balance - amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock 해제
        }
        log("거래 종료");
        return true;
    }

    @Override
    public int getBalance() {
        lock.lock(); // ReentrantLock 이용하여 lock을 걸기
        try {
            return balance;
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock 해제
        }
    }
}
22:33:47.016 [       t1] 거래 시작: BankAccountV6
22:33:47.016 [       t2] 거래 시작: BankAccountV6
22:33:47.023 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
22:33:47.023 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
22:33:47.504 [     main] t1 state: TIMED_WAITING
22:33:47.504 [     main] t2 state: TIMED_WAITING
22:33:47.520 [       t2] [진입 실패] 이미 처리중인 작업이 있습니다.
22:33:48.039 [       t1] [출금 완료] 출금액: 800, 잔액: 200
22:33:48.039 [       t1] 거래 종료
22:33:48.040 [     main] 최종 잔액: 200

 

실행 결과 분석

t1 : 먼저 락을 획득하고 임계 영역을 수행한다.

t2 : lock.tryLock(0.5초)을 호출하고 락획득을 시도한다. 락이 없으므로 0.5 초간 대기한다.

이때 t2 은  TIME_WAITING 상태가 된다.

내부에서 LockSupport.parkNanos(시간) 이 호출된다.

t2 : 대기 시간인 0.5초간 락을 획득하지 못했다. lock.tryLock(시간)에서 즉시 빠져나온다. 이때 false 가 반환된다.

스레드는 TIMED_WAITING ->RUNNABLE 이 된다.

t2:

"[진입 실패] 이미 처리중인 작업이 있습니다."

 

출력하고 false를 반환하면서 메서드를 종료한다.

t1 : 임계 영역의 수행을 완료하고 거래를 종료한다. 마지막으로 락을 반납한다.

 

 

정리

자바 1.5에서 등장한 Lock 인터페이스와 ReentrantLock 덕분에 synchronized ` 의 단점인 무한 대기와 공정성문제를 극복하고, 또 더욱 유연하고 세밀한 스레드 제어가 가능하게 되었다

'JAVA' 카테고리의 다른 글

Runnable과 Callable  (0) 2025.01.12
Java에서의 지연 초기화,지연 평가 Optional 활용: orElse vs orElseGet  (0) 2025.01.06
고급 동기화 - concurrent.Lock  (1) 2025.01.05
생산자 소비자 문제1  (1) 2024.12.31
메모리 가시성  (0) 2024.12.29