legacy/Java

[Java] LockSupport와 ReentrantLock

heemang_e 2024. 8. 5. 02:16

 

1. LockSupport

1-1. LockSupport란?

synchronized의 경우 임계 영역에 접근하지 못하고 대기 중인 스레드는 BLOCKED 상태가 된다. BLOCKED 상태는 다른 스레드에 의해 interrupt 될 수 없기 때문에 lock을 얻지 못하면 영원히 대기 상태로 유지한다.

LockSupport는 무한 대기를 해결하기 위해서 대기 중인 스레드의 상태를 BLOCKED가 아닌 WAITING 이 되게 한다. WAITING 상태는 다른 스레드가 interrupt를 통해 깨우거나, 스스로가 시간을 정하여 깨어날 수 있다.

1-2. LockSupport의 대표적인 기능

  • park() : 현재 스레드를 WAITING 상태로 바꾼다.
  • parkNanos(nanos) : 나노초 동안 현재 스레드를 TIMED_WAITING 상태로 바꾼다. 나노초 이후엔 RUNNABLE 상태로 변경된다.
  • LockSupport.unpark(thread) : 지정한 스레드를 WAITING 상태에서 RUNNABLE 상태로 변경한다. unpark()의 경우 외부 스레드에서 호출해야 한다. WAITING 상태의 스레드에서는 unpark()를 호출할 수 없기 때문이다.

1-3. 예시 코드

  • park() 코드
public class LockSupportMainV1 {

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

        sleep(100);
        log("t1 스레드 상태 : " + t1.getState());

        log("main 스레드에서 park()를 호출하여 t1 스레드가 깨어난다.");
//        LockSupport.unpark(t1); // unpark()를 통해 스레드를 RUNNABLE 상태로 만든다.
        t1.interrupt(); // t1 스레드를 인터럽트를 발생시켜 RUNNABLE 상태로 변경한다.
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            log("park 시작");
            LockSupport.park();

            log("park 종료, 스레드 상태 : " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}
  • park() 출력 결과

synchronized와 달리 임계 영역에 접근하지 못한 스레드는 WAITING 상태가 된다. WAITING 상태의 스레드는 unpark() 또는 interrupt()를 통해 RUNNABLE 상태로 변경할 수 있다.

// LockSupport.unpark(t1) 호출
/*
 * 00:48:56.290 [   thread] park 시작
 * 00:48:56.378 [     main] t1 스레드 상태 : WAITING
 * 00:48:56.378 [     main] main 스레드에서 park()를 호출하여 t1 스레드가 깨어난다.
 * 00:48:56.378 [   thread] park 종료, 스레드 상태 : RUNNABLE
 * 00:50:39.697 [   thread] 인터럽트 상태: false
 */

// t1.interrupt() 호출
// BLOCKED 상태와 달리, WAITING 상태의 스레드에 interrupt를 발생시킬 수 있다.
/*
 * 00:50:39.610 [   thread] park 시작
 * 00:50:39.694 [     main] t1 스레드 상태 : WAITING
 * 00:50:39.694 [   thread] park 종료, 스레드 상태 : RUNNABLE
 * 00:50:39.697 [   thread] 인터럽트 상태: true
 */

 

  • parkNanos(nanos) 코드
public class LockSupportMainV2 {

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

        sleep(100);
        log("t1 스레드 상태 : " + t1.getState());

        log("main 스레드에서 park()를 호출하여 t1 스레드가 깨어난다.");
    }

    static class MyTask implements Runnable {

        @Override
        public void run() {
            log("park 시작");
            LockSupport.parkNanos(2000_000000); // 2초(20억 나노초) 후에 TIMED_WAITING -> RUNNABLE 상태가 된다.

            log("park 종료, 스레드 상태 : " + Thread.currentThread().getState());
        }
    }
}
  • parkNanos(nanos) 결과

park() 시에 대기 시간을 설정하여, 대기 시간 동안 임계 영역에 접근하지 못한 스레드를 RUNNABLE 상태로 변경한다.

