본문 바로가기

Java

[JAVA] ReentrantLock

0. 들어가기 전

자바에서 멀티스레드 환경에서의 동기화를 위해 가장 기본적으로 제공되는 도구는 synchronized 키워드입니다.

자바 1.0부터 제공되어 왔으며, 문법적으로 간단하고 자동으로 락 해제까지 지원해주는 매우 편리한 기능입니다.

하지만 synchronized 는 다음과 같은 한계점을 가지고 있었습니다.

 

❌ synchronized의 한계점

1) 무한 대기
BLOCKED 상태에 있는 스레드는 락을 획득할 때까지 무한정 기다립니다.
락을 일정 시간까지만 기다리는 타임아웃 기능이 없으며, 인터럽트로 대기 상태를 해제할 수도 없습니다.

2) 공정성 부족
락이 해제되었을 때 어떤 스레드가 락을 먼저 획득할지는 보장되지 않습니다.
심할 경우, 특정 스레드는 오랫동안 락을 획득하지 못하는 상황이 발생할 수 있습니다.

 

이러한 단점들로 인해, 더 정밀하고 유연한 동기화 제어가 가능한 도구들이 필요해졌습니다.

이에 따라 자바 1.5부터 java.util.concurrent 패키지가 도입되었고, 이 안에는 다양한 동시성 제어 클래스들이 포함되어 있습니다.

 

그 중에서도 synchronized 무한 대기 문제를 해결할 수 있는 가장 기본적인 도구가 바로 LockSupport 입니다.

이제 LockSupport가 어떤 문제를 어떻게 해결하는지 살펴보겠습니다.

 

👉 synchronized 키워드에 대한 자세한 내용이 궁금하다면 이 글을 참고해주세요.

 

 

[JAVA] synchronized 동기화

0. 들어가기 전멀티스레드를 사용할 때 가장 주의해야 할 점은, 같은 자원(리소스)에 여러 스레드가 동시에 접근할 때 발생하는 동시성 문제 (concurrency issue)입니다. 이처럼 여러 스레드가 동시에

madeprogame.tistory.com


1. LockSupport

LockSupport 는 스레드를 직접 제어할 수 있는 저수준 동기화 도구로, 스레드를 WAITING 상태로 전환시킬 수 있습니다.

WAITING 상태에 들어간 스레드는 다른 스레드가 명시적으로 깨워주기 전까지는 계속 대기하며, CPU의 실행 스케줄링 대상에도 포함되지 않습니다.

 

대표적인 기능

park();
  • 현재 스레드를 WAITING 상태로 전환하여 대기시킵니다.
parkNanos(nanos);
  • 현재 스레드를 지정한 나노초 동안만 TIMED_WAITING 상태로 전환합니다.
  • 해당 시간이 지나면 스레드는 자동으로 RUNNABLE 상태로 복귀합니다.
unpark(thread);
  • WAITING 상태에 있는 대상 스레드를 깨워서 RUNNABLE 상태로 전환합니다.

