1. 배경
Java에서 여러 Thread가 하나의 공유 자원에 동시에 접근한다면
데이터의 일관성 보장을 위해 synchronized 나 ReentrantLock 을 사용할 수 있다
하나의 Thread가 이미 Lock을 획득하고 있다면,
다른 Thread는 Lock이 해제될 때까지 Critical Section에 진입하지 못한다
일반적으로 이런 상황을 두고 Lock을 획득하지 못한 Thread가 Blocking되어 대기한다고 표현한다
그렇다면 Blocking된 Thread는 실제로 무엇을 하고 있을까?
Lock을 획득할 때까지 CPU를 계속 사용하며 대기하는 Spin Wait 형태일까?
아니면 실행을 중단하고 CPU 자원을 다른 Thread에 넘기는 것일까?
또 synchronized 와 ReentrantLock 은 서로 다른 방식으로 Lock을 관리하는데,
Lock을 획득하지 못한 Thread를 실제로 멈추고 다시 깨우는 과정 역시 같을까?
이번 글에서는 두 Lock의 내부 동작에서 시작해
Thread가 어떻게 대기 상태로 전환되고 다시 실행 가능한 상태가 되는지 살펴보고자 한다
2. Synchronized와 ReentrantLock의 관리 계층
Java에서는 위의 두 가지 Lock 매커니즘을 활용해 접근을 제어한다
두 방식 모두 하나의 Thread가 Lock을 획득하면 다른 Thread가 해당 Lock을 획득할 때까지 대기하는 점에서는 비슷하다
하지만 Lock과 대기 Thread를 관리하는 위치는 서로 다르다
synchronized 는 Java 언어와 JVM이 직접 지원하는 Monitor 기반의 동기화 방식이다
synchronized (lock) {
count++;
}
하나의 객체에는 Monitor가 논리적으로 연결되며,
동일한 Monitor를 대상으로 여러 Thread가 경쟁하면 JVM이 Lock의 소유권과 대기 Thread를 관리한다
따라서 synchronized 를 사용하는 코드에서는 애플리케이션이 별도의 관리를 하지 않는다
반면 ReentrantLock 은 Java의 패키지에 구현된, 애플리케이션 레벨의 Lock이다
Lock lock = new ReentrantLock();
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
ReentrantLock 의 내부 구현은 AQS( AbstractQueuedSynchronizer )를 기반으로 한다
AQS는 하나의 동기화 상태값과 FIFO 형태의 Sync Queue를 이용해
Lock을 획득하지 못한 Thread를 관리하기 위한 기반 구조를 제공한다
3. Synchronized의 Thread 대기
앞서
synchronized 는 JVM의 Monitor를 기반으로 Lock과 대기 Thread를 관리한다고 살펴보았다
여기서 Monitor는 Java 객체와 논리적으로 연결되는 동기화 개념이고,
HotSpot에서는 실제 대기 Thread를 관리하기 위해 ObjectMonitor 라는 내부 구조를 사용한다
(이 글에서는 Java에서 널리 사용되는 HotSpot JVM을 예시로 구현 형태를 알아보도록 하겠다)
객체가 생성될 때마다 항상 ObjectMonitor 가 함께 생성되는 것은 아니다
OpenJDK 공식 문서에는 아래와 같이 설명한다
If retrieval of the ObjectMonitor fails, because there is no ObjectMonitor, either because this is the first time inflating or the ObjectMonitor has been deflated a new ObjectMonitor must be created and associated with the object. (출처)
즉, ObjectMonitor 는 단순한 Lock 상태만으로 처리하기 어려워져 Monitor Inflation이 발생할 때 필요해지는 구조다
실제로 HotSpot JVM에서는 경합이 크지 않은 경우 바로 Kernel에 진입하지 않고,
CAS 등의 원자적 연산과 짧은 Spin을 통해 Lock 획득을 다시 시도할 수 있다
하지만 계속해서 Lock을 획득하지 못한다면 Thread를 CPU에서 계속 실행시키는 것보다 대기 상태로 전환하는 것이 효율적이다
이 경우에 ObjectMonitor 가 Lock을 기다리는 Thread를 내부 대기 구조에 등록하고,
JVM 내부의 Thread Parking 메커니즘을 통해 실행을 중단시킨다
4. ReentrantLock의 Thread 대기
ReentrantLock 은 생성할 때 Lock 획득 순서에 대한 정책을 선택할 수 있다
new ReentrantLock(); // Unfair
new ReentrantLock(true); // Fair
기본값은 Unfair이다
Unfair Lock에서는 새로운 Thread가 lock() 을 호출했을 때,
이미 대기 중인 Thread가 존재하더라도 현재 Lock이 비어 있다면 바로 획득을 시도할 수 있다