// LockSupport의 parkNanos(nanos)는 지정한 시간동안 대기 후, lock을 얻지 못하면 대기를 종료한다.
/*
 * 00:53:21.164 [   thread] park 시작
 * 00:53:21.249 [     main] t1 스레드 상태 : TIMED_WAITING
 * 00:53:21.250 [     main] main 스레드에서 park()를 호출하여 t1 스레드가 깨어난다.
 * 00:53:23.172 [   thread] park 종료, 스레드 상태 : RUNNABLE
 */

 

 

2. ReentrantLock

2-1. ReentrantLock이란?

Lock 인터페이스 구현체 중 하나로, 멀티 스레딩 환경에서 임계 구역을 위한 lock을 구현하는 데 사용된다. ReentrantLock 객체에서 사용하는 락은 synchronized에서 사용되는 객체 내부의 모니터락이 아닌, ReentrantLock이 제공하는 lock이다.

 

2-2. Lock 인터페이스

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();
}
  • void lock()
    • 임계 영역에 접근하기 위한 lock(모니터락x, 모니터락은 synchronized에서만 사용)을 획득하는 메서드이다.
    • 다른 스레드가 lock을 획득한 상태라면, 현재 스레드는 lock을 얻을 때까지 WAITING 상태가 된다. 주의할 점은 WAITING 상태임에도 다른 스레드에 의해 interrupt가 될 수 없다.
      • 원래 WAITING 상태의 스레드에 interrupt를 발생시킬 수 있으나, 예외적으로 lock()의 경우 WAITING → RUNNABLE이 되었다가 강제로 RUNNABLE → WAITING 상태가 된다.
  • lockInterruptibly()
    • lock()과 동일하게 lock을 얻기 위해 사용하되, 다른 스레드에 의해 interrupt 될 수 있다. 인터럽트가 발생하면 InterruptedException 예외가 발생한다.
  • tryLock()
    • lock 획득을 시도하고, lock 획득 여부(boolean)를 반환한다. 다른 스레드가 lock을 가지고 있다면 false를 반환하고, 현재 스레드가 lock을 획득한 경우 true를 반환한다.
  • tryLock(time, unit)
    • lock을 획득하기 위해 사용하되, 현재 스레드가 lock을 갖지 못한 경우 time 동안 TIMED_WAITING 상태로 대기하고, time동안 lock을 얻지 못하면 false를 반환한다. 또한 time동안 대기하는 동안 다른 스레드에 의해 interrupt 될 수 있다. 이때, InterruptedException 예외가 발생한다.
  • unlock()
    • 현재 스레드가 가지고 있는 lock을 반환한다. 임계 영역에 접근하기 위해 대기 중인 스레드가 있다면 그중에 한 스레드가 lock을 획득할 수 있다. lock()을 통해 lock을 얻은 경우, 사용 완료 후 반드시 unlock()을 호출해야 한다. 호출하지 않으면 대기 중인 스레드가 lock을 얻지 못하고 대기 상태를 유지하게 된다.

 

2-3. 공정성와 비공정성

lock을 얻지 못하여 대기 중인 스레드가 여러 개 존재할 수 있는데, 어떤 스레드가 lock을 반환하였을 때 대기 중인 스레드 중에 어떤 스레드가 lock을 획득할지 보장할 수 없다. 여기서 공정성이란 lock을 얻을 기회가 생겼을 때, 대기 중인 스레드들이 공정하게 lock을 얻을 수 있도록 보장한다.

 