LockSupport 코드 예시 1

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); // 1. unpark 사용
    }

    static class ParkTest implements Runnable {

        @Override
        public void run() {
            log("park 시작");
            LockSupport.park();
            log("park 종료, state: " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}

 

실행 결과

  • main 스레드가 Thread-1을 start() 하면, Thread-1은 RUNNABLE 상태가 되어 실행 대기 상태에 들어갑니다.
  • 이후 Thread-1 내부에서 LockSupport.park()를 호출하면, 자신을 직접 WAITING 상태로 전환시켜 대기하게 됩니다.
  • 그다음, main 스레드가 LockSupport.unpark(Thread-1)을 호출하면, 대기 중이던 Thread-1이 깨어나 다시 RUNNABLE 상태로 전환됩니다.

이처럼 LockSupport 는 스레드를 WAITING 상태로 만들고 다시 RUNNABLE 상태로 복귀시키는 제어 기능을 제공합니다.

그런데, 대기 상태로 만드는 park()는 매개변수가 없지만, 다시 실행 가능하게 만드는 unpark(thread)는 깨울 스레드를 명시적으로 지정해야 합니다. 그 이유는 다음과 같습니다.

 

🤔 park() 는 매개변수가 없는데, unpark(thread1)는 왜 특정 스레드를 지정하는 매개변수가 있을까?

스레드는 자신이 실행 중일 때만 park()를 호출하여 스스로 대기 상태로 전환할 수 있지만, 일단 WAITING 상태에 빠지면 자기 코드를 더 이상 실행할 수 없기 때문에, 반드시 외부 스레드의 도움을 받아야 깨어날 수 있기 때문입니다. 즉, unpark()는 대기 중인 특정 스레드를 외부에서 깨워야 하므로, 깨울 대상 스레드를 명확히 지정할 수 있도록 매개변수를 받도록 설계된 것입니다.

 

🤔 park() 로 인해 대기 중인 스레드에 대해 interrupt() 를 호출하면, 깨어날까?

interrupt()로 깨우기 가능

WAITING 상태에 있는 스레드에게 인터럽트가 발생하면, 해당 스레드는 RUNNABLE 상태로 전환되며 중간에 깨어나게 됩니다.
즉, unpark()를 사용하지 않고도 인터럽트를 통해 스레드를 깨우는 것이 가능합니다. 예를 들어, LockSupport.park()로 대기 중인 스레드에 대해 interrupt()를 호출하면, 해당 스레드는 WAITING → RUNNABLE 상태로 변하게 됩니다. 또한, 스레드의 인터럽트 플래그가 true로 설정된 것도 함께 확인할 수 있습니다.
 
이번에는 LockSupport.parkNanos(nanos)를 사용해 스레드를 일정 시간 동안만 대기시키는 예제를 살펴보겠습니다.
 

LockSupport 코드 예시 2

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 시작");
            LockSupport.parkNanos(2000_000000);
            log("park 종료, state: " + Thread.currentThread().getState());
            log("인터럽트 상태: " + Thread.currentThread().isInterrupted());
        }
    }
}

  • Thread-1은 LockSupport.parkNanos(2초)를 호출하여 2초 동안 TIMED_WAITING 상태에 진입하게 된다.
  • 그리고 2초가 지나면 자동으로 대기 상태를 벗어나 RUNNABLE 상태로 전환되어 실행을 계속한다.

2. BLOCKED vs WAITING

1. BLOCKED 상태

  • 정의: 다른 스레드가 보유한 synchronized 락을 획득하기 위해 대기할 때 발생합니다.
  • 인터럽트 반응: BLOCKED 상태의 스레드는 인터럽트를 받아도 깨어나지 못합니다. 여전히 BLOCKED 상태를 유지합니다.
  • 용도: synchronized 블록이나 메서드 진입 시, 이미 락이 점유 중이라면 스레드는 BLOCKED 상태가 됩니다.
  • 특징: 자바의 락 전용 대기 상태입니다. 용도가 제한적입니다.

2. WAITING / TIMED_WAITING 상태

1)WAITING 상태

  • 정의: 외부의 신호가 있을 때까지 무기한 대기하는 상태입니다.
  • 전환 조건: Thread.join(), LockSupport.park(), Object.wait() 등 호출 시 진입합니다.
  • 인터럽트 반응: 인터럽트를 걸면 RUNNABLE 상태로 복귀합니다.
  • 특징: 외부에서 반드시 직접 깨워주어야 다시 실행됩니다.

2)TIMED_WAITING 상태

  • 정의: 일정 시간만큼만 대기하는 상태입니다.
  • 전환 조건: Thread.sleep(ms), Object.wait(timeout), Thread.join(timeout), LockSupport.parkNanos(ns) 등을 호출할 때 진입합니다.
  • 인터럽트 반응: 역시 인터럽트로 깨어날 수 있으며, 지정 시간이 지나도 자동으로 RUNNABLE 상태로 복귀합니다.
  • 특징: WAITING 상태의 시간 제한 버전입니다.

