관리 메뉴

나구리의 개발공부기록

고급 동기화 - concurrent.Lock, LockSupport, ReentrantLock(이론, 활용, 대기 중단) 본문

인프런 - 실전 자바 로드맵/실전 자바 - 고급 1편, 멀티스레드와 동시성

고급 동기화 - concurrent.Lock, LockSupport, ReentrantLock(이론, 활용, 대기 중단)

소소한나구리 2025. 2. 11. 21:20
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급1편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. LockSupport

1) LockSupport

(1) LockSupport 기능

  • 자바 1.0 부터 제공되는 synchronized는 매우 편리한 기능이지만 무한 대기, 공정성과 같은 단점이 있어 자바 1.5부터 동시성 문제 해결을 위한 라이브러리 패키지가 추가되었음
  • 이 라이브러리에는 수많은 클래스가 있지만 가장 기본이 되는 클래스 중에는 LockSupport가 있음
  • LockSupport는 스레드를 WAITING 상태로 변경할 수 있음
  • WAITING 상태는 누가 깨워주기 전까지는 계속 대기하며 CPU 실행 스케줄링에 들어가지 않음
  • park(): 스레드를 WAITING 상태로 변경하여 스레드를 대기 상태로 둠
  • parkNanos(nanos): 스레드를 나노초 동안만 TIMED_WAITING 상태로 변경하고 지정한 나노초가 지나면 RUNNABLE 상태로 변경됨
  • unpark(thread): WAITING 상태의 대상 스레드를 RUNNABLE 상태로 변경함

(2) LockSupportMainV1

  • ParkTest의 run() 메서드에서 LockSupport.park()로 스레드를 대기상태로 변경하고 main 스레드에서 상태를 출력해 보면 WAITING으로 출력이 됨
  • main 메서드에서 LockSupport.unpark(thread1)로 해당 스레드의 대기상태를 깨운 후 상태를 다시 출력해보면 RUNNABLE로 출력되는 것을 확인할 수 있음
  • 여기에서는 인터럽트를 사용하지 않았으므로 false임
package thread.sync.lock;

