ReentrantLock의 이해와 활용
ReentrantLock 한 줄 정의
ReentrantLock은 synchronized처럼 “한 번에 한 스레드만 임계 구역을 통과하게 하는 락”이지만,락 자체를 객체로 다루면서 훨씬 많은 기능을 제공하는 고급 락이다.
private final ReentrantLock lock = new ReentrantLock();
여기서 lock은 JVM 모니터락이 아니다.
synchronized가 사용하는 “모니터(monitor)”와는 완전히 별개의 락 객체다. 이 차이에서부터 ReentrantLock의 모든 추가 기능들이 나온다고 보면 된다.
1. Reentrant(재진입 가능)이라는 이름의 진짜 의미
1.1 그냥 “또 들어올 수 있다”가 아님
“현재 락을 가지고 있는 스레드가 다시 같은 락을 요청해도 DeadLock 없이 다시 들어올 수 있다.”
이게 Reentrant의 정의인데, 이걸 아주 구체적으로 뜯어보면
- ReentrantLock은 내부적으로 “락을 가진 스레드 + 그 스레드가 몇 번이나 락을 다시 획득했는지(hold count)”를 같이 관리한다.
- 같은 스레드가 다시 lock()을 호출하면:
- 소유자(owner)가 같으면 → deadlock이 아니라 hold count만 +1
- 다른 스레드가 lock()을 호출하면 → 대기 상태로 큐에 줄 서기
예시로 보면 다음과 같다.
class Service {
private final ReentrantLock lock = new ReentrantLock();
public void outer() {
lock.lock();
try {
// ...
inner(); // 내부에서 다시 lock() 호출
} finally {
lock.unlock();
}
}
private void inner() {
lock.lock();
try {
// ...
} finally {
lock.unlock();
}
}
}
동작 흐름:
- outer()가 lock.lock() → owner = Thread-1, holdCount = 1
- inner()에서도 lock.lock() → Thread-1이 outer() 내부에서 직접 호출해 같은 Thread-1이므로
- holdCount = 2만 증가 (대기 X)
- inner()의 unlock() → holdCount 2 → 1
- outer()의 unlock() → holdCount 1 → 0
- → holdCount가 0이 되는 순간 락 완전 해제
- ReentrantLock은 “같은 스레드가 몇 번 락을 잡았는지”를 기억한다.
- unlock()은 “한 번 lock()에 정확히 한 번씩” 대응해야 한다.
- 이 구조 덕분에 재귀 호출, 계층적 메서드 호출에서 같은 락을 여러 번 쓰기 편하다.
- non-reentrant 락이었다면 위 코드는 자기 자신에게 걸리는 데드락이 되었을 것.
synchronized도 사실 재진입 가능(reentrant)한 모니터 락이지만, ReentrantLock은 그걸 객체로 꺼내서 더 많은 기능을 붙인 버전이라고 보면 된다.
2. ReentrantLock은 “모니터락”이 아니다
synchronized는 이렇게 생긴 문법을 가진다.
synchronized (obj) {
// obj의 모니터락을 기반으로 동기화
}
- 이때 락의 실체는 “obj에 붙어 있는 모니터”.
- 우리는 그 모니터에 대한 정보를 직접 볼 수도, 컨트롤할 수도 없다.
반면 ReentrantLock은
private final ReentrantLock lock = new ReentrantLock();
// ...
lock.lock();
try {
// critical section
} finally {
lock.unlock();
}
- 락이 객체로 독립되어 있다.
- 그래서 다음이 가능해진다:
- 공정/비공정 모드 설정
- 현재 누가 락을 가지고 있는지 확인 (isHeldByCurrentThread(), getOwner())
- 대기 중인 스레드가 있는지 확인 (hasQueuedThreads())
- 여러 Condition을 락 하나에 묶어 관리 (lock.newCondition())
즉,
synchronized→ “객체에 숨겨진 모니터를 살짝 빌려 쓰는 문법”
ReentrantLock→ “락 자체를 new 해서 노골적으로 들고 사용하는 객체”
라고 생각하면 된다.
3. 공정 모드(Fair) vs 비공정 모드(Nonfair)
ReentrantLock의 아주 중요한 특징 중 하나가 공정성(fairness) 옵션이다.
new ReentrantLock(true); // 공정 락 (Fair)
new ReentrantLock(false); // 비공정 락 (Nonfair, 기본값)
new ReentrantLock(); // 기본: 비공정
3.1 공정 락(Fair Lock)
“먼저 기다린 스레드가 먼저 락을 얻는다.”
정확히 말하면:
- 락을 얻으려는 스레드를 FIFO 큐에 쌓아둔다.
- 락이 풀릴 때, 큐에서 가장 오래 기다린 스레드가 우선적으로 락을 얻는다.
- 그래서
- 기아(starvation) 가 발생하지 않는다 (이론적으로).
- 대신 매번 “내 순서인가?” 확인하고 큐를 유지해야 해서 성능이 떨어진다.
3.2 비공정 락(Nonfair Lock) – 기본값
“새로 온 스레드도 빈틈만 보이면 바로 락을 가져갈 수 있다.”
- 락이 풀리는 순간:
- 대기 중인 스레드가 있어도
- 지금 막 lock()을 호출한 스레드가 운 좋게 바로 락을 가져갈 수 있음
- 이걸 “barging(새치기)”라고 부른다.
- 결과:
- 스루풋(처리량)은 올라간다.
- 대신 오래 기다리는 스레드가 계속 미뤄질 수 있어 기아(starvation) 가능성 up.
대부분의 일반 서비스에서는
- 요청이 아주 많고
- 살짝의 불공정성을 허용할 수 있을 때
→ 비공정 락이 기본적으로 더 빠르고 실용적이라서 default가 Nonfair다.
3.3 synchronized의 공정성은?
- synchronized는 공정성 옵션이 없다.
- 즉, 내부 구현에 따라 실질적으로 비공정(nonfair)에 가깝게 동작한다고 보면 된다.
- “먼저 기다린 스레드가 먼저 락을 얻는다”는 보장이 전혀 없음.
4. Condition
synchronized에서는 조건 대기를 이렇게 한다.
synchronized (obj) {
while (!조건만족) {
obj.wait();
}
// ...
obj.notify();
}
문제는
- obj마다 대기 큐가 딱 하나뿐. → 서로 다른 조건을 모두 한 큐에 얹어 써야 함.
- notify()를 하면 “어떤 스레드가 깨어날지”를 알 수 없다.
- “읽는 스레드 / 쓰는 스레드 / 검사하는 스레드” 등 여러 부류가 있어도, 같은 wait() / notify() 큐를 공유해야 해서 코드가 지저분해진다.
4.1 ReentrantLock + Condition
ReentrantLock에서는 이렇게 한다.
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
핵심 차이
항목 wait/notify(Object) Condition (Lock 기반)
| 대기 큐 개수 | 객체당 1개 | 락 하나에 Condition 여러 개 가능 |
| API | wait(), notify() | await(), signal(), signalAll() |
| 제어력 | 누가 깨어날지 모름 | 논리적으로 역할별 Condition 분리 |
| 사용법 | synchronized 필요 | 반드시 lock.lock() 안에서만 사용 |
생산자/소비자 패턴 예시:
class BoundedQueue<E> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();
private final Queue<E> queue = new ArrayDeque<>();
private final int capacity;
BoundedQueue(int capacity) {
this.capacity = capacity;
}
public void put(E e) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await(); // 꽉 차면 notFull 큐에서 대기
}
queue.add(e);
notEmpty.signal(); // 소비자 쪽 깨우기
} finally {
lock.unlock();
}
}
public E take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 비었으면 notEmpty 큐에서 대기
}
E e = queue.remove();
notFull.signal(); // 생산자 쪽 깨우기
return e;
} finally {
lock.unlock();
}
}
}
여기서
- notEmpty에는 “소비자(꺼내는 쪽)” 스레드만 대기
- notFull에는 “생산자(넣는 쪽)” 스레드만 대기
이렇게 역할별로 Condition 큐를 쪼개서 관리할 수 있다.
이때,
- await() / signal() / signalAll()은 반드시 해당 Condition을 만든 Lock을 잡은 상태에서만 호출해야 한다.
- await()는 내부적으로 lock.unlock() + 대기 + 재획득 과정을 한 번에 처리한다.
- signal()은 해당 Condition 큐에서 “하나의 스레드”만 깨운다.
- signalAll()은 Condition 큐에 대기 중인 “모든 스레드”를 깨운다.
5. tryLock() 공정성 주의점
여기가 약간 헷갈리는 포인트라, 정확히 짚고 넘어가야 한다.
공식 문서에서 말하는 핵심
공정 모드(fair=true)로 생성했어도 tryLock()은 공정성을 깨고 “새치기(barging)”할 수 있다.
5.1 문제 상황
Lock lock = new ReentrantLock(true); // 공정 락
// 스레드 A, B는 이미 lock()을 호출하고 대기 중
// 이때 스레드 C가 등장해서:
if (lock.tryLock()) {
// 바로 락 획득 가능 (대기 큐 무시)
}
공정 락인데도, tryLock()은 “지금 당장 락이 비어 있으면 그냥 잡는다”는 성격이라
- 이미 공정하게 줄 서 있는 스레드들(A, B)이 있어도 tryLock() 호출한 스레드(C)가 운 좋게 끼어들어 락을 가져갈 수 있다.
즉, 공정 모드여도
- lock() → 공정성 준수 (FIFO)
- tryLock() → 공정성 무시 가능 (barging)
5.2 그럼 공정성을 지키면서 “즉시 시도”는 못하나?
이때 쓰는 트릭이 바로 이거
lock.tryLock(0, TimeUnit.SECONDS);
왜 이게 다르냐면
- tryLock(long time, TimeUnit unit)은 공정 모드 설정을 존중하는 구현을 따른다.
- 공정 락에서 tryLock(0, SECONDS)를 호출하면:
- 락이 비어 있고,
- 나보다 먼저 기다리는 스레드가 없다면 락 획득
- 이미 큐에 선행 대기 스레드가 있다면 → 바로 false 반환 (새치기 안 함)
그래서 정리하면
timeout 버전의 tryLock은 내부적으로 공정 모드의 큐에 들어간 뒤, 시간이 0이면 바로 한 번 체크만 하고 빠지는 느낌으로 동작한다, 라고 이해하면 된다! (빠진다는 게 바로 false 되어 탈락된다는 것. 대기 X)