정리하면 BLOCKED, WAITING, TIMED_WAITING 상태는 모두 스레드가 실행되지 않고 대기하는 상태이며, 실행 스케줄링 대상에서 제외되기 때문에 CPU 입장에서는 모두 비슷하게 "작동하지 않는 상태"로 간주됩니다. 이 중에서 BLOCKED 상태는 다른 스레드가 보유한 synchronized 락을 기다릴 때에만 발생하는, 자바의 락 전용 대기 상태입니다. 반면, WAITINGTIMED_WAITING 상태는 시간 대기, 조건 대기 등 다양한 상황에서 활용 가능한 범용적인 대기 상태라고 이해하면 됩니다.


3. LockSupport 정리

이처럼 LockSupport를 활용하면 무한 대기하지 않는 락 기능을 직접 구현할 수 있습니다. 예를 들어, 하나의 lock 클래스를 만들고, 먼저 락을 얻은 스레드는 RUNNABLE 상태로 실행하고, 락을 얻지 못한 스레드는 park()를 호출하여 대기 상태로 전환시키는 방식입니다. 이후 임계 영역을 실행한 스레드는 락을 반납하고, unpark()를 통해 대기 중인 다른 스레드를 깨우는 구조를 만들 수 있습니다. 또한, parkNanos()를 활용하면 스레드가 너무 오래 대기하지 않도록 타임아웃을 설정하는 것도 가능합니다.

// 코드 예시
if (!lock.tryLock(10초)) { // 내부에서 parkNanos() 사용 
    log("[진입 실패] 너무 오래 대기했습니다.");
    return false;
}

//임계 영역 시작
...
//임계 영역 종료
lock.unlock() // 내부에서 unpark() 사용

 

그러나 이러한 락 기능을 직접 구현하는 것은 매우 어렵습니다. 예를 들어, 동시에 10개의 스레드가 실행될 때 단 1개의 스레드만 락을 가지도록 하려면, 나머지 9개의 스레드를 모두 대기 상태로 만들고, 대기 중인 스레드를 추적할 수 있는 자료구조도 직접 구현해야 합니다.

나아가, 어떤 스레드를 먼저 깨울지 결정하는 우선순위 정책까지도 고려해야 하므로, 이 모든 기능을 LockSupport 만으로 구현하기에는 너무 저수준입니다. 따라서 현실적으로는 synchronized 처럼 더 고수준의 동기화 도구가 필요합니다.

 

하지만 걱정할 필요는 없습니다. 자바는 이미 이런 기능들을 Lock 인터페이스와 그 대표적인 구현체인 ReentrantLock을 통해 잘 구현해두었습니다. ReentrantLock은 내부적으로 LockSupport를 활용하여, synchronized가 가진 무한 대기공정성 부족과 같은 단점들을 보완하면서도, 매우 편리하게 임계 영역을 다룰 수 있는 다양한 기능을 제공한다. 예를 들어, 락 획득 시 타임아웃 지정, 인터럽트 가능 여부 설정, 공정성(fairness) 정책 적용 등 다양한 기능을 통해 보다 정밀한 동기화 제어가 가능합니다.


4. ReentrantLock

자바는1.0 부터 존재하던 synchronized BLOCKED 상태 기반의 임계 영역 관리가 가진 한계를 극복하기 위해,