public class LockSupportMainV1 {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new ParkTest(), "Thread-1");
        thread1.start();

        // 잠시 대기하여 Thread-1이 park 상태에 빠질 시간을 줌
        sleep(100);
        log("Thread-1 state: " + thread1.getState());
        log("main -> unpark(Thread-1)");
        LockSupport.unpark(thread1);
    }

    static class ParkTest implements Runnable {

        @Override
        public void run() {
            log("park 시작");
            LockSupport.park();
            log("park 종료, state: " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}
/* 실행 결과
18:21:22.434 [ Thread-1] park 시작
18:21:22.525 [     main] Thread-1 state: WAITING
18:21:22.525 [     main] main -> unpark(Thread-1)
18:21:22.526 [ Thread-1] park 종료, state: RUNNABLE
18:21:22.528 [ Thread-1] 인터럽트 상태: false
*/

 

(2) 동작 설명

  • main 스레드가 Thread-1을 start()하면 Thread-1은 RUNNABLE 상태가 되고 LockSupport.park()를 호출하여 WAITING 상태가 되면서 대기함
  • main 스레드가 Thread-1을 unpark()로 깨우면 Thread-1은 WAITING -> RUNNABLE 상태로 변함
  • 이처럼 LockSupport는 특정 스레드를 WAITING, RUNNABLE 상태로 변경할 수 있음
  • 대기 상태로 바꾸는 LockSupport.park()는 매개변수가 없는데 실행 가능 상태로 바꾸는 LockSupport.unpark(thread1)는 특정 스레드를 인자로 전달해주어야 함
  • 실행 중인 스레드는 LockSupport.park()를 호출해서 스스로 대기 상태에 빠질 수 있지만 대기 상태의 스레드는 자신의 코드를 실행할 수 없기 때문에 외부 스레드의 도움을 받아야 깨어날 수 있음

(3) 인터럽트 사용

public class LockSupportMainV1 {
    public static void main(String[] args) {
    
    // ... 기존 코드 동일
    
//        LockSupport.unpark(thread1);
        thread1.interrupt();
    }
}       
/* 실행 결과
18:26:53.347 [ Thread-1] park 시작
18:26:53.442 [     main] Thread-1 state: WAITING
18:26:53.442 [     main] main -> unpark(Thread-1)
18:26:53.442 [ Thread-1] park 종료, state: RUNNABLE
18:26:53.445 [ Thread-1] 인터럽트 상태: true
*/
  • WAITING 상태의 스레드에 인터럽트가 발생하면 RUNNABLE 상태로 변하면서 깨어나게 됨
  • thread1.interrupt()를 호출하도록 변경 후 실행해 보면 스레드가 WAITING -> RUNNABLE 상태로 깨어나고 인터럽트 상태가 true가 된 것을 확인할 수 있음
  • 이처럼 WAITING 상태의 스레드는 인터럽트로도 중간에 깨울 수 있음

2) 시간 대기

(1) LockSupportMainV2

  • 스레드를 나노초 동안만 대기 시키는 메서드인 parkNanos(nanos)가 존재하지만, 밀리초 동안만 대기하는 메서드는 없음
  • parkUntil(밀리초)라는 메서드가 있는데 이 메서드는 특정 에포크(Epoch) 시간에 맞추어 깨어나는 메서드이므로 정확한 미래의 에포크 시점을 지정해야 함
  • 스레드를 깨우기 위한 unpark()를 사용하지 않고 parkNanos(2000_000000)을 사용하여 2초 후에 스레드가 깨어나도록 설정하고 코드를 실행해 보면 2초가 지난 후 스레드가 TIMED_WAITING -> RUNNABLE로 변경되는 것을 확인할 수 있음
package thread.sync.lock;

public class LockSupportMainV2 {
    public static void main(String[] args) {
        Thread thread1 = new Thread(new ParkTest(), "Thread-1");
        thread1.start();

        // 잠시 대기하여 Thread-1이 park 상태에 빠질 시간을 줌
        sleep(100);
        log("Thread-1 state: " + thread1.getState());
    }

    static class ParkTest implements Runnable {

        @Override
        public void run() {
            log("park 시작, 2초 대기");
            LockSupport.parkNanos(2000_000000);
            log("park 종료, state: " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}
/* 실행 결과
18:42:25.379 [ Thread-1] park 시작, 2초 대기
18:42:25.472 [     main] Thread-1 state: TIMED_WAITING
18:42:27.386 [ Thread-1] park 종료, state: RUNNABLE
18:42:27.388 [ Thread-1] 인터럽트 상태: false
*/

3) BLOCKED vs WAITING

(1) 인터럽트

  • BLOCKED 상태는 인터럽트가 걸려도 대기 상태를 빠져나오지 못함
  • WAITING, TIMED_WAITING 상태는 인터럽트가 걸리면 대기 상태를 빠져나오고 RUNNABLE 상태로 변함

(2) 용도

  • BLOCKED 상태는 자바의 synchronized에서 락을 획득하기 위해 대기할 때 사용됨
  • WAITING, TIMED_WAITING 상태는 스레드가 특정 조건이나 시간 동안 대기할 때 발생하는 상태임
  • Thread.join(), LockSupport.park(), Object.wait()와 같음 메서드 호출 시 WAITING상태가 됨
  • Thread.sleep(ms), Object.wait(long timeout), Thread.join(long millis), LockSupport.parkNanos(nanos) 등과 같은 시간제한이 있는 대기 메서드를 호출할 때 TIMED_WAITING 상태가 됨

(3) WAITING 상태와 TIMED_WAITING상태는 서로 짝이 있음

  • sleep() 메서드를 제외하면 join(), park(), wait() 메서드는 각각의 짝이 있음
  • Thread.join(), Thread.join(long millis)
  • Object.wait(), Object.wait(long timeout)
  • LockSupport.park(), LockSupport.parkNanos(long millis)

(4) 정리

  • BLOCKED, WAITING, TIMED_WAITING 상태 모두 스레드가 대기하며 실행 스케줄링에 들어가지 않기 때문에 CPU 입장에서 보면 실행하지 않는 비슷한 상태임
  • BLOCKED 상태는 synchronized에서만 사용하는 특별한 대기 상태라고 이해하면 되고 WAITING, TIMED_WAITING 상태는 범용적으로 활용할 수 있는 대기 상태라고 이해하면 됨

4) LockSupport 정리

(1) LockSupport 정리

if (!lock.tryLock(10초)) { // 내부에서 parkNanos() 사용
    log("[진입 실패] 너무 오래 대기했습니다.");
    return false;
}
//임계 영역 시작
...
//임계 영역 종료
lock.unlock() // 내부에서 unpark() 사용
  • LockSupport를 사용하면 WAITING, TIMED_WAITING 상태로 변경할 수 있고 인터럽트를 받아서 스레드를 깨울 수 있으므로 이런 기능들을 사용하면 synchronized의 단점인 무한 대기 문제를 해결할 수 있음
  • 위의 예시처럼 LockSupport를 활용하면 무한 대기하지 않는 락 기능을 만들 수 있음, 물론 그냥 되는 것은 아니고 LockSupport를 활용해서 안전한 임계 영역을 만드는 어떤 기능을 개발해야 함
  • 락(lock)이라는 클래스를 만들고 특정 스레드가 먼저 락을 얻으면 RUNNABLE로 실행하고 락을 얻지 못하면 park()를 사용해서 대기상태로 변경하게 하고 스레드가 임계 영역의 실행을 마치고 나면 락을 반납하고 unpark()를 사용해서 대기 중인 다른 스레드를 깨우고 parkNanos()를 사용해서 너무 오래 대기하면 스레드가 스스로 중간에 깨어나게 할 수도 있음
  • 그러나, 이런 기능을 직접 구현하기는 매우 어려움
  • 예를 들어 스레드 10개를 동시에 실행했는데 그중에 딱 1개의 스레드만 락을 가질 수 있도록 락 기능을 만들어야 하면 나머지 9개의 스레드가 대기해야 하는데 어떤 스레드가 대기하고 있는지 알 수 있는 자료 구조가 필요함
  • 그래야 이후에 대기 중인 스레드를 찾아서 깨울 수 있으며 대기 중인 스레드 중에 어떤 스레드를 깨울지에 대한 우선순위 결정도 필요함
  • 즉, LockSupport는 너무 저수준의 클래스이므로 직접 사용하기에는 힘들며 synchronized처럼 더 고수준의 기능이 필요함
  • 자바는 Lock 인터페이스와 ReentrantLock이라는 구현체로 이런 기능들을 이미 다 구현해 두었음

2. ReentrantLock

1) 이론

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();
}

 

