ReentrantLock 사용법과 try-finally
ReentrantLock을 얻은 직후 try-finally로 풀어야 하는 이유와, 잔액 검사·차감·조회를 모두 같은 락으로 보호하는 방법을 계좌와 재고 예제로 배웁니다.
synchronized 블록은 빠져나가는 순간 락이 풀립니다. 블록 끝까지 실행했든, 중간에 return했든, 예외가 났든 JVM이 모니터를 돌려줍니다. 앞 글에서 본 ReentrantLock은 다릅니다. 락을 얻는 lock()과 돌려주는 unlock()이 평범한 메서드 호출이라서, 코드가 unlock()까지 가지 못하면 락은 잡힌 채로 남습니다. 그 뒤로 이 락을 lock()으로 얻으려는 스레드는 모두 끝없이 기다리게 됩니다.
이 글에서는 먼저 unlock()을 빠뜨렸을 때 실제로 어떤 일이 생기는지 실행해 보고, 어떤 경로로 나가든 락을 돌려주는 lock(), try, finally의 배치를 익힙니다. 그다음 계좌와 재고 예제로, 공유 상태를 검사하고 바꾸고 읽는 코드가 모두 같은 락을 써야 하는 이유를 살펴봅니다. 마지막으로 synchronized와 ReentrantLock 가운데 무엇을 고를지 정리합니다.
소유 스레드가 끝나도 락은 풀리지 않는다
락을 가진 스레드가 끝나면 그 락도 저절로 풀리리라 생각하기 쉽습니다. 아래 프로그램으로 확인해 보겠습니다. owner 스레드는 락을 얻고 한 줄을 출력한 뒤 unlock() 없이 끝납니다. main은 join()으로 owner가 끝나기를 기다린 다음, tryLock(20, TimeUnit.MILLISECONDS)로 최대 20밀리초 동안 락을 얻어 봅니다. 출력 이름 wrong-auto-release는 '소유자가 끝나면 자동으로 풀린다'는 잘못된 가정을 가리키며, 그 가정이 맞다면 true가 나와야 합니다.
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();
}
}main은 owner가 끝난 뒤에야 락을 시도하므로, 이 프로그램의 출력은 스레드 실행 순서와 상관없이 언제나 다음과 같습니다. owner는 unlock()을 부르지 않으므로, 락이 풀린다면 그것은 스레드가 끝날 때 무언가가 대신 풀어 준 경우뿐입니다.
owner-acquired
owner=TERMINATED, wrong-auto-release=false첫 줄은 owner가 락을 얻은 직후에 출력한 것입니다. 둘째 줄을 보면 owner는 이미 실행을 마친 TERMINATED 상태인데도, main은 20밀리초를 기다린 끝에 락을 얻지 못했습니다. ReentrantLock은 내부에 '지금 락을 소유한 스레드'와 '그 스레드가 얻은 횟수'를 기록해 두고, 이 횟수는 unlock()이 호출될 때만 줄어듭니다. 스레드가 끝났다고 이 기록을 지우는 코드는 어디에도 없으므로, 락은 이미 끝난 스레드의 소유로 남습니다.
다른 스레드가 대신 풀어 줄 수도 없습니다. ReentrantLock의 unlock()은 호출한 스레드가 소유자일 때만 동작하고, 그렇지 않으면 IllegalMonitorStateException을 던집니다. 실제로 이 상태에서 main이 unlock()을 호출하면 예외가 나고 락은 여전히 잠겨 있습니다. 결국 이렇게 새어 나간 락은 되찾을 방법이 없고, 그 락으로 보호하던 객체는 더 쓸 수 없게 됩니다. 예제가 lock() 대신 시간 제한이 있는 tryLock을 쓴 것도 이 때문인데, lock()을 썼다면 main이 영원히 기다려 프로그램이 끝나지 않습니다.
소유자 종료와 명시적 락 해제는 별개다
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에서는 이런 일이 생기지 않습니다. 모니터는 synchronized 블록이나 메서드를 벗어날 때 해제되는데, 스레드가 끝나려면 실행 중이던 블록을 모두 벗어나야 하기 때문입니다. 정상으로 끝나든 예외로 끝나든 해제는 JVM이 맡습니다. ReentrantLock은 이 일을 프로그래머에게 넘기는 대신 시간 제한이나 중단 가능한 대기 같은 기능을 얻었습니다. 그러니 ReentrantLock을 쓰는 코드는 어떤 경로로 나가든 unlock()이 실행되는 구조를 갖춰야 합니다.
lock() 바로 다음 줄에서 try를 연다
어떤 경로로 나가든 코드를 실행하게 해 주는 도구는 finally입니다. try 블록이 끝까지 실행되든, 중간에 return하든, 예외가 나든 finally 블록은 실행됩니다. 그래서 Lock 인터페이스 문서는 다음 모양을 기본 사용법으로 제시합니다.
lock.lock();
try {
// 락으로 보호하는 상태에 접근한다
} finally {
lock.unlock();
}finally는 OutOfMemoryError 같은 Error가 던져졌을 때도 실행됩니다. 다만 System.exit()가 호출되거나 프로세스가 강제로 종료되면 finally도 실행되지 않습니다. 이때는 락도 프로세스와 함께 사라지니 누수는 문제가 되지 않지만, 락 안에서 파일이나 데이터베이스에 반쯤 써 둔 내용은 그대로 남습니다. 락은 실행 중인 스레드끼리 순서를 정해 줄 뿐, 끊긴 작업을 되돌려 주지는 않습니다.
이 모양에서 중요한 것은 try가 시작되는 위치입니다. lock()이 정상적으로 반환됐다는 것은 락을 얻었다는 뜻이므로, 바로 다음 줄에서 try를 열면 '락을 가진 동안 실행되는 코드'와 'finally가 지켜 주는 코드'가 정확히 일치합니다. try를 한 줄만 옮겨도 이 일치가 깨집니다. 자주 저지르는 두 가지 실수를 차례로 보겠습니다.
try를 lock()보다 먼저 열 때
lock()을 try 안에서 호출하면, 락을 얻기 전에 예외가 난 경우에도 finally가 실행됩니다. 그러면 finally의 unlock()은 얻지도 않은 락을 풀려고 합니다. 다음 코드는 출금액을 검사하다가 예외를 던지는데, 그 검사가 try 안이면서 lock()보다 앞에 있습니다.
import java.util.concurrent.locks.ReentrantLock;
public final class TryBeforeLockBug {
private final ReentrantLock lock = new ReentrantLock();
private int balance = 1000;
void withdraw(int amount) {
try {
if (amount <= 0) throw new IllegalArgumentException("amount=" + amount);
lock.lock();
balance -= amount;
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
TryBeforeLockBug account = new TryBeforeLockBug();
try {
account.withdraw(-5);
} catch (RuntimeException e) {
System.out.println("caught: " + e);
}
}
}caught: java.lang.IllegalMonitorStateExceptionwithdraw(-5)가 던진 것은 원래 IllegalArgumentException이었습니다. 그런데 이 예외가 try 블록을 빠져나가면서 finally가 실행되었고, 소유하지 않은 락을 풀려던 unlock()이 IllegalMonitorStateException을 던졌습니다. finally에서 새 예외가 나면 원래 예외는 버려지고 새 예외가 대신 전달되므로, 호출자는 '출금액이 잘못되었다'는 진짜 원인을 알 수 없게 됩니다. 획득 도중 예외를 던질 수 있는 lockInterruptibly()를 try 안에서 호출할 때도 같은 일이 생기는데, 이 경우는 다음 글에서 다시 봅니다.
lock()과 try 사이에 코드를 둘 때
반대로 lock()과 try 사이에 다른 코드를 끼워 넣으면, 그 코드에서 예외가 났을 때 finally에 도달하지 못합니다. 락은 얻었는데 풀어 줄 코드가 실행되지 않으니 앞 절에서 본 누수가 그대로 생깁니다. 다음 예제는 수량을 검사하는 줄이 lock()과 try 사이에 있습니다. 잘못된 수량으로 한 번 호출해 예외를 일으킨 다음, 같은 스레드와 다른 스레드에서 각각 락을 써 봅니다.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public final class LeakBetweenLockAndTry {
private final ReentrantLock lock = new ReentrantLock();
private int remaining = 5;
boolean reserve(int count) {
lock.lock();
if (count <= 0) throw new IllegalArgumentException("count"); // lock()과 try 사이
try {
if (remaining < count) return false;
remaining -= count;
return true;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
LeakBetweenLockAndTry seats = new LeakBetweenLockAndTry();
try {
seats.reserve(0);
} catch (IllegalArgumentException e) {
System.out.println("caught, hold=" + seats.lock.getHoldCount());
}
System.out.println(
"same-thread reserve(4)=" + seats.reserve(4) + ", hold=" + seats.lock.getHoldCount());
boolean[] acquired = new boolean[1];
Thread other =
new Thread(
() -> {
try {
acquired[0] = seats.lock.tryLock(100, TimeUnit.MILLISECONDS);
if (acquired[0]) seats.lock.unlock();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
},
"other");
other.start();
other.join();
System.out.println("other-thread acquired=" + acquired[0]);
}
}caught, hold=1
same-thread reserve(4)=true, hold=1
other-thread acquired=falsegetHoldCount()는 현재 스레드가 얻고 아직 풀지 않은 횟수를 돌려줍니다. 첫 줄의 hold=1은 예외가 난 뒤에도 main이 락을 한 번 쥔 채로 남아 있다는 뜻입니다. 셋째 줄에서 다른 스레드는 100밀리초 동안 시도하고도 락을 얻지 못합니다.
이 실수를 찾기 어렵게 만드는 것은 둘째 줄입니다. ReentrantLock은 재진입을 허용하므로, 같은 스레드의 reserve(4)는 아무 문제 없이 락을 한 번 더 얻고(획득 횟수 2) finally에서 한 번 풀어 다시 1로 돌아갑니다. 결과도 true이니 같은 스레드에서만 테스트하면 누수가 드러나지 않습니다. 고치는 방법은 두 가지입니다. 이 검사처럼 공유 상태와 상관없는 입력 검사라면 lock()보다 앞으로 옮기고, 공유 상태를 봐야 하는 검사라면 try 안으로 옮깁니다.
tryLock의 결과로 분기할 때
tryLock()은 락을 얻었는지를 boolean으로 돌려주므로, true일 때만 try와 finally에 들어가야 합니다. false인데 finally에서 unlock()하면 앞의 첫 번째 실수와 같은 예외가 납니다. 획득 실패를 다루는 방법은 tryLock과 시간 제한 락 획득에서 자세히 보고, 여기서는 모양만 기억해 둡니다.
if (lock.tryLock()) {
try {
// 락을 얻었을 때만 실행한다
} finally {
lock.unlock();
}
} else {
// 락을 얻지 못했을 때의 처리
}획득과 해제는 한 메서드 안에 둔다
unlock()은 락을 얻은 스레드가, 락을 얻은 그 메서드 안에서 호출하는 것이 원칙입니다. 락을 얻는 메서드와 푸는 메서드를 나누거나 락을 호출자에게 넘기면, 획득과 해제의 짝이 맞는지 코드만 보고 확인할 수 없습니다. 게다가 다른 스레드가 풀어야 하는 구조라면 그 스레드는 소유자가 아니므로 IllegalMonitorStateException이 납니다. 이 예외가 finally에서 나면 앞에서 본 것처럼 원래 예외까지 가립니다.
잔액 검사·차감·조회를 같은 락으로 묶기
가시성과 임계 영역을 다룬 글에서는 잔액이 1000인 계좌에서 두 스레드가 800씩 출금하는 문제를 synchronized로 해결했습니다. 같은 문제를 ReentrantLock으로 풀어 보겠습니다. withdraw()는 잔액 검사와 차감을 한 번의 획득 안에서 하고, balance()도 같은 락을 얻은 상태에서 잔액을 읽습니다. 출금액 검사는 try 안에 있으므로 예외가 나도 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());
}
}success=1, balance=200두 스레드 가운데 어느 쪽이 먼저 락을 얻을지는 실행할 때마다 다를 수 있습니다. 그래도 출력은 언제나 이 한 줄입니다. 먼저 얻은 쪽이 누구든 아래 순서로 진행되기 때문입니다.
| 순서 | 락을 가진 스레드 | 검사 balance < amount | 반환값 | 잔액 |
|---|---|---|---|---|
| 1 | 먼저 얻은 스레드 | 1000 < 800, 거짓 | true | 200 |
| 2 | 나중에 얻은 스레드 | 200 < 800, 참 | false | 200 |
나중에 얻은 스레드는 첫 번째 스레드가 unlock()한 뒤에야 들어오므로, 차감이 끝난 잔액 200을 보고 거절합니다. 검사와 차감이 한 번의 획득 안에 묶여 있는 한, 두 스레드가 모두 1000을 보고 둘 다 통과하는 일은 일어나지 않습니다.
balance에 volatile을 붙이지 않았는데도 나중 스레드가 200을 정확히 본다는 점도 짚어 둘 만합니다. Lock 인터페이스 문서는 모든 Lock 구현이 synchronized의 모니터와 같은 메모리 동기화 효과를 내야 한다고 정합니다. 그래서 한 스레드가 unlock() 전에 쓴 값은, 그 뒤에 같은 락을 lock()으로 얻은 스레드에게 보입니다. 앞에서 배운 happens-before 관계가 ReentrantLock에도 그대로 적용되는 것입니다.
더 깊이: 성공한 획득과 해제만 가시성을 만든다
Java 언어 명세(JLS 17.4.5)는 모니터에 대해 '해제는 같은 모니터의 이후 획득보다 먼저 일어난다(happens-before)'고 정합니다. Lock 문서는 성공한 lock()이 모니터 획득과, 성공한 unlock()이 모니터 해제와 같은 메모리 효과를 가져야 한다고 정해서 이 규칙을 Lock으로 넓힙니다. 이 규칙은 어느 스레드가 먼저 락을 얻을지는 정하지 않습니다. 먼저 얻은 쪽이 해제 전까지 쓴 값을 나중에 얻은 쪽이 본다는 것만 정합니다.
같은 문서는 실패한 획득과 해제, 그리고 재진입 획득과 해제에는 이런 메모리 효과가 필요 없다고 덧붙입니다. 재진입은 이미 락을 가진 스레드가 다시 얻는 것이라 새로 보여 줄 것이 없으니 당연합니다. 실무에서 기억할 것은 앞쪽입니다. tryLock()이 false를 돌려준 스레드는 다른 스레드의 쓰기를 볼 수 있다는 약속을 받지 못했으므로, 보호하는 필드를 읽어서는 안 됩니다.
읽기만 하는 메서드도 락을 써야 하는 이유
balance()는 값을 읽기만 하니 락 없이 return balance;만 해도 될 것 같습니다. 하지만 쓰기만 락으로 보호하고 읽기는 락 없이 하면, 그 읽기와 쓰기 사이에는 happens-before 관계가 없습니다. 이런 상태를 데이터 경합(data race)이라고 하며, 자바 메모리 모델은 이때 읽는 스레드가 이미 바뀐 값 대신 예전 값을 보는 것을 허용합니다. int 하나를 읽는 동작 자체가 쪼개지지는 않지만, 언제 최신 값을 보게 될지는 정해져 있지 않습니다. volatile이 아닌 long과 double은 한 번의 쓰기가 32비트씩 두 번으로 나뉠 수 있어서, 반만 바뀐 값이 보이는 것까지 허용됩니다(JLS 17.7).
상태가 필드 여러 개로 이루어지면 문제가 더 커집니다. 예를 들어 잔액과 거래 횟수를 함께 기록하는 계좌에서 락 없이 두 필드를 읽으면, 어느 순간에도 실제로 존재한 적이 없는 조합을 볼 수 있습니다. 그래서 규칙은 단순합니다. 락으로 보호하기로 한 필드는 읽기와 쓰기 모두 같은 락을 얻은 상태에서 접근하고, 여러 필드를 함께 돌려줘야 한다면 한 번의 획득 안에서 모두 복사합니다.
경합이 없을 때 락을 얻고 푸는 비용은 작으므로, 한 줄짜리 getter라도 먼저 정확하게 만듭니다. 읽기가 압도적으로 많고 측정해 보니 락 경합이 실제 병목이라면, 그때 ReentrantReadWriteLock, volatile 필드로 공개하는 불변 스냅샷, StampedLock의 낙관적 읽기 같은 방법을 비교합니다. 측정 없이 처음부터 이런 구조를 고르면 코드만 복잡해집니다.
한 번 안전하게 읽은 값은 다음 호출까지 유지되지 않는다
락을 써서 안전하게 읽었다고 해서 그 값이 다음 호출 때까지 그대로인 것은 아닙니다. 다음 코드는 잔액을 확인한 뒤 출금합니다.
if (account.balance() >= 800) { // 여기서 1000을 읽었더라도
account.withdraw(800); // 그사이 다른 스레드가 출금했다면 false
}balance()와 withdraw()는 각각 락을 얻었다가 풀기 때문에 두 호출 사이에는 락이 없습니다. 그 틈에 다른 스레드가 먼저 800을 출금할 수 있습니다. LockedBankAccount가 잔액 검사를 withdraw() 안에 두고 성공 여부를 반환값으로 알려 주는 것은 이 때문입니다. 호출자는 미리 읽은 잔액이 아니라 withdraw()의 반환값으로 성공을 판단해야 합니다. 같은 이유로, 두 계좌 사이의 이체처럼 여러 객체를 함께 바꿔야 하는 작업은 객체마다 락이 하나씩 있는 것만으로는 완성되지 않습니다. 이 문제는 뒤에서 교착 상태와 함께 다시 봅니다.
락 필드는 private final로 둔다
락을 담는 필드는 private final로 선언합니다. private이어야 바깥 코드가 이 락을 얻어 쥐고 있으면서 우리 메서드를 예상하지 못한 순간에 기다리게 만드는 일을 막을 수 있습니다. final이어야 락 객체가 중간에 바뀌어 스레드마다 서로 다른 락을 얻는 일을 막을 수 있습니다. 같은 이유로 락 객체를 돌려주는 메서드도 만들지 않습니다.
필드 타입은 LockedBankAccount처럼 Lock 인터페이스로 선언해도 되고, 다음 절의 LockedInventory처럼 ReentrantLock으로 선언해도 됩니다. 인터페이스 타입으로 두면 나중에 다른 구현으로 바꾸기 쉽습니다. 반면 isFair()나 getHoldCount()처럼 ReentrantLock에만 있는 메서드를 써야 한다면 구현 타입으로 선언해야 합니다.
임계 영역에 넣을 것과 뺄 것
임계 영역(critical section)은 한 번에 한 스레드만 실행해야 하는 코드 구간입니다. 어디까지를 락 안에 넣을지는 다음 기준으로 정합니다. 공유 상태의 현재 값을 보고 내리는 판단과, 그 판단에 따른 변경은 반드시 한 번의 획득 안에 함께 넣습니다. withdraw()의 잔액 검사와 차감을 서로 다른 획득으로 나누면, 그 사이에 다른 스레드가 끼어들어 두 스레드가 모두 1000을 보고 통과하는 일이 다시 생깁니다.
반대로 공유 상태와 상관없는 계산은 락 밖에 둘 수 있습니다. 수량이 음수인지 보는 입력 검사가 그런 예입니다. 데이터베이스 조회나 네트워크 호출처럼 오래 기다리는 작업은 락 안에 두지 않는 편이 좋습니다. 락을 쥔 채 기다리면 그동안 같은 객체의 다른 연산이 모두 멈추기 때문입니다. 외부 호출이 꼭 필요하다면 락 안에서 '처리 중' 상태를 기록하고, 락 밖에서 호출한 다음, 다시 락을 얻어 결과를 반영하는 식으로 단계를 나눕니다.
다음 재고 예제는 이 기준을 그대로 따릅니다. put()은 상품의 수량을 정하고, reserve()는 남은 수량을 확인한 뒤 차감하며, snapshot()은 현재 재고의 복사본을 돌려줍니다. 수량 검사는 공유 상태와 상관없으므로 lock()보다 앞에 있고, 나머지 판단과 변경은 모두 같은 락 안에 있습니다. main에서는 3권 있는 book을 두 스레드가 각각 2권씩 예약합니다.
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());
}
}한 번 실행한 결과는 예를 들어 다음과 같습니다. 두 스레드가 원하는 수량을 더하면 4권으로 재고 3권보다 많으므로, 예약은 둘 중 하나만 성공해야 합니다.
a=true
b=false
{book=1}첫 두 줄은 실행할 때마다 달라질 수 있습니다. 300번 실행해 보니 a와 b 중 어느 쪽이 true인지, 어느 줄이 먼저 나오는지에 따라 네 가지 조합이 모두 나왔습니다. 반면 둘 중 정확히 하나만 true라는 점과 마지막 줄 {book=1}은 한 번도 달라지지 않았습니다. 계좌 예제와 같은 이유입니다. 먼저 락을 얻은 쪽이 3권에서 2권을 빼 1권을 남기고, 나중에 얻은 쪽은 1 < 2를 보고 거절합니다.
출력 순서가 락을 얻은 순서와 다를 수 있다는 점도 눈여겨보세요. println()은 reserve()가 락을 푼 뒤에 실행되므로, 먼저 출력한 스레드가 먼저 락을 얻었다는 보장이 없습니다. 실제로 b=false가 a=true보다 먼저 나온 실행도 많았는데, a가 true를 받았다는 것은 a가 먼저 락을 얻었다는 뜻입니다. 동시성 프로그램에서 로그 순서만 보고 실행 순서를 짐작하면 안 되는 이유입니다.
검사·변경·조회가 각 객체의 같은 락을 사용한다
계좌·재고 각각 같은 락에서 첫 요청만 차감하고 뒤 요청을 거부합니다. 먼저 락을 획득한 스레드의 이름을 고정하지 않고 잔량·조건·변경을 추적할 수 있습니다.
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()이 내부의 stock을 그대로 돌려주지 않는 이유는, 내부 맵을 받은 호출자가 락 없이 읽거나 고칠 수 있기 때문입니다. 대신 Map.copyOf()로 락을 쥔 한 시점의 내용을 복사해 수정할 수 없는 맵으로 돌려줍니다. 그래서 복사본에 put()하면 UnsupportedOperationException이 나고, 그 뒤에 원래 재고가 바뀌어도 이미 받은 복사본에는 반영되지 않습니다.
다만 Map.copyOf()는 맵의 구조만 복사하고 키와 값 객체는 원래 맵과 함께 씁니다. 이 예제의 String과 Integer는 불변이라 문제가 없지만, 값이 가변 객체라면 복사본을 통해서도 그 객체의 내부가 바뀔 수 있습니다. 또 Map.copyOf()가 만든 맵은 순회 순서가 정해져 있지 않으므로 원래 맵의 삽입 순서를 기대하면 안 됩니다.
put("book", 7)은 기존 수량에 7을 더하지 않고 수량을 7로 바꿉니다. 그리고 이 예제는 상품 이름이 null이 아니라고 가정합니다. LinkedHashMap은 put(null, 3)을 받아 주지만, 그 뒤 snapshot()을 호출하면 Map.copyOf()가 null 키를 허용하지 않아 NullPointerException을 던집니다. 실제 API로 만든다면 put()에서 상품 이름이 null인지도 먼저 검사해야 합니다.
락을 두 개 이상 잡을 때 생기는 교착 상태
락이 둘 이상 필요해지면 try와 finally를 정확히 써도 막을 수 없는 문제가 생깁니다. 계좌 A에서 B로 이체하려면 두 잔액을 함께 바꿔야 하므로 두 계좌의 락을 모두 얻어야 합니다. 그런데 스레드 1이 A에서 B로, 스레드 2가 B에서 A로 동시에 이체하면서 각자 출금 계좌의 락부터 얻으면 다음과 같이 됩니다.
| 단계 | 스레드 1 (A에서 B로 이체) | 스레드 2 (B에서 A로 이체) |
|---|---|---|
| 1 | A의 락을 얻음 | B의 락을 얻음 |
| 2 | B의 락을 기다림 | A의 락을 기다림 |
두 스레드는 서로 상대가 쥔 락을 기다리므로 영원히 진행하지 못합니다. 이것을 교착 상태(deadlock)라고 합니다. 각 락의 try와 finally가 아무리 정확해도, 해제는 임계 영역을 마친 뒤에야 일어나므로 이 상황을 풀지 못합니다.
흔한 해결책은 두 가지입니다. 하나는 모든 스레드가 같은 순서로 락을 얻게 하는 것입니다. 예를 들어 계좌 번호가 작은 쪽의 락을 항상 먼저 얻기로 하면 두 스레드 모두 A부터 얻으려 하므로, 한쪽이 1단계에서 기다릴 뿐 서로 기다리는 고리가 생기지 않습니다. 다른 하나는 시간 제한이 있는 tryLock으로 두 번째 락을 시도하고, 얻지 못하면 이미 얻은 락을 풀었다가 처음부터 다시 시도하는 것입니다. 이 글의 계좌와 재고는 객체 하나의 상태를 락 하나로 보호하므로 이런 문제가 없지만, 락을 여러 개 쓰는 설계라면 획득 순서부터 정해 두어야 합니다.
synchronized와 ReentrantLock 중에서 고르기
두 방식의 차이를 표로 정리하면 다음과 같습니다.
| 비교 항목 | synchronized | ReentrantLock |
|---|---|---|
| 해제 | 블록을 벗어나면 자동 | finally에서 직접 unlock() |
| 락을 기다리다 포기하기 | 할 수 없음 | tryLock(timeout, unit), lockInterruptibly() |
| 획득 실패를 결과로 받기 | 할 수 없음 | tryLock() |
| 공정 모드 | 없음 | new ReentrantLock(true) |
| 조건별 대기 | 모니터마다 대기 집합 하나 (wait, notify) | newCondition()으로 여러 개 |
표의 아래쪽 네 줄이 필요하지 않다면 synchronized가 더 나은 선택입니다. 해제를 빠뜨릴 수 없고 코드도 짧습니다. 시간 제한이나 중단 가능한 대기가 실제로 필요할 때 ReentrantLock을 고르고, 고른 뒤에는 코드 리뷰나 정적 분석 도구로 lock() 다음 줄의 try와 finally를 빠짐없이 확인합니다. 중단 가능한 대기는 다음 글에서, 조건별 대기는 ReentrantLock과 Condition에서 다룹니다.
성능 때문에 ReentrantLock으로 바꾸는 것은 근거가 약합니다. 두 방식의 속도는 JVM 버전과 경합 정도에 따라 달라지고, synchronized도 JVM이 꾸준히 최적화해 왔습니다. 예를 들어 Java 21의 가상 스레드는 synchronized 안에서 기다리면 자신을 실행하던 플랫폼 스레드까지 함께 붙잡아 두는 고정(pinning) 문제가 있어서 ReentrantLock이 권장되곤 했는데, JDK 24의 JEP 491로 이런 고정이 대부분 사라졌습니다. 가상 스레드는 가상 스레드를 다루는 글에서 배웁니다. 정말 성능을 비교해야 한다면 기능, 임계 영역의 범위, 보호하는 대상이 똑같은 두 구현을 실제 작업 부하로 측정합니다.
정리
ReentrantLock은 소유 스레드가 끝나도 저절로 풀리지 않고, 소유자가 아닌 스레드는 대신 풀 수 없습니다. 해제는 전적으로 코드가 맡습니다.lock()바로 다음 줄에서try를 열고finally에서unlock()합니다.try를 먼저 열면 얻지 않은 락을 풀려다 원래 예외를 가리고, 사이에 코드를 두면 예외가 났을 때 락이 샙니다.- 재진입 때문에 같은 스레드에서는 락 누수가 드러나지 않습니다. 누수는 다른 스레드로 확인합니다.
- 공유 상태의 검사, 변경, 조회는 모두 같은
private final락으로 보호합니다. 성공한unlock()과 그 뒤의lock()사이에 happens-before 관계가 생기므로volatile이 따로 필요 없습니다. - 현재 값에 따른 판단과 변경은 한 번의 획득 안에 넣고, 오래 기다리는 외부 호출은 락 밖으로 뺍니다. 락을 여러 개 잡아야 한다면 획득 순서를 정합니다.
- 시간 제한이나 중단 가능한 대기가 필요 없다면
synchronized가 더 단순하고 안전합니다.
연습 문제
- 좌석 5개를 두 스레드가 각각 4개씩 예약하는 프로그램을
ReentrantLock으로 작성하세요. 정확히 한 요청만 성공해 좌석 1개가 남아야 하고, 0 이하의 수량은 락을 남기지 않고IllegalArgumentException을 던져야 합니다. 남은 좌석을 읽는 메서드도 같은 락을 쓰게 만드세요. - 새
LockedBankAccount(1000)에서 한 스레드가 차례로withdraw(0)(예외를 잡음),withdraw(800),withdraw(300),balance()를 호출합니다. 각 호출의 결과와 최종 잔액을 구하고,withdraw(0)의 예외가 잔액 부족으로 받는false와 어떻게 다른지 설명하세요. - 새
LockedInventory에put("book", 3)을 하고 복사본 A를 얻습니다. 이어서reserve("book", 2)를 두 번 호출하고put("book", 7)을 한 뒤 복사본 B를 얻습니다. 두 예약의 결과, A와 B의book값, A에put()을 호출했을 때의 결과를 답하세요. LeakBetweenLockAndTry의reserve()에서 수량 검사 줄을 옮겨 누수를 없애는 방법 두 가지를 쓰세요. 그리고 고쳤는지 확인할 때 같은 스레드에서 다시 호출해 보는 것만으로는 부족한 이유를 설명하세요.
해설
1번은 좌석 수와 락을 한 객체에 묶고, 예약과 조회 모두 lock() 바로 다음 줄에서 try를 엽니다. 수량 검사는 try 안에 있으므로 예외가 나도 finally가 락을 풉니다.
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먼저 락을 얻은 요청이 5에서 4를 빼 1을 남기고, 나중 요청은 1 < 4를 보고 false를 돌려받습니다. 남은 좌석이 부족할 때는 try 안에서 return false로 빠져나가지만 이때도 finally가 실행됩니다. ok[0]과 ok[1] 가운데 어느 쪽이 true인지는 실행마다 다를 수 있으므로 성공한 개수만 확인합니다.
이 main은 양수 수량만 예약하므로 예외 경로는 실행하지 않습니다. 예외 뒤에 락이 풀리는지 확인하려면 새 Seats(5)에서 main이 reserve(0)을 호출해 예외를 잡은 다음, 다른 스레드가 같은 객체에 reserve(4)를 호출하게 합니다. 그 스레드가 true를 받고 남은 좌석이 1이면 예외 뒤에도 락이 풀렸다는 뜻이며, 실제로 실행하면 그렇게 나옵니다. 반드시 다른 스레드를 써야 하는 이유는 4번에서 설명합니다.
2번에서 withdraw(0)은 IllegalArgumentException을 던지고 잔액을 바꾸지 않습니다. 이어서 withdraw(800)은 true(잔액 200), withdraw(300)은 200이 300보다 작으므로 false, balance()는 200입니다. 예외는 호출자가 애초에 잘못된 값을 넘겼다는 뜻이고, false는 올바른 요청이었지만 잔액이 모자랐다는 정상적인 결과입니다. 어느 경로로 끝나든 finally가 락을 풉니다.
3번에서 두 예약은 true, false입니다. A는 처음 복사한 {book=3}을 그대로 갖고, B는 {book=7}입니다. 두 번째 예약은 재고 1을 그대로 두고 실패하며, 마지막 put("book", 7)은 1에 7을 더하지 않고 7로 바꿉니다. A는 수정할 수 없는 맵이므로 put()을 호출하면 UnsupportedOperationException이 납니다.
4번의 검사는 공유 상태를 보지 않으므로 lock()보다 앞으로 옮기면 됩니다. 검사를 try 안으로 옮겨도 finally가 락을 풀어 주므로 누수가 사라집니다. 확인할 때 같은 스레드만 쓰면 안 되는 이유는 LeakBetweenLockAndTry의 둘째 출력 줄에 있습니다. 누수가 있어도 같은 스레드는 재진입으로 락을 다시 얻어 정상처럼 동작하므로, 그 테스트는 고치기 전에도 통과합니다. 예외가 난 뒤에 다른 스레드가 시간 제한을 두고 락을 얻어 보아야 누수가 있는지 구별할 수 있습니다.
다음 글
ReentrantLock을 고르는 대표적인 이유는 락을 기다리다가 포기할 수 있다는 점입니다. 다음 글에서는 lock()으로 기다리는 스레드가 interrupt()를 받아도 멈추지 않는다는 것을 확인하고, lockInterruptibly()로 락 대기를 취소하는 방법을 다룹니다.