ReentrantLock과 Condition
명시적 락에서 await·신호를 호출할 수 있는 범위를 확인하고 try-finally 해제·인터럽트·공정성 선택을 포함한 대기 큐를 구현합니다.
ReentrantLock은 synchronized의 대체 철자에 그치지 않습니다.
잠금 획득을 메서드 호출로 드러내고, 시간 제한 획득과 인터럽트 가능한 획득, 여러 조건 대기실을 제공합니다.
그만큼 해제 책임도 코드가 직접 집니다.
예외가 발생해도 반드시 unlock이 실행되도록 lock() 직후 try-finally 골격을 세워야 합니다.
Condition은 자신을 만든 락과 결합됩니다.
await()는 해당 락을 보유한 스레드만 호출할 수 있고, 대기에 들어가며 소유권을 내놓습니다.
signal() 역시 같은 락 안에서 상태를 바꾼 뒤 실행해야 합니다.
모니터 규칙이 객체 메서드에서 명시적 타입으로 옮겨 왔을 뿐, 조건 검사와 신호의 원리는 그대로입니다.
락 미소유 상태의 await 호출
아래 예제는 newCondition()까지는 성공하지만 락을 얻지 않고 대기합니다.
실행하면 IllegalMonitorStateException이 발생합니다.
Condition이라는 별도 객체가 있으니 독립적으로 기다릴 수 있을 것이라는 오해를 바로 드러내는 실패입니다.
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class AwaitWithoutLock {
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Condition changed = lock.newCondition();
try {
changed.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}락을 얻었다가 해제한 뒤 signal하는 것도 같은 오류입니다.
공유 상태의 변경과 알림 사이를 다른 스레드가 관찰하면 신호와 조건이 어긋날 수 있습니다.
올바른 순서는 잠금, 조건 재검사, 상태 전이, 신호, finally 해제입니다.
대기자는 await 내부에서 잠시 해제하지만 메서드가 반환될 때는 다시 락을 가진 상태라는 점도 중요합니다.
명시적 락을 사용할 때 검토할 네 경계
첫째, 모든 접근 경로가 같은 ReentrantLock을 사용하는지 확인합니다.
일부 메서드만 synchronized를 쓰면 두 잠금은 서로를 배제하지 않습니다.
둘째, unlock은 락 획득이 성공한 뒤의 finally에서 한 번만 실행합니다.
셋째, 조건은 술어를 반복 확인합니다.
넷째, 인터럽트 가능한 메서드가 취소 신호를 삼키지 않도록 예외를 전달합니다.
isLocked()를 보고 나서 행동하는 코드는 검사 직후 상태가 달라질 수 있으므로 동기화 수단이 아닙니다.tryLock실패는 임계 영역을 실행하지 않았다는 정상 분기이며 무조건unlock해서는 안 됩니다.- 공정 락은 경합 시 오래 기다린 스레드의 획득을 우선하지만 처리량이 낮아질 수 있고 스레드 스케줄링이나 전체 작업의 실행 순서를 보장하지는 않습니다. 인자 없는
tryLock()은 공정 정책을 따르지 않습니다. await는 신호 없이 임의로 복귀할 수도 있습니다. 이 락의 Condition은 복귀 전에 락을 다시 얻으므로, 소유한 상태에서 공유 조건을 다시 검사합니다.- 진단 메서드의 대기자 수는 순간 추정치이므로 정확성 조건으로 사용하지 않고 운영 관찰에만 씁니다.
- 한 Condition에 서로 다른 술어가 섞이면 단일 모니터와 같은 대상 선택 문제가 다시 생깁니다.
명시적 잠금의 장점은 기능이 필요할 때만 생깁니다.
단순 상호 배제만 필요하면 synchronized가 해제 누락을 구조적으로 막아 더 읽기 쉽습니다.
시간 제한 락 획득, 인터럽트 가능한 획득, 복수 조건 중 하나가 실제 요구일 때 ReentrantLock의 추가 복잡도를 정당화할 수 있습니다.
단일 변경 조건으로 동작하는 메시지 저장소
이 구현은 아직 Condition을 하나만 둡니다.
생산자와 소비자가 같은 대기실을 공유하므로 상태 전이 뒤 signalAll을 사용합니다.
앞 절의 모니터 버전과 동등한 정확성을 유지하면서 명시적 락 골격을 익히는 것이 목표입니다.
용량과 큐 상태는 오직 lock이 보호합니다.
import java.util.ArrayDeque;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class SingleConditionBuffer<E> {
private final Queue<E> queue = new ArrayDeque<>();
private final int capacity;
private final ReentrantLock lock;
private final Condition changed;
public SingleConditionBuffer(int capacity, boolean fair) {
if (capacity < 1) throw new IllegalArgumentException("capacity");
this.capacity = capacity;
this.lock = new ReentrantLock(fair);
this.changed = lock.newCondition();
}
public void put(E value) throws InterruptedException {
if (value == null) throw new NullPointerException("value");
lock.lockInterruptibly();
try {
while (queue.size() == capacity) changed.await();
queue.add(value);
changed.signalAll();
} finally {
lock.unlock();
}
}
public E take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (queue.isEmpty()) changed.await();
E value = queue.remove();
changed.signalAll();
return value;
} finally {
lock.unlock();
}
}
public int size() {
lock.lock();
try {
return queue.size();
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
SingleConditionBuffer<String> buffer = new SingleConditionBuffer<>(1, false);
Thread consumer = Thread.ofPlatform().start(() -> {
try { System.out.println("got=" + buffer.take()); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread producer = Thread.ofPlatform().start(() -> {
try { buffer.put("condition"); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
producer.join();
consumer.join();
System.out.println("size=" + buffer.size());
}
}SingleConditionBuffer의 put과 take가 같은 락과 changed 조건을 사용하며 대기와 해제 책임을 나누는 지점을 비교합니다.
| 원문의 구간 | 락 소유 | 조건·해제 동작 |
|---|---|---|
lockInterruptibly() | 획득에 성공하면 try로 진입 | 입구에서 중단되면 try에 들어가지 않으므로 unlock하지 않습니다. |
| 각 메서드의 while | changed.await() 동안 반납 | put은 가득 참, take는 빈 상태를 재검사합니다. 복귀하려면 락을 다시 얻어야 합니다. |
| 큐 변경과 finally | 변경·알림까지 같은 락 보유 | 양쪽 모두 changed.signalAll을 호출하고 finally에서 unlock합니다. |
lockInterruptibly()- 락 소유: 획득에 성공하면 try로 진입조건·해제 동작: 입구에서 중단되면 try에 들어가지 않으므로 unlock하지 않습니다.
- 각 메서드의 while
- 락 소유:
changed.await()동안 반납조건·해제 동작: put은 가득 참, take는 빈 상태를 재검사합니다. 복귀하려면 락을 다시 얻어야 합니다. - 큐 변경과 finally
- 락 소유: 변경·알림까지 같은 락 보유조건·해제 동작: 양쪽 모두 changed.signalAll을 호출하고 finally에서 unlock합니다.
정상 완료하면 got=condition 다음 size=0입니다. 이 main의 fair 값은 false이며 실제 await 호출이나 공정성 효과를 출력하지 않습니다.
lockInterruptibly를 사용하면 락 입구에서 기다리는 동안에도 취소에 반응합니다.
일반 lock은 획득 전 인터럽트가 즉시 예외로 나오지 않습니다.
버퍼 작업이 요청 취소와 연결되어 있다면 입구 대기와 조건 대기 모두 중단 가능해야 일관된 API가 됩니다.
반드시 획득에 성공한 뒤 try로 들어가므로 finally의 해제도 안전합니다.
락 획득 제한 시간과 조건 대기 구분
락을 얻는 시간과 버퍼 자리가 생기는 시간은 서로 다른 지연입니다.
다음 카운터는 락 획득에 100밀리초 제한을 두고, 획득에 성공한 뒤 별도의 조건 대기 예산을 시작합니다.
awaitNanos의 반환 잔여값을 다음 반복에 전달하면 매번 원래 예산을 새로 주는 일을 피할 수 있습니다.
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class TimedConditionCounter {
private final ReentrantLock lock = new ReentrantLock();
private final Condition advanced = lock.newCondition();
private long value;
void increment() {
lock.lock();
try {
value++;
advanced.signalAll();
} finally {
lock.unlock();
}
}
boolean awaitAtLeast(long target, Duration timeout) throws InterruptedException {
if (!lock.tryLock(100, TimeUnit.MILLISECONDS)) return false;
try {
long remaining = timeout.toNanos();
while (value < target) {
if (remaining <= 0) return false;
remaining = advanced.awaitNanos(remaining);
}
return true;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
TimedConditionCounter counter = new TimedConditionCounter();
Thread updater = Thread.ofPlatform().start(() -> {
counter.increment();
counter.increment();
});
System.out.println("reached=" + counter.awaitAtLeast(2, Duration.ofSeconds(1)));
updater.join();
}
}TimedConditionCounter.awaitAtLeast는 시간 제한 락 획득 뒤 조건 예산을 초기화하고 조건과 잔여값을 차례로 검사합니다.
| 원문의 경계 | 시간·조건 검사 | 반환 또는 다음 동작 |
|---|---|---|
| 락 획득 | tryLock(100, MILLISECONDS) | 획득하지 못하면 false입니다. 조건 예산은 아직 시작하지 않았습니다. |
| 획득에 성공한 뒤 | remaining을 timeout으로 초기화 | main은 여기서 새 1초 조건 예산을 사용합니다. |
| 값이 목표보다 작음 | remaining ≤ 0인지 검사 | 0 이하면 false, 아니면 awaitNanos의 반환 잔여값으로 다시 반복합니다. |
| 값이 목표 이상임 | while을 건너뛰거나 빠져나옴 | 잔여값이 양수인지 추가로 검사하지 않고 true입니다. |
- 락 획득
- 시간·조건 검사:
tryLock(100, MILLISECONDS)반환 또는 다음 동작: 획득하지 못하면 false입니다. 조건 예산은 아직 시작하지 않았습니다. - 획득에 성공한 뒤
- 시간·조건 검사: remaining을 timeout으로 초기화반환 또는 다음 동작: main은 여기서 새 1초 조건 예산을 사용합니다.
- 값이 목표보다 작음
- 시간·조건 검사: remaining ≤ 0인지 검사반환 또는 다음 동작: 0 이하면 false, 아니면 awaitNanos의 반환 잔여값으로 다시 반복합니다.
- 값이 목표 이상임
- 시간·조건 검사: while을 건너뛰거나 빠져나옴반환 또는 다음 동작: 잔여값이 양수인지 추가로 검사하지 않고 true입니다.
false 한 값으로 락 획득 실패와 조건 예산 종료를 구분할 수 없습니다. 락 재획득·스케줄 지연과 출력 뒤 updater.join까지 전체 실행 시간을 제한하는 코드는 아닙니다.
업데이트와 락 획득이 진행되는 시점에 따라 reached는 true 또는 false가 될 수 있습니다.
절대 시각을 직접 빼는 방법도 가능하지만 System.currentTimeMillis는 시계 조정 영향을 받으므로 경과 시간에는 단조 증가하는 System.nanoTime이나 Condition의 잔여값을 사용합니다.
ReentrantLock 도입 여부 판단표
| 필요한 기능 | synchronized 가능성 | 결론 |
|---|---|---|
| 단순한 짧은 상호 배제 | 충분함 | 내장 모니터 유지 |
| 락 입구에서 취소 | 직접 지원 부족 | lockInterruptibly 고려 |
| 획득 시간 제한 | 직접 표현 어려움 | tryLock 사용 |
| 조건을 역할별로 분리 | 대기 집합 하나 | 여러 Condition 채택 |
공정성 플래그를 켜면 모든 지연 문제가 사라진다고 생각해서는 안 됩니다.
공정 락도 재진입과 스케줄러 영향이 있으며, 조건에서 깨어난 뒤의 정확한 실행 순서를 약속하지 않습니다.
기아가 실제로 관찰되고 잠금 획득 편향이 원인일 때 처리량과 함께 비교합니다.
연습 문제
창고 재고 카운터에 awaitStock(int minimum, Duration timeout)을 구현하세요.
입고는 양수만 허용하고 조건을 알립니다.
대기 메서드는 락 입구에서도 인터럽트 가능해야 합니다. 락을 얻은 뒤 조건 대기 예산을 시작하고 반환된 잔여값을 다음 반복에 사용하세요. 락 입구의 대기에는 시간 제한을 두지 않습니다.
조건이 아직 충족되지 않았고 잔여 예산이 0 이하이면 재고를 바꾸지 않고 false를 반환합니다.
정답과 해설
재고 필드와 조건은 같은 명시적 락이 보호합니다.
입고가 끝난 후 신호를 보내고, 대기자는 stock < minimum인 동안 awaitNanos를 호출합니다.
제한 시간 판단과 조건 검사가 잠금 안에 있으므로 접점에서 입고와 타임아웃이 경쟁해도 하나의 일관된 상태를 봅니다.
import java.time.Duration;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class InventoryThresholdSolution {
private final ReentrantLock lock = new ReentrantLock();
private final Condition stockChanged = lock.newCondition();
private int stock;
void receive(int quantity) {
if (quantity <= 0) throw new IllegalArgumentException("quantity");
lock.lock();
try {
stock = Math.addExact(stock, quantity);
stockChanged.signalAll();
} finally {
lock.unlock();
}
}
boolean awaitStock(int minimum, Duration timeout) throws InterruptedException {
if (minimum < 0) throw new IllegalArgumentException("minimum");
lock.lockInterruptibly();
try {
long nanos = timeout.toNanos();
while (stock < minimum) {
if (nanos <= 0) return false;
nanos = stockChanged.awaitNanos(nanos);
}
return true;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
InventoryThresholdSolution inventory = new InventoryThresholdSolution();
Thread delivery = Thread.ofPlatform().start(() -> inventory.receive(12));
boolean ready = inventory.awaitStock(10, Duration.ofMillis(500));
delivery.join();
System.out.println("ready=" + ready);
}
}입고 시점에 따라 ready는 true 또는 false가 될 수 있습니다. 뒤의 delivery.join은 입고 스레드 종료를 기다리지만 이미 결정된 반환값을 바꾸지 않습니다.
여러 종류의 재고나 출고 가능 조건이 필요해지면 하나의 Condition에 복잡한 역할을 모두 섞지 말고 도메인 술어와 신호 범위를 다시 설계합니다.
필요 수량이 서로 다른 대기자는 signal 하나로 올바른 대상 선택을 보장할 수 없으므로 전체 알림이 더 안전합니다.
명시적 락의 능력과 책임
ReentrantLock을 택했다면 모든 종료 경로의 해제를 눈으로 추적할 수 있어야 합니다.
획득 직후 try, 해제는 finally, 상태 검사와 신호는 그 사이에 둡니다.
Condition 호출이 소유권 밖으로 새면 즉시 런타임 실패하거나 더 미묘한 신호 손실로 이어집니다.
단일 조건 버전은 모니터 모델과 연결하기 위한 중간 단계입니다.
다음 절에서는 생산 가능과 소비 가능 대기실을 분리해 전체 각성을 줄이되, 반복 검사와 인터럽트 규칙은 그대로 유지합니다.