(1) Lock 인터페이스

  • Lock 인터페이스는 동시성 프로그래밍에서 쓰이는 안전한 임계 영역을 위한 락을 구현하는 데 사용하며 다음과 같은 메서드를 제공하고 대표적인 구현체로 ReentrantLock이 있음
  • void lock()
    • 락을 획득하며 만약 다른 스레드가 이미 락을 획득했다면 락이 풀릴 때까지 현재 스레드는 대기(WAITING)함
    • 인터럽트에 응답하지 않음
  • 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 인터페이스와 ReentrantLock이 제공하는 기능임
  • 모니터 락과 BLOCKED 상태는 synchronized에서만 사용됨

** 참고

  • lock() 메서드는 인터럽트에 응하지 않는다고 되어있는데, 이 메서드의 의도는 인터럽트가 발생해도 무시하고 락을 기다리는 것임
  • lock()을 호출해서 락을 얻기 위해 대기 중인 스레드에 인터럽트가 발생하면 순간 대기 상태를 빠져나오는 것은 맞음
  • 그래서 아주 짧지만 WAITING -> RUNNABLE 상태가 됨
  • 그러나 lock() 메서드 안에서 해당 스레드를 다시 WAITING 상태로 강제 변경을 해버리기 때문에 인터럽트를 무시함
  • 인터럽트가 필요하면 lockInterruptibly()를 사용하면 되므로 새로운 Lock은 개발자에게 다양한 선택권을 제공함

(2) 공정성

package thread.sync.lock;

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();
        }
    }
}
  • Lock 인터페이스의 대표적인 구현체인 ReentrantLock 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공함
  • 비공정 모드(Non-fair mode)
    • ReentrantLock의 기본 모드
    • 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없음
    • 락을 풀었을 때, 대기 중인 스레드 중 아무나 락을 획득할 수 있으므로 락을 빨리 획득할 수 있지만 특정 스레드가 장기간 락을 획득하지 못할 가능성도 있음
    • 성능 우선: 락을 획득하는 속도가 빠름
    • 선점 가능: 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있음
    • 기아 현상 가능성: 특정 스레드가 계속해서 락을 획득하지 못할 수 있음
  • 공정 모드(Fair mode)
    • 생성자에서 true를 전달하면 공정 모드로 동작함
    • 공정 모드는 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 함
    • 먼저 대기한 스레드가 먼저 락을 획득하게 되어 스레드 간의 공정성을 보장할 수 있지만 이로 인해 성능이 저하될 수 있음
    • 공정성 보장: 대기 큐에서 먼저 대기한 스레드가 락을 먼저 획득함
    • 기아 현상 방지: 모든 스레드가 언젠가 락을 획득할 수 있게 보장함
    • 성능 저하: 락을 획득하는 속도가 느려질 수 있음