ReentrantLock은 공정성 모드비공정성 모드를 지원한다.

  • 공정성 모드
    • lock을 얻을 기회가 생겼을 때, 여러 스레드 중에 요청한 순서대로 lock을 얻을 수 있도록 한다.
    • new ReentrantLock(true); → ReentrantLock 객체 생성 시에 인자로 true를 전달한다.
    • 공정성 보장 : 대기 큐에서 먼저 대기한 스레드부터 lock을 얻을 수 있다.
    • 기아 현상 방지 : 모든 스레드가 반드시 lock을 얻을 수 있다. 우선순위 존재 X
    • 성능 저하 : lock 획득 속도가 느려진다.
  • 비공정성 모드
    • lock을 얻을 기회가 생겼을 때, 어떠한 순서대로 lock을 얻음을 보장하지 않는다. 즉, 대기 중인 스레드 중에 아무나 lock을 얻게 된다.
    • 성능 우선 : lock 획득 속도가 빠르다.
    • 선점 가능 : 뒤늦게 대기한 스레드가 먼저 lock을 얻을 수 있다.
    • 기아 현상 발생 : lock 획득 순서가 없으므로, 특정 스레드가 lock을 획득하지 못할 수 있다.

 

2-4. 예시 코드

  • main 코드
public class BankMain {

    public static void main(String[] args) throws InterruptedException {
//        BankAccount bankAccount = new BankAccountV1(1000); // 계좌 초기 잔액은 1000원이다.
//        BankAccount bankAccount = new BankAccountV2(1000); // 계좌 초기 잔액은 1000원이다.
//        BankAccount bankAccount = new BankAccountV3(1000); // 계좌 초기 잔액은 1000원이다.
//        BankAccount bankAccount = new BankAccountV4(1000); // ReentrantLock을 사용하여 lock 처리
//        BankAccount bankAccount = new BankAccountV5(1000); // ReentrantLock을 사용하여 lock 처리
        BankAccount bankAccount = new BankAccountV6(1000); // ReentrantLock을 사용하여 lock 처리

        Thread t1 = new Thread(new WithdrawTask(bankAccount, 800), "t1");
        Thread t2 = new Thread(new WithdrawTask(bankAccount, 800), "t2");
        t1.start();
        t2.start();

        sleep(500);
        log("t1 state: " + t1.getState());
        log("t2 state: " + t2.getState());

        t1.join();
        t2.join();
        log("최종 잔액: " + bankAccount.getBalance());
    }
}

 

  • lock()unlock()을 사용하여 임계 영역 다루기
    • lock()의 경우 내부에서 LockSupport.park()가 호출된다.
    • unlock()의 경우 내부에서 LockSupport.unpark()가 호출된다.

lock()을 사용한 경우 반드시 작업을 종료하고 unlock()을 호출해야 한다. lock을 반환하지 않으면 대기 중인 스레드가 lock을 얻을 수 없어 무한정 대기하게 된다.

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

public class BankAccountV4 implements BankAccount {

    private int balance; // 계좌 잔액, (공유자원)

    private final Lock lock = new ReentrantLock(); // 비공정 모드(Non-fair) : 대기 중인 스레드 중에 아무나 락을 획득할 수 있다.

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

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

        lock.lock(); // ReentrantLock 이용하여 lock을 건다.

        // 임계 영역이 끝나면 반드시 lock을 해제해야 한다.
        // lock을 해지하지 않으면 대기 중인 스레드가 lock을 획득하지 못하고 계속 대기하게 된다.
        try {
            log("[검증 시작] 출금액 : " + amount + ", 현재 잔액 : " + balance);
            if (balance < amount) { // 계좌 잔액 < 출금액
                log("[검증 실패] 출금액 : " + amount + ", 현재 잔액 : " + balance);
                return false;
            }

            log("[검증 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
            sleep(1000); // 계좌에서 출금하는데 1초가 걸린다고 가정
            balance -= amount;
            log("[출금 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
        } finally { // 반드시 unlock을 해야 한다.
            lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
        }

        log("거래 종료");
        return true;
    }

    @Override
    public  int getBalance() {
        lock.lock(); // ReentrantLock 이용하여 lock을 건다.
        try {
            return balance;
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
        }
    }
}
  • lock()unlock() 출력 결과

lock을 획득하지 못한 t2 스레드는 WAITING 상태가 되며, t1 스레드가 작업을 종료하고 lock을 반납하면 t2 스레드가 획득하게 된다. 이때, t2 스레드는 WAITING 상태에서 RUNNABLE 상태가 된다.

/*
 * 22:41:34.857 [       t1] 거래 시작: BankAccountV4
 * 22:41:34.857 [       t2] 거래 시작: BankAccountV4
 * 22:41:34.864 [       t1] [검증 시작] 출금액 : 800, 현재 잔액 : 1000
 * 22:41:34.864 [       t1] [검증 완료] 출금액 : 800, 현재 잔액 : 1000
 * 22:41:35.339 [     main] t1 state: TIMED_WAITING
 * 22:41:35.339 [     main] t2 state: WAITING
 * 22:41:35.869 [       t1] [출금 완료] 출금액 : 800, 현재 잔액 : 200
 * 22:41:35.869 [       t1] 거래 종료
 * 22:41:35.869 [       t2] [검증 시작] 출금액 : 800, 현재 잔액 : 200
 * 22:41:35.869 [       t2] [검증 실패] 출금액 : 800, 현재 잔액 : 200
 * 22:41:35.871 [     main] 최종 잔액: 200
 */

 

