ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] LockSupport와 ReentrantLock
    legacy/Java 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) 메서드를 제공한다.
Designed by Tistory.