(3) 비공정 모드, 공정 모드

  • 비공정 모드: 성능을 중시하고 스레드가 락을 빨리 획득할 수 있지만 특정 스레드가 계속해서 락을 획득하지 못할 수 있음
  • 공정 모드: 스레드가 락을 획득하는 순서를 보장하여 공정성을 중시하지만 성능이 저하될 수 있음
  • 비공정 모드도 내부는 Queue처럼 구현되어 있기 때문에 기본적으로는 오래 대기한 스레드가 먼저 실행되지만 이를 보장하지 않고 새로운 스레드가 끼어들면 해당 스레드가 먼저 실행될 수 있는 것임

2) 활용

(1) BankAccountV4

  • synchronized(this) 대신에 락을 사용하도록 private final Lock lock = new ReentrantLock()을 생성
  • lock.lock()을 사용해서 락을 걸면 lock.unlock()을 호출하기 전까지 안전한 임계 영역이 됨
  • 임계 영역이 끝나고 락이 반납되지 않으면 대기하는 스레드가 락을 얻지 못하기 때문에 반드시 락을 반납해야 함
  • 따라서 lock.unlock()은 반드시 finally 블럭에 작성해야 검증에 실패해서 중간에 return이 되거나 예상치 못한 예외가 발생하여도 lock.unlock()이 호출되어 락을 반납할 수 있음
package thread.sync;

public class BankAccountV4 implements BankAccount {

    private int balance;
    private final Lock lock = new ReentrantLock();

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

    @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 -= amount;
            log("[출금 완료] 출금액: " + amount + ", 잔액: " + balance);
            
        } finally {
            lock.unlock();  // 반드시 해제 해주어야 함
        }
        
        log("거래 종료");
        return true;
    }

    @Override
    public int getBalance() {
        lock.lock();
        
        try {
            return balance;
        } finally {
            lock.unlock();
        }
    }
}

 

(2) BankMain

  • BankAccountV4로 변경하여 실행해 보면 정상적으로 스레드들이 순서대로 동작하는 것을 확인할 수 있음
/* 실행 결과
20:07:00.804 [       t1] 거래 시작: BankAccountV4
20:07:00.804 [       t2] 거래 시작: BankAccountV4
20:07:00.810 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
20:07:00.810 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
20:07:01.294 [     main] t1 state: TIMED_WAITING
20:07:01.295 [     main] t2 state: WAITING
20:07:01.816 [       t1] [출금 완료] 출금액: 800, 잔액: 200
20:07:01.817 [       t1] 거래 종료
20:07:01.820 [       t2] [검증 시작] 출금액: 800, 잔액: 200
20:07:01.821 [       t2] [검증 실패] 출금액: 800, 잔액: 200
20:07:01.822 [     main] 최종 잔액: 200
*/

 

(3) 실행 결과 분석

  • t1과 t2가 출금을 시작했을 때 t1이 약간 먼저 실행된다고 가정하고 분석
  • ReentrantLock 내부에는 락과 락을 얻지 못해 대기하는 스레드를 관리하는 대기 큐가 존재함(여기서 이야기하는 락은 객체 내부에 있는 모니터 락이 아니며 ReentrantLock이 제공하는 기능임)
  • t1 스레드가 ReentrantLock에 있는 락을 획득하고 RUNNABLE 상태가 되어 임계 영역의 코드를 실행함
  • t2 스레드가 ReentrantLock에 있는 락의 획득을 시도하지만 락이 없으므로 WAITING 상태가 되고 LockSupport.park()가 내부에서 호출되어 대기 큐에서 관리됨
  • 참고로 tryLock(long time, TimeUnit unit)처럼 시간 대기 기능을 사용하면 TIMED_WAITING이 되고 대기 큐에서 관리됨
  • t1 스레드가 임계 영역의 수행을 완료하여 출금이 완료되고 결과를 balance에 반영하여 잔액이 200원이 됨
  • 이때 임계 영역을 수행하고 나면 lock.unlock()을 호출하여 t1은 락을 반납하고 내부에서 LockSupport.unpark(thread)가 내부에서 호출되어 대기 큐의 스레드를 하나 깨움
  • t2 스레드가 RUNNABLE 상태가 되면서 깨어난 스레드는 락 획득을 시도함
  • 이때 락을 획득하면 lock.lock()을 빠져나오면서 대기 큐에서 제거되고 이때 락을 획득하지 못하면 다시 대기 상태가 되면서 대기 큐에 유지됨
  • 비공정 모드로 동작하면 락 획득을 시도하는 잠깐 사이에 새로운 스레드가 락을 먼저 가져갈 수 있으며 공정 모드의 경우에는 대기 큐에 먼저 대기한 스레드가 먼저 락을 가져감
  • t2가 임계 영역의 로직을 수행하게 되고 잔액이 출금액보다 적으므로 검증 로직을 통과하지 못하여 검증에 실패하고 return false가 호출됨
  • 이때 finally 구문이 있으므로 finally 구문으로 이동하여 lock.unlock()을 호출하여 락을 반납하고 대기 큐의 스레드를  하나 깨우려고 시도하지만 대기 큐에 스레드가 없으므로 이때는 깨우지 않고 최종 종료됨

