ReentrantLock과 계좌 불변식
종료 스레드가 락을 남기는 실패를 재현하고 검증·변경·조회에 같은 private final 락을 적용합니다.
ReentrantLock은 synchronized와 달리 블록을 벗어났다고 자동 해제되지 않습니다.
return이나 예외가 발생해도 락은 소유자가 unlock()할 때까지 남습니다.
따라서 획득 직후 try-finally를 열고 보호하는 모든 경로가 같은 락을 사용해야 합니다.
소유 스레드 종료 시 자동 해제 오해
아래 소유자는 락을 얻은 뒤 unlock 없이 종료합니다.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class LeakedReentrantLockBug {
public static void main(String[] args) throws InterruptedException {
ReentrantLock lock = new ReentrantLock();
Thread owner =
new Thread(
() -> {
lock.lock();
System.out.println("owner-acquired");
},
"leaking-owner");
owner.start();
owner.join();
boolean acquired = lock.tryLock(20, TimeUnit.MILLISECONDS);
System.out.println("owner=" + owner.getState() + ", wrong-auto-release=" + acquired);
if (acquired) lock.unlock();
}
}LeakedReentrantLockBug에서 join이 소유자 종료를 확인해도 timed tryLock이 실패하는 이유를 추적합니다.
| 확인 지점 | 원문에서 일어난 일 | 결과 |
|---|---|---|
| 소유자 실행 | 획득 뒤 unlock 없이 종료 | owner-acquired 출력 |
main의 join() 반환 | 소유자 종료 · 락은 계속 점유됨 | 이후 상태 조회는 TERMINATED |
| 20ms 획득 시도 | main은 종료한 소유자의 락을 얻지 못함 | wrong-auto-release=false |
- 소유자 실행
- 원문에서 일어난 일: 획득 뒤 unlock 없이 종료결과:
owner-acquired출력 main의join()반환- 원문에서 일어난 일: 소유자 종료 · 락은 계속 점유됨결과: 이후 상태 조회는
TERMINATED - 20ms 획득 시도
- 원문에서 일어난 일: main은 종료한 소유자의 락을 얻지 못함결과:
wrong-auto-release=false
락 누수는 남지만 시간 제한 시도 뒤 프로그램은 정상 종료합니다. 20ms는 요청한 대기 시간입니다.
내장 기능 모니터는 synchronized 블록을 예외로 나가도 JVM이 반납하지만 명시적 Lock은 개발자 책임입니다.
소유자 사망을 감지해 다른 스레드가 unlock할 수도 없습니다.
소유하지 않은 호출은 IllegalMonitorStateException을 냅니다.
락·try·finally 구조
try를 락 호출보다 먼저 열면 락 획득 중 예외가 난 경로에서도 finally가 unlock을 호출할 수 있습니다.
기본 형태는 lock.lock(); try { critical(); } finally { lock.unlock(); }입니다.
성공 여부를 반환하는 tryLock은 boolean true일 때만 이 블록에 들어갑니다.
return이 try 안에 있어도 finally는 실행됩니다.
입력 검사 실패가 임계 영역 중간에 있더라도 unlock을 놓치지 않습니다.
Error를 포함한 비정상 흐름에서도 finally는 일반적으로 실행되지만 프로세스 강제 종료까지 회복하는 영속성 보장은 아닙니다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public final class LockedBankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
public LockedBankAccount(int initial) {
if (initial < 0) throw new IllegalArgumentException("initial");
balance = initial;
}
public boolean withdraw(int amount) {
lock.lock();
try {
if (amount <= 0) throw new IllegalArgumentException("amount");
if (balance < amount) return false;
balance -= amount;
return true;
} finally {
lock.unlock();
}
}
public int balance() {
lock.lock();
try {
return balance;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
LockedBankAccount account = new LockedBankAccount(1000);
boolean[] result = new boolean[2];
Thread first = new Thread(() -> result[0] = account.withdraw(800), "lock-a"),
second = new Thread(() -> result[1] = account.withdraw(800), "lock-b");
first.start();
second.start();
first.join();
second.join();
System.out.println(
"success="
+ ((result[0] ? 1 : 0) + (result[1] ? 1 : 0))
+ ", balance="
+ account.balance());
}
}잔액의 쓰기와 읽기가 같은 ReentrantLock을 사용하므로 별도 volatile을 추가하지 않습니다.
getter의 동일한 락 규칙
write만 락하고 read는 plain 필드로 두면 판독기가 stale 값나 복합 상태의 불일치를 볼 수 있습니다.
보호 필드에 대한 모든 가변 접근을 같은 락으로 감쌉니다.
스냅샷이 여러 필드를 묶으면 한 획득에서 모두 복사합니다.
단순 int 게터 한 줄도 락 오버헤드보다 정확성이 우선입니다.
read 비율이 매우 높고 경합 증거가 있을 때 ReadWriteLock, 불변 스냅샷, optimistic scheme을 측정해 검토합니다.
미리 복잡도를 늘리지 않습니다.
락 필드를 인터페이스 타입으로 선언하면 구현 교체가 쉽지만 공정성·큐 진단을 써야 하면 ReentrantLock 타입이 필요합니다.
외부 호출자에게 락 객체를 반환하지 않습니다.
임계 영역 안에는 검증과 상태 커밋을 함께 배치
withdraw는 균형 확인과 차감을 분리하면 안 됩니다.
입력 형식 검증처럼 공유 상태와 무관한 계산은 락 밖에서 할 수 있지만 현재 잔액을 전제로 한 판단은 안에 둡니다.
DB나 네트워크 호출을 락 안에서 기다리면 다른 계정 연산이 모두 정지하므로 reservation이나 트랜잭션 구분점을 다시 설계합니다.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantLock;
public final class LockedInventory {
private final Map<String, Integer> stock = new LinkedHashMap<>();
private final ReentrantLock lock = new ReentrantLock();
public void put(String item, int quantity) {
if (quantity < 0) throw new IllegalArgumentException();
lock.lock();
try {
stock.put(item, quantity);
} finally {
lock.unlock();
}
}
public boolean reserve(String item, int quantity) {
if (quantity <= 0) throw new IllegalArgumentException();
lock.lock();
try {
int current = stock.getOrDefault(item, 0);
if (current < quantity) return false;
stock.put(item, current - quantity);
return true;
} finally {
lock.unlock();
}
}
public Map<String, Integer> snapshot() {
lock.lock();
try {
return Map.copyOf(stock);
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
LockedInventory inventory = new LockedInventory();
inventory.put("book", 3);
Thread a = new Thread(() -> System.out.println("a=" + inventory.reserve("book", 2))),
b = new Thread(() -> System.out.println("b=" + inventory.reserve("book", 2)));
a.start();
b.start();
a.join();
b.join();
System.out.println(inventory.snapshot());
}
}LockedBankAccount와 LockedInventory의 실제 메서드만 비교합니다. 성공 주체와 재고 출력 순서는 고정하지 않습니다.
| 대상과 메서드 | 해당 객체의 락이 보호하는 범위 | 두 작업자 종료 뒤의 결과 |
|---|---|---|
계좌 · withdraw(800) | 잔액 확인과 800 차감을 한 획득에서 처리 | 두 요청 중 성공은 1회 |
계좌 · balance() | 출금과 같은 락으로 잔액 읽기 | balance=200 |
재고 · reserve("book", 2) | 현재 수량 확인과 차감을 한 획득에서 처리 | 두 요청 중 하나만 true |
재고 · snapshot() | 예약과 같은 락 안에서 Map.copyOf로 복사 | {book=1} · 가변 내부 Map을 노출하지 않음 |
- 계좌 ·
withdraw(800) - 해당 객체의 락이 보호하는 범위: 잔액 확인과 800 차감을 한 획득에서 처리두 작업자 종료 뒤의 결과: 두 요청 중 성공은
1회 - 계좌 ·
balance() - 해당 객체의 락이 보호하는 범위: 출금과 같은 락으로 잔액 읽기두 작업자 종료 뒤의 결과:
balance=200 - 재고 ·
reserve("book", 2) - 해당 객체의 락이 보호하는 범위: 현재 수량 확인과 차감을 한 획득에서 처리두 작업자 종료 뒤의 결과: 두 요청 중 하나만
true - 재고 ·
snapshot() - 해당 객체의 락이 보호하는 범위: 예약과 같은 락 안에서
Map.copyOf로 복사두 작업자 종료 뒤의 결과:{book=1}· 가변 내부 Map을 노출하지 않음
계좌와 재고는 각 객체의 private final lock을 씁니다. 재고의 println은 reserve가 락을 해제한 뒤 실행되므로, 먼저 출력한 스레드가 성공자라는 뜻은 아닙니다.
unlock 실패를 다른 예외로 가리지 않는 이유
finally에서 unlock할 때 현재 스레드가 소유자가 아니면 IllegalMonitorStateException이 발생해 원래 업무 예외를 가릴 수 있습니다.
구조를 단순하게 유지해 획득과 해제를 같은 어휘적 메서드에 둡니다.
락을 호출자에게 넘겨 다른 스레드가 해제하도록 설계하지 않습니다.
여러 락을 중첩하면 각 락은 잘 해제돼도 획득 순서가 반대여서 교착 상태가 생길 수 있습니다.
전역 순서를 정하거나 tryLock 시간 제한으로 롤백하는 전략이 필요합니다.
한 문서의 bank 계정은 락 하나로 불변식을 보호해 이 문제를 피합니다.
두 락 구현의 비교 기준
기능 결과, 임계 영역 범위, 락 식별 정보가 같아야 성능을 비교할 수 있습니다.
ReentrantLock으로 바꿨다는 이유만으로 빨라진다고 가정하지 않습니다.
JVM synchronized 최적화도 발전하므로 작업 부하 벤치마크가 기준입니다.
시간 제한이나 중단 가능 wait가 필요하지 않으면 synchronized가 자동 해제와 간단한 문법에서 유리합니다.
명시적 Lock을 선택했다면 static analysis와 코드 검토에서 finally 패턴을 필수로 확인합니다.
연습 문제
초기 좌석 5개에서 두 스레드가 각각 4개를 예약하도록 구현하세요.
정확히 한 요청만 성공하고 1개가 남아야 하며 잘못된 수량도 락 누수 없이 예외를 던져야 합니다.
정답과 return 경로
부족한 경우 try 안에서 false를 반환하지만 finally가 실행됩니다.
남은 좌석을 읽는 remaining()도 같은 락을 사용합니다.
import java.util.concurrent.locks.ReentrantLock;
public final class LockedSeatReservationSolution {
private static final class Seats {
private int remaining;
private final ReentrantLock lock = new ReentrantLock();
Seats(int count) {
remaining = count;
}
boolean reserve(int count) {
lock.lock();
try {
if (count <= 0) throw new IllegalArgumentException("count");
if (remaining < count) return false;
remaining -= count;
return true;
} finally {
lock.unlock();
}
}
int remaining() {
lock.lock();
try {
return remaining;
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
Seats seats = new Seats(5);
boolean[] ok = new boolean[2];
Thread a = new Thread(() -> ok[0] = seats.reserve(4)),
b = new Thread(() -> ok[1] = seats.reserve(4));
a.start();
b.start();
a.join();
b.join();
System.out.println(
"success="
+ ((ok[0] ? 1 : 0) + (ok[1] ? 1 : 0))
+ ", remaining="
+ seats.remaining());
}
}결과는 success=1, remaining=1입니다. 제시한 main은 양수 예약만 호출하므로 잘못된 수량의 예외 경로는 실행하지 않습니다.
어느 index가 true인지는 공정성 규칙이 없으므로 검사하지 않습니다.
ReentrantLock 활용의 핵심은 기능이 아니라 구조입니다.
private final 식별 정보, 획득 직후 try-finally, 보호 필드의 모든 접근, 최소이면서 완전한 불변식 범위를 지켜야 명시적 락의 제어력이 안전하게 작동합니다.