따라서 먼저 기다리던 Thread보다 나중에 진입한 Thread가 Lock을 먼저 획득하는 상황이 발생할 수 있다
반면 Fair Lock은 Lock이 비어 있더라도 자신보다 먼저 기다리고 있는 Thread가 존재하는지 확인한다
이에 따라 새 Thread가 Lock을 바로 가져가지 않고 대기 순서를 따른다
즉 두 방식의 차이는 Lock을 획득하기 전에 기존 대기 순서를 고려하는지 여부에 있다
하지만 Lock을 획득하지 못한 이후의 대기 방식은 공통적으로 AQS를 기반으로 한다
Thread는 AQS의 Sync Queue에 등록되고,
더 이상 Lock을 획득할 수 없는 상태라면 LockSupport.park() 를 통해 실행을 중단한다
이후 Lock을 소유하고 있던 Thread가 unlock() 을 호출하면
대기 중인 Thread가 다시 Lock 획득을 시도할 수 있도록 깨워진다
정리하면 ReentrantLock 은 AQS를 통해 대기 순서와 상태를 관리하고,실제로 Thread의 실행을 멈추는 과정에서는 LockSupport.park() 를 사용한다
5. LockSupport와 park()
park() 가 호출되면 현재 Thread는 더 이상 실행되지 않고,다시 실행될 수 있는 조건이 충족될 때까지 대기한다
이때 LockSupport 는 Thread마다 하나의 Permit을 갖는 것처럼 동작한다
Permit은 최대 하나까지만 유지되어, 개념적으로 0/1 Permit 형태를 지닌다
Permit이 1인 경우 한 번의 park() 를 Blocking하지 않고 지나갈 수 있으며,Permit이 0인 경우에는 Thread Blocking이 발생한다
이는 park() 와 unpark() 의 실행 순서가 뒤집혀도 신호를 잃지 않도록 하기 위함이다
단순한 sleep-wakeup 구조라면 wakeup이 먼저 발생하고 sleep에 진입하는 경우, wakeup 신호를 놓쳐 영원히 대기할 수 있다
이를 깨우는 신호가 먼저 왔는데 당시 Thread가 자고 있지 않아 신호가 사라지는 Lost Wakeup 문제라 한다
unpark() 는 대상 Thread가 사용할 수 있는 Permit을 제공하고, park() 상태라면 다시 실행 가능한 상태가 되도록 한다
Thread가 다시 실행되더라도 Lock을 실제로 획득할 수 있는지는 다시 확인해야 한다
때문에 AQS는 Thread가 깨어날 때마다 현재 자신이 Lock을 획득할 수 있는 상태인지 다시 확인한다
다만 Thread를 Parking한 뒤 다시 깨우는 과정에는 Scheduling과 Context Switching 비용이 발생할 수 있다
특히 여러 Thread가 하나의 Lock을 순차적으로 기다리는 상황에서
앞선 Thread의 실행이 지연되면 뒤의 Thread도 같이 지연될 수 있다
이러한 현상을 Lock Convoy라 한다
6. Futex
앞서 LockSupport.park() 를 통해 Thread의 실행을 중단할 수 있다는 점을 살펴보았다
하지만 실제로 Thread를 CPU Scheduling 대상에서 제외하고 다시 깨우는 작업은 애플리케이션만으로 처리할 수 없다
Thread가 실제로 대기 상태에 들어가려면 결국 운영 체제의 도움이 필요하다
Linux에서는 이러한 대기를 구현하기 위한 대표적인 매커니즘으로 Futex(Fast Userspace Mutex)를 제공한다
Futex의 핵심은 모든 Lock 연산을 Kernel에서 처리하지 않는다는 점이다
Lock에 경합이 없다면 User Space에 존재하는 상태값을 CAS와 같은 원자적 연산으로 처리할 수 있다
문제는 다른 Thread가 이미 Lock을 가지고 있어 현재 Thread가 더 이상 진행할 수 없는 경우다
이때 Thread를 계속 실행시키면 Lock이 해제될 때까지 CPU를 소모하게 된다
때문에 실제 Blocking이 필요한 시점에는 futex() System Call을 통해 Kernel의 도움을 받을 수 있다
이 구조 때문에 Futex는 일반적인 Kernel Mutex와는 조금 다른 특징을 가진다
일반적인 System Call에서는 User Space에서 Kernel로 요청을 전달한 뒤,
Kernel 내부의 자원이나 상태를 대상으로 작업하는 경우가 많다
반면 Futex는 User Space에 존재하는 메모리 주소를 Kernel에 전달한다
futex(uaddr, FUTEX_WAIT, expected, ...);
여기서 uaddr 는 User Space에 존재하는 Futex Word의 주소이며,
Kernel은 해당 주소의 값을 확인해 Thread를 실제로 Blocking할지 결정한다
즉 Futex는 User Space에서 관리되는 동기화 상태와
Kernel이 담당하는 Thread Blocking을 하나의 메모리 주소를 통해 연결한다