3) 대기 중단

(1) BankAccountV5 - 즉시 중단

  • boolean tryLock()을 사용하여 락을 획득을 시도하고 획득할 수 없으면 바로 포기하고 대기하지 않으며 false를 반환함
package thread.sync;

public class BankAccountV5 implements BankAccount {

    // ... 기존 코드 동일 생략
    
    @Override
    public boolean withdraw(int amount) {
        log("거래 시작: " + getClass().getSimpleName());

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

    // ... 기존 코드 동일 생략
}

 

(2) BankMain

  • BankAccountV5로 변경 후 실행해 보면 t1과 t2가 동시에 실행되지만 t1이 먼저 락을 획득하여 임계 영역을 수행하기 때문에 t2는 락이 없다는 것을 확인하고 lock.tryLock()에서 즉시 빠져나오면서 false를 반환함
  • t2는 if 문의 조건을 통해 진입 실패했다는 출력문을 호출하고 return으로 빠져나오게 되고 t1은 임계 영역의 수행을 완료하고 정상적으로 거래를 종료함
/* 실행 결과
21:00:57.432 [       t1] 거래 시작: BankAccountV5
21:00:57.432 [       t2] 거래 시작: BankAccountV5
21:00:57.434 [       t2] [진입 실패] 이미 처리중인 작업이 있습니다
21:00:57.437 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
21:00:57.437 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
21:00:57.921 [     main] t1 state: TIMED_WAITING
21:00:57.921 [     main] t2 state: TERMINATED
21:00:58.444 [       t1] [출금 완료] 출금액: 800, 잔액: 200
21:00:58.445 [       t1] 거래 종료
21:00:58.446 [     main] 최종 잔액: 200
*/

 

(3) BankAccountV6 - 시간 대기

  • lock.tryLock(500, TimeUnit.MILLISECONDS): 락이 없을 때 락을 대기할 시간을 지정하고 해당 시간이 지나도 락을 얻지 못하면 false를 반환하면서 해당 메서드를 빠져나옴
  • 스레드의 상태는 대기하는 동안 TIMED_WAITING이 되고 대기 상태를 빠져나오면 RUNNABLE이 됨
  • 두 번째 인자로 TimeUnit 타입을 지정해주어야 함
package thread.sync;

public class BankAccountV6 implements BankAccount {

    // ... 기존 코드 동일 생략
    @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);
        }

    // ... 기존 코드 동일 생략

}

 

(4) BankMain

  • BankAccountV6로 변경 후 실행해 보면 t1이 먼저 락을 획득하고 임계 영역을 수행하고 t2는 락이 없으므로 내부에서 LockSupport.parkNanos(시간)이 호출되어 0.5초간 대기하며 TIMED_WAITING상태가 됨
  • 대기시간인 0.5초간 락을 획득하지 못했으므로 즉시 빠져나오며 이때 false가 반환되고 스레드는 TIMED_WAITING -> RUNNABLE 상태가 되어 진입 실패했다는 문구를 출력하고 false를 반환하면서 메서드를 종료함
  • t1은 임계 영역의 수행을 완료하고 거래를 정상 종료됨
/* 실행 결과 
21:07:29.470 [       t1] 거래 시작: BankAccountV6
21:07:29.470 [       t2] 거래 시작: BankAccountV6
21:07:29.475 [       t1] [검증 시작] 출금액: 800, 잔액: 1000
21:07:29.475 [       t1] [검증 완료] 출금액: 800, 잔액: 1000
21:07:29.964 [     main] t1 state: TIMED_WAITING
21:07:29.964 [     main] t2 state: TIMED_WAITING
21:07:29.977 [       t2] [진입 실패] 이미 처리중인 작업이 있습니다
21:07:30.480 [       t1] [출금 완료] 출금액: 800, 잔액: 200
21:07:30.481 [       t1] 거래 종료
21:07:30.482 [     main] 최종 잔액: 200
*/

 

(5) 정리

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