자바 1.5 부터 Lock 인터페이스 와 그 구현체인 ReentrantLock 을 도입했습니다.

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():
  • 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 현재 스레드는 대기(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 메서드와 유사한 역할을 한다.

⚠️ 주의할 점

여기서 사용하는 락은 객체의 모니터 락이 아닙니다!
synchronized 에서 사용하는 모니터 락과 BLOCKED 상태는 자바 언어 차원에서 제공되는 구조이고, lock()Lock 인터페이스와 ReentrantLock 구현체가 제공하는 별도의 기능입니다. 정리하자면, lock()을 사용할 때는 synchronized 에서 사용되는 모니터 락이나 BLOCKED 상태와는 전혀 다르다는 것입니다.

 

🔎 lock() 은 인터럽트를 무시한다?

lock() 메서드는 인터럽트가 발생하더라도 락을 끝까지 기다리는 방식입니다. 분명히 WAITING 상태에서 인터럽트가 발생하면 스레드는 대기 상태를 벗어난다고 배웠는데, lock()은 왜 무시할까요? 비밀은 이렇습니다. 일반적으로 WAITING 상태의 스레드는 인터럽트가 발생하면 RUNNABLE 상태로 깨어나지만, lock()을 사용할 경우 인터럽트가 발생해 잠시 깨어나더라도 메서드 내부에서 다시 WAITING 상태로 진입하게 됩니다. 즉, 스레드가 일시적으로 깨어났다가 다시 대기 상태로 돌아가며, 결과적으로 인터럽트를 무시한 것처럼 동작하게 되는 것입니다. 참고로 인터럽트에 반응하며 락을 얻고 싶다면 lockInterruptibly()를 사용하면 됩니다.

공정성 문제

Lock 인터페이스가 제공하는 다양한 기능 덕분에 synchronized의 단점인 무한 대기 문제는 해결되었습니다. 하지만 여전히 공정성에 대한 문제가 남아 있습니다. Lock 인터페이스의 대표적인 구현체인 ReentrantLock은 이러한 문제를 해결하기 위해 공정 모드(fair mode)를 제공합니다. 공정 모드를 사용하면 먼저 락을 요청한 스레드가 먼저 락을 획득할 수 있도록 우선순위를 보장하여, 특정 스레드가 계속해서 락을 얻지 못하는 상황을 방지할 수 있습니다.

 

사용 예시

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();
        }
    }
}
  • ReentrantLock공정성(fairness) 모드비공정(non-fair) 모드로 설정할 수 있으며, 이 두 모드는 락을 획득하는 방식에 차이가 있습니다.

🌟 비공정 모드 (Non-fair mode)

비공정 모드는 락을 먼저 요청한 스레드가 먼저 락을 획득한다는 보장이 없습니다. 락이 해제되면 대기 중인 스레드 중 아무나 락을 획득할 수 있으며, 새로 진입한 스레드가 기존 대기 중인 스레드보다 먼저 락을 획득할 수도 있습니다. 이러한 구조는 락을 빠르게 획득할 수 있어 성능이 뛰어나지만, 특정 스레드가 계속 락을 얻지 못하는 기아 현상(starvation)이 발생할 가능성이 있습니다.

 

🌟 공정 모드 (Fair mode)

공정 모드는 생성자에서 true 값을 전달하여 설정할 수 있으며, 예시는 new ReentrantLock(true)입니다. 공정 모드에서는 락을 요청한 순서대로 스레드가 락을 획득하게 됩니다. 즉, 먼저 대기한 스레드가 먼저 락을 얻을 수 있도록 공정성을 보장하며, 기아 현상을 방지할 수 있습니다. 그러나 이로 인해 락 획득 속도가 느려져 성능이 저하될 수 있습니다.

 

참고로, 비공정 모드라고 해서 스레드가 완전히 무작위로 락을 획득하는 것은 아닙니다. 

일반적으로는 락 요청 순서에 따라 동작하지만, 새로운 스레드가 타이밍 좋게 CPU를 먼저 점유하는 등의 상황에서는 대기 중인 스레드를 건너뛰고 락을 먼저 획득할 수 있습니다. 이러한 경우가 반복되면 특정 스레드가 계속해서 락을 얻지 못하는 기아 현상이 발생할 수 있으며, 결국 공정한 순서를 보장하지 않는다는 데 의미가 있습니다.

 

요약하자면, 비공정 모드는 성능을 중시하지만 공정성을 희생할 수 있고, 공정 모드는 공정성을 보장하지만 성능이 저하될 수 있습니다.

이처럼 Lock 인터페이스 ReentrantLock 구현체를 사용하면, synchronized가 가진 무한 대기와 공정성 문제를 모두 해결할 수 있습니다.


 

 

'Java' 카테고리의 다른 글

[JAVA] 동시성 컬렉션  (0) 2025.06.30
[JAVA] 원자적 연산  (0) 2025.06.26
[JAVA] synchronized 동기화  (0) 2025.06.21
[JAVA] happens-before 관계  (1) 2025.06.20
[JAVA] volatile과 메모리 가시성  (0) 2025.06.20