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();
}
}소유자 종료와 명시적 락 해제는 별개다
owner 종료와 join 뒤에도 명시적 락은 점유되고 main timed tryLock은 실패합니다. 스레드 수명과 락 소유권의 다른 전환을 보여 줍니다.
LeakedReentrantLockBug에서 join이 소유자 종료를 확인해도 timed tryLock이 실패하는 이유를 추적합니다.
락 누수 · 종료와 해제
소유자 종료와 명시적 락 해제는 별개다
join은 소유자 종료를 확인합니다. unlock 없이 남긴 명시적 락은 다른 스레드가 얻을 수 없습니다.
실선: 원 실행·회수·시도의 관계종료한스레드의 명시적락은 남음프로그램은 이 시도 뒤 종료
실선: 원 실행·회수·시도의관계종료한스레드의 명시적락은남음프로그램은 이 시도 뒤 종료
소유자 실행
- 원문에서 일어난 일
- 획득 뒤 unlock 없이 종료
- 결과
owner-acquired출력
main의join()반환- 원문에서 일어난 일
- 소유자 종료 · 락은 계속 점유됨
- 결과
- 이후 상태 조회는
TERMINATED
20ms 획득 시도
- 원문에서 일어난 일
- main은 종료한 소유자의 락을 얻지 못함
- 결과
wrong-auto-release=false
락 누수는 남지만 시간 제한 시도 뒤 프로그램은 정상 종료합니다. 20ms는 요청한 대기 시간입니다.
이 예의 조건
- owner는 unlock 없이 종료하고 main은 락을 얻지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | owner는 unlock 없이 종료하고 join은 종료만 회수합니다. main의 20ms 획득 시도는 false이며 main에는 unlock 책임이 없고 프로그램은 유한하게 끝납니다. |
| 5 | unlock 없이 run 종료 |
| 9 | 종료만 회수 |
| 10 | 반납 아님 |
| 12 | 20ms 요청 |
| 13 | 결과 false |
| 17 | 종료한 스레드의 명시적 락은 남음 |
| 구분 | 내용 |
|---|---|
| 설명 | owner는 unlock 없이 종료하고 join은 종료만 회수합니다. main의 20ms 획득 시도는 false이며 main에는 unlock 책임이 없고 프로그램은 유한하게 끝납니다. |
| 전체 읽기 정보 | 소유자 종료와 명시적 락 해제는 별개다 소유자 종료와 명시적 락 해제는 별개다 LeakedReentrantLockBug에서 join이 소유자 종료를 확인해도 timed tryLock이 실패하는 이유를 추적합니다. 락 누수 · 종료와 해제 소유자 종료와 명시적 락 해제는 별개다 join은 소유자 종료를 확인합니다. unlock 없이 남긴 명시적 락은 다른 스레드가 얻을 수 없습니다. 소유자 실행 원문에서 일어난 일 획득 뒤 unlock 없이 종료 결과 owner-acquired 출력 main 의 join() 반환 원문에서 일어난 일 소유자 종료 · 락은 계속 점유됨 결과 이후 상태 조회는 TERMINATED 20ms 획득 시도 원문에서 일어난 일 main은 종료한 소유자의 락을 얻지 못함 결과 wrong-auto-release=false 락 누수는 남지만 시간 제한 시도 뒤 프로그램은 정상 종료합니다. 20ms는 요청한 대기 시간입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 소유자 획득 owner가 락을 얻고 owner-acquired를 출력합니다. |
|
|
| 종료 회수 owner 종료 후 main이 정상 join을 끝냅니다. |
|
|
| 유한 획득 시도 main의 timed tryLock(20ms)이 false입니다. |
|
|
소유자 종료와 명시적 락 해제는 별개다
owner 종료와 join 뒤에도 명시적 락은 점유되고 main timed tryLock은 실패합니다. 스레드 수명과 락 소유권의 다른 전환을 보여 줍니다.
LeakedReentrantLockBug에서 join이 소유자 종료를 확인해도 timed tryLock이 실패하는 이유를 추적합니다.
락 누수 · 종료와 해제
소유자 종료와 명시적 락 해제는 별개다
join은 소유자 종료를 확인합니다. unlock 없이 남긴 명시적 락은 다른 스레드가 얻을 수 없습니다.
실선: 원 실행·회수·시도의 관계종료한스레드의 명시적락은 남음프로그램은 이 시도 뒤 종료
실선: 원 실행·회수·시도의관계종료한스레드의 명시적락은남음프로그램은 이 시도 뒤 종료
소유자 실행
- 원문에서 일어난 일
- 획득 뒤 unlock 없이 종료
- 결과
owner-acquired출력
main의join()반환- 원문에서 일어난 일
- 소유자 종료 · 락은 계속 점유됨
- 결과
- 이후 상태 조회는
TERMINATED
20ms 획득 시도
- 원문에서 일어난 일
- main은 종료한 소유자의 락을 얻지 못함
- 결과
wrong-auto-release=false
락 누수는 남지만 시간 제한 시도 뒤 프로그램은 정상 종료합니다. 20ms는 요청한 대기 시간입니다.
이 예의 조건
- owner는 unlock 없이 종료하고 main은 락을 얻지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | owner는 unlock 없이 종료하고 join은 종료만 회수합니다. main의 20ms 획득 시도는 false이며 main에는 unlock 책임이 없고 프로그램은 유한하게 끝납니다. |
| 5 | unlock 없이 run 종료 |
| 9 | 종료만 회수 |
| 10 | 반납 아님 |
| 12 | 20ms 요청 |
| 13 | 결과 false |
| 17 | 종료한 스레드의 명시적 락은 남음 |
| 구분 | 내용 |
|---|---|
| 설명 | owner는 unlock 없이 종료하고 join은 종료만 회수합니다. main의 20ms 획득 시도는 false이며 main에는 unlock 책임이 없고 프로그램은 유한하게 끝납니다. |
| 전체 읽기 정보 | 소유자 종료와 명시적 락 해제는 별개다 소유자 종료와 명시적 락 해제는 별개다 LeakedReentrantLockBug에서 join이 소유자 종료를 확인해도 timed tryLock이 실패하는 이유를 추적합니다. 락 누수 · 종료와 해제 소유자 종료와 명시적 락 해제는 별개다 join은 소유자 종료를 확인합니다. unlock 없이 남긴 명시적 락은 다른 스레드가 얻을 수 없습니다. 소유자 실행 원문에서 일어난 일 획득 뒤 unlock 없이 종료 결과 owner-acquired 출력 main 의 join() 반환 원문에서 일어난 일 소유자 종료 · 락은 계속 점유됨 결과 이후 상태 조회는 TERMINATED 20ms 획득 시도 원문에서 일어난 일 main은 종료한 소유자의 락을 얻지 못함 결과 wrong-auto-release=false 락 누수는 남지만 시간 제한 시도 뒤 프로그램은 정상 종료합니다. 20ms는 요청한 대기 시간입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 소유자 획득 owner가 락을 얻고 owner-acquired를 출력합니다. |
|
|
| 종료 회수 owner 종료 후 main이 정상 join을 끝냅니다. |
|
|
| 유한 획득 시도 main의 timed tryLock(20ms)이 false입니다. |
|
|
내장 모니터는 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을 추가하지 않습니다.
초기 1000에서 먼저 획득한 요청이 800을 빼면 200이 남습니다. 다음 요청은 같은 락 안에서 200<800을 확인해 false를 반환하므로 음수가 되지 않습니다. 어느 스레드가 먼저 획득하는지 정하지 않아도 최종 success=1, balance=200은 설명할 수 있습니다. 재고 3에서 2개씩 예약하는 예제도 같은 이유로 한 요청만 성공하며, println은 해제 뒤 실행돼 로그 순서가 획득 순서를 대신하지 않습니다.
getter의 동일한 락 규칙
쓰기에만 락을 사용하고 동기화 없이 필드를 읽으면 읽는 스레드가 오래된 값이나 서로 맞지 않는 복합 상태를 볼 수 있습니다.
보호 필드에 대한 모든 가변 접근을 같은 락으로 감쌉니다.
스냅샷이 여러 필드를 묶으면 한 획득에서 모두 복사합니다.
한 번의 안전한 조회가 다음 호출까지 상태를 고정하지는 않습니다. 호출자가 balance()에서 1000을 읽은 뒤 withdraw(800)을 부르기 전에 다른 요청이 먼저 출금할 수 있습니다. 출금 메서드가 락 안에서 현재 잔액을 다시 검사하고 그 반환값으로 성공을 결정해야 합니다. 두 계좌 사이의 이체처럼 여러 객체의 변경을 묶어야 하는 계약은 이 예제의 객체별 락 하나만으로 완성되지 않습니다.
단순 int 게터 한 줄도 락 오버헤드보다 정확성이 우선입니다.
read 비율이 매우 높고 경합 증거가 있을 때 ReadWriteLock, 불변 스냅샷, 낙관적 접근 방식을 측정해 검토합니다.
미리 복잡도를 늘리지 않습니다.
락 필드를 인터페이스 타입으로 선언하면 구현 교체가 쉽지만 공정성·큐 진단을 써야 하면 ReentrantLock 타입이 필요합니다.
외부 호출자에게 락 객체를 반환하지 않습니다.
임계 영역 안에는 검증과 상태 커밋을 함께 배치
withdraw는 잔액 확인과 차감을 분리하면 안 됩니다.
입력 형식 검증처럼 공유 상태와 무관한 계산은 락 밖에서 할 수 있지만 현재 잔액을 전제로 한 판단은 안에 둡니다.
DB나 네트워크 호출을 락 안에서 기다리면 다른 계정 연산이 모두 정지하므로 예약 상태나 트랜잭션 경계를 다시 설계합니다.
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의 실제 메서드만 비교합니다. 성공 주체와 재고 출력 순서는 고정하지 않습니다.
불변식 · 검사 / 변경 / 조회
검사·변경·조회가 각 객체의 같은 락을 사용한다
계좌와 재고는 각 객체의 private final 락을 사용합니다. 상태 판단·차감·조회가 같은 락 경계에 들어갑니다.
계좌 · withdraw(800)
- 해당 객체의 락이 보호하는 범위
- 잔액 확인과 800 차감을 한 획득에서 처리
- 두 작업자 종료 뒤의 결과
- 두 요청 중 성공은
1회
계좌 · balance()
- 해당 객체의 락이 보호하는 범위
- 출금과 같은 락으로 잔액 읽기
- 두 작업자 종료 뒤의 결과
balance=200
재고 · reserve("book", 2)
- 해당 객체의 락이 보호하는 범위
- 현재 수량 확인과 차감을 한 획득에서 처리
- 두 작업자 종료 뒤의 결과
- 두 요청 중 하나만
true
재고 · snapshot()
- 해당 객체의 락이 보호하는 범위
- 예약과 같은 락 안에서
Map.copyOf로 복사 - 두 작업자 종료 뒤의 결과
{book=1}· 가변 내부 Map을 노출하지 않음
계좌와 재고는 각 객체의 private final lock을 씁니다. 재고의 println은 reserve가 락을 해제한 뒤 실행되므로, 먼저 출력한 스레드가 성공자라는 뜻은 아닙니다.
이 예의 조건
- 먼저 획득한 스레드 이름과 해제 뒤 로그 순서는 가변입니다. 두 객체는 별도입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 계좌 1000에서 800 두 요청/재고 3에서 2 두 요청은 각 객체의 동일 락 아래 검사·차감으로 성공 하나와 200/1을 남깁니다. 먼저 락을 획득한 스레드의 이름·로그 순서는 고정하지 않습니다. |
| 4 | 초기 잔액 1000 |
| 5 | 두 요청이 각 800 출금 |
| 7 | 확인·차감 |
| 9 | 잔액 200으로 변경 |
| 11 | 200<800 |
| 12 | false |
| 13 | 최종 success 1·balance 200 |
| 16 | 두 요청이 각 2개 예약 |
| 18 | 확인·차감 |
| 20 | 재고 1로 변경 |
| 22 | 1<2 |
| 23 | false |
| 24 | 같은 락의 snapshot은 book 1 |
| 구분 | 내용 |
|---|---|
| 설명 | 계좌 1000에서 800 두 요청/재고 3에서 2 두 요청은 각 객체의 동일 락 아래 검사·차감으로 성공 하나와 200/1을 남깁니다. 먼저 락을 획득한 스레드의 이름·로그 순서는 고정하지 않습니다. |
| 전체 읽기 정보 | 검사·변경·조회가 각 객체의 같은 락을 사용한다 검사·변경·조회가 각 객체의 같은 락을 사용한다 LockedBankAccount와 LockedInventory의 실제 메서드만 비교합니다. 성공 주체와 재고 출력 순서는 고정하지 않습니다. 불변식 · 검사 / 변경 / 조회 검사·변경·조회가 각 객체의 같은 락을 사용한다 계좌와 재고는 각 객체의 private final 락을 사용합니다. 상태 판단·차감·조회가 같은 락 경계에 들어갑니다. 계좌 · withdraw(800) 해당 객체의 락이 보호하는 범위 잔액 확인과 800 차감을 한 획득에서 처리 두 작업자 종료 뒤의 결과 두 요청 중 성공은 1 회 계좌 · balance() 해당 객체의 락이 보호하는 범위 출금과 같은 락으로 잔액 읽기 두 작업자 종료 뒤의 결과 balance=200 재고 · reserve("book", 2) 해당 객체의 락이 보호하는 범위 현재 수량 확인과 차감을 한 획득에서 처리 두 작업자 종료 뒤의 결과 두 요청 중 하나만 true 재고 · snapshot() 해당 객체의 락이 보호하는 범위 예약과 같은 락 안에서 Map.copyOf 로 복사 두 작업자 종료 뒤의 결과 {book=1} · 가변 내부 Map을 노출하지 않음 계좌와 재고는 각 객체의 private final lock을 씁니다. 재고의 println은 reserve가 락을 해제한 뒤 실행되므로, 먼저 출력한 스레드가 성공자라는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 초기 상태 계좌와 재고에 같은 크기의 요청 둘이 들어옵니다. |
|
|
| 첫 성공 먼저 락을 획득한 스레드가 요청 조건을 확인하고 차감합니다. |
|
|
| 두 번째 거절 뒤 요청은 남은 값이 부족해 실패합니다. |
|
|
검사·변경·조회가 각 객체의 같은 락을 사용한다
계좌·재고 각각 같은 락에서 첫 요청만 차감하고 뒤 요청을 거부합니다. 먼저 락을 획득한 스레드의 이름을 고정하지 않고 잔량·조건·변경을 추적할 수 있습니다.
LockedBankAccount와 LockedInventory의 실제 메서드만 비교합니다. 성공 주체와 재고 출력 순서는 고정하지 않습니다.
불변식 · 검사 / 변경 / 조회
검사·변경·조회가 각 객체의 같은 락을 사용한다
계좌와 재고는 각 객체의 private final 락을 사용합니다. 상태 판단·차감·조회가 같은 락 경계에 들어갑니다.
계좌 · withdraw(800)
- 해당 객체의 락이 보호하는 범위
- 잔액 확인과 800 차감을 한 획득에서 처리
- 두 작업자 종료 뒤의 결과
- 두 요청 중 성공은
1회
계좌 · balance()
- 해당 객체의 락이 보호하는 범위
- 출금과 같은 락으로 잔액 읽기
- 두 작업자 종료 뒤의 결과
balance=200
재고 · reserve("book", 2)
- 해당 객체의 락이 보호하는 범위
- 현재 수량 확인과 차감을 한 획득에서 처리
- 두 작업자 종료 뒤의 결과
- 두 요청 중 하나만
true
재고 · snapshot()
- 해당 객체의 락이 보호하는 범위
- 예약과 같은 락 안에서
Map.copyOf로 복사 - 두 작업자 종료 뒤의 결과
{book=1}· 가변 내부 Map을 노출하지 않음
계좌와 재고는 각 객체의 private final lock을 씁니다. 재고의 println은 reserve가 락을 해제한 뒤 실행되므로, 먼저 출력한 스레드가 성공자라는 뜻은 아닙니다.
이 예의 조건
- 먼저 획득한 스레드 이름과 해제 뒤 로그 순서는 가변입니다. 두 객체는 별도입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 계좌 1000에서 800 두 요청/재고 3에서 2 두 요청은 각 객체의 동일 락 아래 검사·차감으로 성공 하나와 200/1을 남깁니다. 먼저 락을 획득한 스레드의 이름·로그 순서는 고정하지 않습니다. |
| 4 | 초기 잔액 1000 |
| 5 | 두 요청이 각 800 출금 |
| 7 | 확인·차감 |
| 9 | 잔액 200으로 변경 |
| 11 | 200<800 |
| 12 | false |
| 13 | 최종 success 1·balance 200 |
| 16 | 두 요청이 각 2개 예약 |
| 18 | 확인·차감 |
| 20 | 재고 1로 변경 |
| 22 | 1<2 |
| 23 | false |
| 24 | 같은 락의 snapshot은 book 1 |
| 구분 | 내용 |
|---|---|
| 설명 | 계좌 1000에서 800 두 요청/재고 3에서 2 두 요청은 각 객체의 동일 락 아래 검사·차감으로 성공 하나와 200/1을 남깁니다. 먼저 락을 획득한 스레드의 이름·로그 순서는 고정하지 않습니다. |
| 전체 읽기 정보 | 검사·변경·조회가 각 객체의 같은 락을 사용한다 검사·변경·조회가 각 객체의 같은 락을 사용한다 LockedBankAccount와 LockedInventory의 실제 메서드만 비교합니다. 성공 주체와 재고 출력 순서는 고정하지 않습니다. 불변식 · 검사 / 변경 / 조회 검사·변경·조회가 각 객체의 같은 락을 사용한다 계좌와 재고는 각 객체의 private final 락을 사용합니다. 상태 판단·차감·조회가 같은 락 경계에 들어갑니다. 계좌 · withdraw(800) 해당 객체의 락이 보호하는 범위 잔액 확인과 800 차감을 한 획득에서 처리 두 작업자 종료 뒤의 결과 두 요청 중 성공은 1 회 계좌 · balance() 해당 객체의 락이 보호하는 범위 출금과 같은 락으로 잔액 읽기 두 작업자 종료 뒤의 결과 balance=200 재고 · reserve("book", 2) 해당 객체의 락이 보호하는 범위 현재 수량 확인과 차감을 한 획득에서 처리 두 작업자 종료 뒤의 결과 두 요청 중 하나만 true 재고 · snapshot() 해당 객체의 락이 보호하는 범위 예약과 같은 락 안에서 Map.copyOf 로 복사 두 작업자 종료 뒤의 결과 {book=1} · 가변 내부 Map을 노출하지 않음 계좌와 재고는 각 객체의 private final lock을 씁니다. 재고의 println은 reserve가 락을 해제한 뒤 실행되므로, 먼저 출력한 스레드가 성공자라는 뜻은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 초기 상태 계좌와 재고에 같은 크기의 요청 둘이 들어옵니다. |
|
|
| 첫 성공 먼저 락을 획득한 스레드가 요청 조건을 확인하고 차감합니다. |
|
|
| 두 번째 거절 뒤 요청은 남은 값이 부족해 실패합니다. |
|
|
복사본은 획득한 한 시점의 값입니다. snapshot()이 반환한 Map에 put하면 UnsupportedOperationException이 발생합니다. 이후 원 재고를 변경해도 이전 복사본에는 반영되지 않으며, 여기서는 키 String과 값 Integer도 불변이라 값 내부의 변경 문제가 없습니다. 일반 가변 값의 깊은 복사까지 Map.copyOf가 수행하는 것은 아닙니다. 복사본의 순회 순서는 보장되지 않습니다.
put("book", 7)은 기존 수량에 7을 더하는 보충이 아니라 수량을 7로 교체합니다. 이 예제의 상품 키는 null이 아닌 문자열로 가정합니다. 코드가 put(null, 3) 자체를 막지는 않지만, 그 상태에서 snapshot()의 Map.copyOf는 NullPointerException을 던집니다. 일반 API로 확장한다면 상품 키 검증도 명시해야 합니다.
unlock 실패를 다른 예외로 가리지 않는 이유
finally에서 unlock할 때 현재 스레드가 소유자가 아니면 IllegalMonitorStateException이 발생해 원래 업무 예외를 가릴 수 있습니다.
구조를 단순하게 유지해 획득과 해제를 같은 메서드 안에 둡니다.
락을 호출자에게 넘겨 다른 스레드가 해제하도록 설계하지 않습니다.
여러 락을 중첩하면 각 락은 잘 해제돼도 획득 순서가 반대여서 교착 상태가 생길 수 있습니다.
전역 순서를 정하거나 tryLock 시간 제한으로 롤백하는 전략이 필요합니다.
이 문서의 은행 계좌는 락 하나로 불변식을 보호해 이 문제를 피합니다.
두 락 구현의 비교 기준
기능 결과, 임계 영역 범위, 락 식별 정보가 같아야 성능을 비교할 수 있습니다.
ReentrantLock으로 바꿨다는 이유만으로 빨라진다고 가정하지 않습니다.
JVM synchronized 최적화도 발전하므로 작업 부하 벤치마크가 기준입니다.
시간 제한이나 중단 가능 wait가 필요하지 않으면 synchronized가 자동 해제와 간단한 문법에서 유리합니다.
명시적 Lock을 선택했다면 정적 분석과 코드 검토에서 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은 양수 예약만 호출하므로 잘못된 수량의 예외 경로는 실행하지 않습니다.
추가 검산은 새 Seats(5) 하나에서 시작하세요. main이 reserve(0)을 호출해 IllegalArgumentException을 잡고, 다른 작업자가 같은 객체에서 reserve(4)를 호출하게 합니다. 그 작업자가 종료한 뒤 성공 true와 remaining=1이면 예외 뒤에도 락이 반납된 경로를 확인합니다. 같은 main 스레드만 두 번 호출하면 누수가 있어도 재진입으로 진행할 수 있으므로 다른 스레드를 사용해야 합니다. 이 별도 입력 실험과 원 두 양수 요청의 결과를 한 실행으로 섞지 않습니다.
어느 index가 true인지는 공정성 규칙이 없으므로 검사하지 않습니다.
ReentrantLock 활용의 핵심은 기능이 아니라 구조입니다.
private final 식별 정보, 획득 직후 try-finally, 보호 필드의 모든 접근, 최소이면서 완전한 불변식 범위를 지켜야 명시적 락의 제어력이 안전하게 작동합니다.
실패 경로와 복사 시점을 확인하는 연습
각 상황은 새 객체에서 시작하고 명시한 순서대로 호출합니다. 추가 동시 요청은 없습니다.
LockedBankAccount(1000)에서withdraw(0)의 예외를 잡은 뒤withdraw(800),withdraw(300),balance()를 실행합니다. 두 반환값과 최종 잔액을 구하고 0 수량이 출금 실패false와 같은지 설명하세요.- 새
LockedInventory에put("book", 3)을 하고 복사본 A를 얻습니다. 이어reserve("book", 2)를 두 번 호출하고put("book", 7)후 복사본 B를 얻습니다. 두 예약 결과와 A·B의 book 값, A에put할 때의 결과를 답하세요. - 원 해답의
Seats(5)에서 현재 스레드가reserve(0)의 예외를 잡고, 새 작업자가 같은 객체의reserve(4)를 호출하게 합니다. 작업자 종료 뒤 기대할 반환값과 남은 좌석은 무엇이며 왜 같은 스레드의 후속 호출만으로는 락 누수 검사가 부족한가요?
정답과 상태 변경 경계
- 0은
IllegalArgumentException이고 잔액을 바꾸지 않습니다. 이후 800 출금은true, 300 출금은 잔액 200보다 커서false, 최종 잔액은 200입니다. 잘못된 입력의 예외와 정상 입력의 잔액 부족은 서로 다른 결과입니다. 모든 경로에서finally가 획득분을 해제합니다. - 예약은
true,false입니다. A는 처음 복사한 3을 유지하고 B는 교체된 7입니다. A의put은UnsupportedOperationException입니다. 두 번째 예약 실패는 재고 1을 그대로 두며, 마지막put은 1+7이 아니라 7을 저장합니다. - 새 작업자의 반환값은
true, 남은 좌석은 1입니다.reserve(0)의 예외 경로가finally로 락을 해제했으므로 다른 작업자가 획득할 수 있습니다. 같은 소유자만 다시 호출하면 누수가 있어도 재진입으로 진행할 수 있어 다른 스레드의 진입을 확인해야 합니다.