User Space에서 관리되는 uaddr 의 값이 LOCKED 라면 Thread를 Blocking 상태로 재우고,
그렇지 않으면 즉시 반환하여 기존 Thread의 작업을 이어간다
이때 단순히 현재 값을 확인한 뒤 Thread를 재우는 것만으로 처리하면
앞서 살펴본 Lost Wakeup 문제가 Kernel 단에서 다시 발생한다
이를 해결하기 위해 FUTEX_WAIT 은 Thread를 단순히 재우는 것이 아니라,
Futex Word의 값을 읽고 Expected Value와 비교하여 Blocking까지 들어가는 과정을 원자적으로 처리한다
값이 이미 변경되어 Expected Value와 다르다면 Thread는 잠들지 않고 EAGAIN 을 반환받아 User Space로 돌아간다
이처럼 Futex는 Kernel이 단순히 Lock을 대신 관리하는 것이 아니다
Lock의 상태 자체는 여전히 User Space에서 관리할 수 있고,
Kernel은 실제 Thread Blocking이 필요한 순간에만 개입한다
이러한 구조 덕분에 경합이 없는 일반적인 경우에는 System Call 없이 User Space에서 빠르게 처리하고,
실제 대기가 필요한 경우에만 Kernel까지 내려가는 것이 가능하다
'탐구일지 > Java' 카테고리의 다른 글
| Java의 Hash 자료구조 동작 원리와 안전한 Hash Key 설계 (0) | 2026.08.05 |
|---|---|
| LongAdder는 어떻게 CAS 경쟁을 분산할까? (0) | 2026.08.03 |
| AtomicLong은 어떻게 Lock-Free를 달성했을까? (0) | 2026.07.30 |
| G1 GC는 왜 기본 GC가 되었을까? (0) | 2026.04.24 |
| JVM GC는 어떻게 발전해왔는가? (0) | 2026.04.22 |