  • tryLock()을 사용하여 임계 영역 다루기
    • lock 획득을 시도하되, 획득한 경우에 true를 반환하고, 그렇지 않은 경우에 false를 반환한다.
    • lock을 획득하지 못한다면 false를 반환하고, 대기큐에 저장되지 않고 바로 종료된다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class BankAccountV5 implements BankAccount {

    private int balance; // 계좌 잔액, (공유자원)

    private final Lock lock = new ReentrantLock(); // 비공정 모드(Non-fair) : 대기 중인 스레드 중에 아무나 락을 획득할 수 있다.

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

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

        // lock을 획득할 수 없는 경우 tryLock()은 false를 반환한다.
        // 반대로 lock을 획득할 수 있는 경우, lock을 획득하고 true를 반환한다.
        if(!lock.tryLock()) {
            log("[진입 실패] 이미 처리 중인 작업이 존재합니다.");
            return false;
        }

        // 임계 영역이 끝나면 반드시 lock을 해제해야 한다.
        // lock을 해지하지 않으면 대기 중인 스레드가 lock을 획득하지 못하고 계속 대기하게 된다.
        try {
            log("[검증 시작] 출금액 : " + amount + ", 현재 잔액 : " + balance);
            if (balance < amount) { // 계좌 잔액 < 출금액
                log("[검증 실패] 출금액 : " + amount + ", 현재 잔액 : " + balance);
                return false;
            }

            log("[검증 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
            sleep(1000); // 계좌에서 출금하는데 1초가 걸린다고 가정
            balance -= amount;
            log("[출금 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
        } finally { // 반드시 unlock을 해야 한다.
            lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
        }

        log("거래 종료");
        return true;
    }

    @Override
    public  int getBalance() {
        lock.lock(); // ReentrantLock 이용하여 lock을 건다.
        try {
            return balance;
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
        }
    }
}
  • tryLock() 출력 결과

lock을 획득하지 못한 스레드는 바로 종료되기 때문에 TERMINATED 상태가 된다.

/*
 * 22:50:37.540 [       t1] 거래 시작: BankAccountV5
 * 22:50:37.540 [       t2] 거래 시작: BankAccountV5
 * 22:50:37.543 [       t2] [진입 실패] 이미 처리 중인 작업이 존재합니다.
 * 22:50:37.547 [       t1] [검증 시작] 출금액 : 800, 현재 잔액 : 1000
 * 22:50:37.548 [       t1] [검증 완료] 출금액 : 800, 현재 잔액 : 1000
 * 22:50:38.024 [     main] t1 state: TIMED_WAITING
 * 22:50:38.025 [     main] t2 state: TERMINATED
 * 22:50:38.554 [       t1] [출금 완료] 출금액 : 800, 현재 잔액 : 200
 * 22:50:38.556 [       t1] 거래 종료
 * 22:50:38.563 [     main] 최종 잔액: 200
 */

 

  • tryLock(time, unit)을 통해 임계 구역 다루기
    • 메서드 호출 당시에 lock을 획득할 수 있으면 바로 true를 반환하고, 획득하지 못했다면 time 동안 lock을 획득하기 위해 대기한다. 대기하는 스레드의 상태는 TIMED_WAITING 상태가 된다.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class BankAccountV6 implements BankAccount {

    private int balance; // 계좌 잔액, (공유자원)

    private final Lock lock = new ReentrantLock(); // 비공정 모드(Non-fair) : 대기 중인 스레드 중에 아무나 락을 획득할 수 있다.

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

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

        try {
            if (!lock.tryLock(500, TimeUnit.MILLISECONDS)) {
                log("[진입 실패] 0.5초 간 기다렸으나 lock을 획득하지 못하여 작업을 중단합니다.");
                return false;
            }
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        // 임계 영역이 끝나면 반드시 lock을 해제해야 한다.
        // lock을 해지하지 않으면 대기 중인 스레드가 lock을 획득하지 못하고 계속 대기하게 된다.
        try {
            log("[검증 시작] 출금액 : " + amount + ", 현재 잔액 : " + balance);
            if (balance < amount) { // 계좌 잔액 < 출금액
                log("[검증 실패] 출금액 : " + amount + ", 현재 잔액 : " + balance);
                return false;
            }

            log("[검증 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
            sleep(1000); // 계좌에서 출금하는데 1초가 걸린다고 가정
            balance -= amount;
            log("[출금 완료] 출금액 : " + amount + ", 현재 잔액 : " + balance);
        } finally { // 반드시 unlock을 해야 한다.
            lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
        }

        log("거래 종료");
        return true;
    }

    @Override
    public  int getBalance() {
        lock.lock(); // ReentrantLock 이용하여 lock을 건다.
        try {
            return balance;
        } finally {
            lock.unlock(); // ReentrantLock 이용하여 lock을 해제한다.
        }
    }
}
  • tryLock(time, unit) 출력 결과

tryLock()과 달리 lock을 얻지 못한 t2 스레드가 바로 종료되는 것이 아닌, time 동안 TIMED_WAITING 상태가 된다. time 동안 lock을 얻지 못하면 TERMINATED 상태가 된다.

/*
 * 22:54:38.298 [       t1] 거래 시작: BankAccountV6
 * 22:54:38.298 [       t2] 거래 시작: BankAccountV6
 * 22:54:38.303 [       t1] [검증 시작] 출금액 : 800, 현재 잔액 : 1000
 * 22:54:38.303 [       t1] [검증 완료] 출금액 : 800, 현재 잔액 : 1000
 * 22:54:38.783 [     main] t1 state: TIMED_WAITING
 * 22:54:38.784 [     main] t2 state: TIMED_WAITING
 * 22:54:38.805 [       t2] [진입 실패] 0.5초 간 기다렸으나 lock을 획득하지 못하여 작업을 중단합니다.
 * 22:54:39.304 [       t1] [출금 완료] 출금액 : 800, 현재 잔액 : 200
 * 22:54:39.304 [       t1] 거래 종료
 * 22:54:39.307 [     main] 최종 잔액: 200
 */

 

 

3. synchronized vs ReentrantLock

3-1. synchronized

  • 모니터락을 획득하지 못한 스레드는 BLOCKED 상태가 된다. BLOCKED 상태인 스레드에 interrupt를 발생시킬 수 없기 때문에, lock을 획득할 때까지 무한정 대기하게 된다.
  • 일정 시간동안 모니터락을 획득하지 못한 경우에 종료하도록 하는 기능을 제공하지 않는다.

3-2. ReentrantLock

  • synchronized보다 lock을 다루는 기능을 더 제공한다.
  • ReentrantLock이 제공하는 lock을 획득하지 못한 스레드는 WAITING 상태가 되어 대기큐에 저장된다.
    • WAITING 상태인 스레드에 interrupt를 발생시키거나, unlock()을 통해 RUNNABLE 상태로 변경할 수 있다.
  • 일정 시간동안 lock을 획득하지 못한 경우 RUNNABLE 상태가 되도록 하는 tryLock(time, unit) 메서드를 제공한다.