본문 바로가기

ReentrantLock의 재진입과 공정성

Lock 인터페이스가 synchronized와 무엇이 다른지, ReentrantLock이 같은 스레드의 중첩 획득을 어떻게 세는지, 공정 모드가 무엇을 바꾸고 어떤 비용을 치르는지 배웁니다.

synchronized는 블록에 들어갈 때 락을 얻고 블록을 벗어날 때 자동으로 놓습니다. 쓰기 쉽고 해제를 빠뜨릴 일도 없지만, 표현할 수 없는 것이 몇 가지 있습니다. 락을 얻을 때까지 끝없이 기다릴 뿐 일정 시간 뒤에 포기할 수 없고, 앞 글에서 본 것처럼 기다리는 스레드를 interrupt로 멈출 수도 없습니다. 지금 바로 얻을 수 있으면 얻고 아니면 다른 일을 하는 시도도 쓸 수 없습니다.

java.util.concurrent.locks 패키지의 Lock 인터페이스는 락을 얻고 놓는 일을 메서드 호출로 나누어 이런 선택지를 열어 줍니다. 대표 구현인 ReentrantLock은 여기에 공정성 설정과 상태 조회 메서드를 더합니다. 이 글에서는 Lock으로 락을 얻는 방법과 기본 사용 모양, 같은 스레드가 같은 락을 다시 얻을 때 일어나는 일, 공정 모드가 무엇을 바꾸고 어떤 비용을 치르는지를 차례로 봅니다. 기능이 많다고 해서 synchronized를 모두 바꿀 필요는 없다는 점도 함께 짚겠습니다.

락을 얻고 놓는 일을 메서드로 한다

synchronized는 락을 블록 구조로만 다룹니다. 여러 락을 얻었다면 얻은 순서의 반대로 놓아야 하고, 각 락은 얻은 블록 안에서 놓여야 합니다. Lock에는 이런 제한이 없어서, 같은 스레드 안이라면 한 메서드에서 얻은 락을 다른 메서드에서 놓거나 여러 락을 아무 순서로 놓을 수도 있습니다. 그 대가로 자동 해제가 없습니다. 그래서 Lock 코드는 거의 언제나 다음 모양으로 씁니다.

lock.lock();
try {
    // 이 락이 보호하는 상태를 읽고 쓴다
} finally {
    lock.unlock();
}

finally에서 해제하는 이유는 임계 영역에서 예외가 나거나 return으로 빠져나가도 락이 반드시 풀리게 하기 위해서입니다. ReentrantLock은 얻은 스레드가 unlock()을 호출하기 전까지 계속 잠겨 있으므로, 해제를 한 번 빠뜨리면 그 뒤로 이 락을 원하는 모든 스레드가 끝없이 기다립니다. 락을 쥔 스레드가 끝나도 락이 풀리지 않는다는 것은 다음 글에서 직접 확인합니다.

lock()을 try 안이 아니라 바로 앞에 두는 데도 이유가 있습니다. 획득 호출을 try 안에 넣으면 획득이 예외로 끝났을 때도 finally가 unlock()을 호출합니다. 락을 쥐지 않은 스레드의 unlock()은 IllegalMonitorStateException을 던지므로, 원래 예외는 이 예외에 가려져 사라집니다. 이 글을 쓰며 lockInterruptibly()를 try 안에 넣고 기다리는 중에 interrupt해 보니, 호출한 쪽에는 InterruptedException 대신 IllegalMonitorStateException이 전달되었습니다.

락을 얻는 네 가지 방법

Lock에는 락을 얻는 메서드가 네 가지 있습니다. 다른 스레드가 락을 쥐고 있을 때 얼마나 기다리는지, 기다리는 중에 interrupt를 받으면 어떻게 되는지가 서로 다릅니다.

획득 방법에 따라 대기와 중단 결과가 달라진다

ReentrantLock의 네 획득 API를 대기 조건과 반환 결과로 비교합니다. 특정 main의 출력이나 실제 대기 시간 표가 아닙니다.

획득 API · 대기와 반환

획득 방법에 따라 대기와 중단 결과가 달라진다

네 API는 기다림과 중단 처리 방식이 다릅니다. 획득하지 않은 시도에는 unlock 책임이 생기지 않습니다.

네 획득 정책의 요청과 반환 네영역은독립API계약입니다. 성공획득분만해제하고시간제한false·중단예외·즉시false를구별하며이전에보유한재진입책임은별도입니다. lock() 요청과 대기 다른소유자면 획득까지 interrupt로 포기 안 함 결과와 해제 획득 후 임계영역 그 획득분을 unlock lockInterruptibly 요청과 대기 다른소유자면 획득·중단 중단은 예외 / 플래그 소거 결과와 해제 획득한 경로만 unlock 기존획득분 책임은 별도 tryLock() 요청과 대기 기다리지 않고 한 번 시도 true 또는 false 결과와 해제 true인 경로만 unlock false는 업무 대체 경로 시간 제한 tryLock 요청과 대기 획득·기한·중단 중 결과 true / false / 예외 결과와 해제 true인 경로만 unlock false·예외는 미획득 실선: 각API의 요청→결과 관계 영역끼리 순차로 호출하는 그림 아님 실제업무성공·시간보장은 별도
네 획득 정책의 요청과 반환 네영역은독립API계약입니다. 성공획득분만해제하고시간제한false·중단예외·즉시false를구별하며이전에보유한재진입책임은별도입니다. lock() 요청과 대기 다른소유자면 획득까지 interrupt로 포기 안 함 결과와 해제 획득 후 임계영역 그 획득분을 unlock lockInterruptibly 요청과 대기 다른소유자면 획득·중단 중단은 예외 / 플래그 소거 결과와 해제 획득한 경로만 unlock 기존획득분 책임은 별도 tryLock() 요청과 대기 기다리지 않고 한 번 시도 true 또는 false 결과와 해제 true인 경로만 unlock false는 업무 대체 경로 시간 제한 tryLock 요청과 대기 획득·기한·중단 중 결과 true / false / 예외 결과와 해제 true인 경로만 unlock false·예외는 미획득 실선: 각API의 요청→결과 관계 영역끼리 순차로 호출하는 그림 아님 실제업무성공·시간보장은 별도
획득 방법에 따라 대기와 중단 결과가 달라진다
획득 API락을 바로 얻을 수 없을 때반환과 중단
lock()획득할 때까지 대기획득 후 반환 · interrupt만으로 포기하지 않음
lockInterruptibly()획득 또는 interrupt까지 대기획득 후 반환 / InterruptedException
tryLock()기다리지 않음즉시 true 또는 false
tryLock(time, unit)획득·시간 만료·interrupt까지 대기true / false / InterruptedException
lock()
락을 바로 얻을 수 없을 때
획득할 때까지 대기
반환과 중단
획득 후 반환 · interrupt만으로 포기하지 않음
lockInterruptibly()
락을 바로 얻을 수 없을 때
획득 또는 interrupt까지 대기
반환과 중단
획득 후 반환 / InterruptedException
tryLock()
락을 바로 얻을 수 없을 때
기다리지 않음
반환과 중단
즉시 true 또는 false
tryLock(time, unit)
락을 바로 얻을 수 없을 때
획득·시간 만료·interrupt까지 대기
반환과 중단
true / false / InterruptedException

처음 얻으려던 락을 획득하지 못한 예외 경로나 false 반환에는 unlock을 대응시키지 않습니다. 재진입 전에 이미 보유한 획득분의 해제 책임은 별도로 남습니다.

lock()은 얻을 때까지 기다리고 interrupt를 받아도 포기하지 않으므로 synchronized와 가장 비슷합니다. lockInterruptibly()도 시간 제한 없이 기다리지만, 기다리는 중에 interrupt를 받으면 InterruptedException을 던지고 빠져나옵니다. tryLock()은 기다리지 않고 한 번만 시도해서 결과를 true나 false로 돌려줍니다. tryLock(time, unit)은 주어진 시간까지만 기다리며, 얻으면 true, 시간이 다 되면 false를 돌려주고, 기다리는 중에 interrupt를 받으면 InterruptedException으로 끝납니다.

획득 방법이 여럿이 되면 지켜야 할 규칙이 하나 생깁니다. 실제로 얻은 것만 해제해야 한다는 것입니다. tryLock()이라면 true를 돌려받은 경우에만 try-finally로 들어가고, false라면 보호 대상에 손대지 않고 실패를 처리합니다.

if (lock.tryLock()) {
    try {
        // 락을 얻었을 때만 공유 상태를 바꾼다
    } finally {
        lock.unlock();
    }
} else {
    // 얻지 못했다: 나중에 다시 시도하거나 실패를 알린다
}

lockInterruptibly()나 시간 제한 tryLock이 InterruptedException으로 끝났을 때도 이번 호출로 얻은 것이 없으니 해제할 것도 없습니다. 다만 같은 스레드가 이 호출 전에 이미 같은 락을 쥐고 있었다면 그 몫은 그대로 남아 있고, 그것을 얻은 바깥 코드가 해제해야 합니다. 이 획득 횟수는 다음 절에서 자세히 봅니다. 두 방법을 언제 쓰는지는 lockInterruptibly를 다루는 글과 tryLock을 다루는 글에서 이어서 다룹니다.

시간 제한은 락을 얻기 위해 기다리는 시간에만 적용된다는 점도 알아 두세요. tryLock(100, TimeUnit.MILLISECONDS)가 80ms 만에 성공하고 그 뒤 작업이 300ms 걸린다면, 호출부터 작업 완료까지는 약 380ms가 걸립니다. tryLock은 획득한 뒤의 작업을 100ms에서 끊어 주지 않고, 스케줄링이 늦어지면 반환 자체가 100ms보다 늦을 수도 있습니다. 요청 전체에 기한이 있다면 획득 이후의 작업에도 따로 적용해야 합니다.

Lock에는 획득 메서드 말고도 newCondition()이 있습니다. 이 락에 묶인 조건 대기열을 만들어 Object.wait·notify와 비슷한 일을 하게 하는데, 한 락에 조건을 여러 개 둘 수 있다는 점이 다릅니다. 자세한 내용은 ReentrantLock과 Condition을 다루는 글에서 봅니다.

더 깊이: Lock이 주는 메모리 가시성

Lock은 상호 배제뿐 아니라 가시성도 synchronized와 똑같이 제공해야 합니다. Lock 문서는 모든 구현이 내장 모니터 락과 같은 메모리 동기화 효과를 내야 한다고 정하고, 성공한 lock은 모니터를 잠그는 동작과, 성공한 unlock은 모니터를 푸는 동작과 같은 효과를 낸다고 적고 있습니다. 그래서 JLS의 happens-before 규칙에 따라, 한 스레드가 unlock() 전에 쓴 값은 그 뒤에 같은 락을 얻은 스레드에 보입니다.

같은 문서는 실패한 획득과 해제, 재진입 획득과 해제에는 메모리 동기화 효과가 요구되지 않는다고도 적습니다. tryLock()이 false를 돌려준 스레드가 보호 대상을 읽으면 최신 값을 본다는 보장이 없다는 뜻입니다. 실패 경로에서 보호 대상에 손대지 말라는 규칙은 상호 배제뿐 아니라 가시성 면에서도 필요합니다.

같은 스레드가 다시 잠그면 획득 횟수가 늘어난다

ReentrantLock의 이름에 들어 있는 재진입(reentrant)은, 락을 쥔 스레드가 같은 락을 다시 얻으려 할 때 기다리지 않고 바로 얻는 성질을 말합니다. 이 성질이 없다면 락을 쥔 메서드가 같은 락을 쓰는 다른 메서드를 부르는 순간, 스레드는 자기가 쥔 락이 풀리기를 기다리며 영원히 멈춥니다. synchronized도 재진입을 허용합니다.

아래 예제에서 outer()는 락을 얻은 채 inner()를 부르고, inner()도 같은 락을 얻습니다. getHoldCount()는 현재 스레드가 이 락을 얻고 아직 해제하지 않은 횟수를, isHeldByCurrentThread()는 현재 스레드가 이 락을 쥐고 있는지를 돌려줍니다.

src/ReentrantHoldCount.java
import java.util.concurrent.locks.ReentrantLock;

public final class ReentrantHoldCount {
    private final ReentrantLock lock = new ReentrantLock();

    void outer() {
        lock.lock();
        try {
            System.out.println("outer-hold=" + lock.getHoldCount());
            inner();
        } finally {
            lock.unlock();
        }
    }

    void inner() {
        lock.lock();
        try {
            System.out.println(
                    "inner-hold="
                            + lock.getHoldCount()
                            + ", owned="
                            + lock.isHeldByCurrentThread());
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantHoldCount example = new ReentrantHoldCount();
        example.outer();
        System.out.println("locked-after=" + example.lock.isLocked());
    }
}
outer-hold=1
inner-hold=2, owned=true
locked-after=false

outer()가 처음 락을 얻으면 횟수가 1이 되어 첫 줄이 나옵니다. inner()의 lock()은 같은 스레드의 재진입이라 기다리지 않고 횟수만 2로 올리므로 두 번째 줄이 나옵니다. inner()의 finally가 한 번 해제하면 횟수는 1로 내려가는데, 이 시점에는 출력이 없습니다.

이때 락이 아직 풀리지 않았다는 점이 중요합니다. 바깥 획득이 남아 있으므로 다른 스레드는 여전히 들어올 수 없고, outer()의 finally가 마지막으로 해제해 횟수가 0이 되어야 비로소 락이 풀립니다. 그래서 main에서 읽은 isLocked()는 false입니다.

중첩 획득 두 번을 각각 해제해야 락 획득 횟수가 0이 된다

한 스레드의 재진입 락 획득 횟수 0→1→2→1→0과 finally 반납의 실제 순서입니다. 같은 락·소유자에서 획득분 균형을 따라가기 좋습니다.

중첩 획득 두 번을 각각 해제해야 보류 횟수가 0이 된다

ReentrantHoldCount의 0, 1, 2, 1, 0 보류 횟수와 실제 출력이 있는 지점을 구분합니다.

재진입 · 획득과 해제

중첩 획득 두 번을 각각 해제해야 보류 횟수가 0이 된다

같은 스레드가 두 번 얻은 락은 두 번 해제합니다. 보류 횟수 0→1→2→1→0을 코드 지점과 함께 읽습니다.

같은 스레드의 획득분을 반납원outer/inner/finally에따라보유횟수0→1→2→1→0입니다. 출력없는inner반납시점과출력지점세개를구별하고대기자수로읽지않습니다.
처음에는0현재 흐름의 획득 없음
연결 관계처음에는0 · 현재 흐름의 획득 없음 → outer가 획득 · outer-hold=1
outer가 획득outer-hold=1
연결 관계outer가 획득 · outer-hold=1 → inner가 재진입 · inner-hold=2 · owned=true
inner가 재진입inner-hold=2owned=true
연결 관계inner가 재진입 · inner-hold=2 · owned=true → inner의 finally · 한 번 반납 / 아직1 · 이 지점은 출력 없음
inner의 finally한 번 반납 / 아직1이 지점은 출력 없음
연결 관계inner의 finally · 한 번 반납 / 아직1 · 이 지점은 출력 없음 → outer의 finally · 남은1도 반납 / 이제0 · locked-after=false
outer의 finally남은1도 반납 / 이제0locked-after=false

실선: 같은 스레드의 중첩·복귀hold는 현재흐름의 보유 횟수대기자수·획득 순서의 증거 아님

같은 스레드의 획득분을 반납원outer/inner/finally에따라보유횟수0→1→2→1→0입니다. 출력없는inner반납시점과출력지점세개를구별하고대기자수로읽지않습니다.
처음에는0현재 흐름의 획득 없음
연결 관계처음에는0 · 현재 흐름의 획득 없음 → outer가 획득 · outer-hold=1
outer가 획득outer-hold=1
연결 관계outer가 획득 · outer-hold=1 → inner가 재진입 · inner-hold=2 · owned=true
inner가 재진입inner-hold=2owned=true
연결 관계inner가 재진입 · inner-hold=2 · owned=true → inner의 finally · 한 번 반납 / 아직1 · 이 지점은 출력 없음
inner의 finally한 번 반납 / 아직1이 지점은 출력 없음
연결 관계inner의 finally · 한 번 반납 / 아직1 · 이 지점은 출력 없음 → outer의 finally · 남은1도 반납 / 이제0 · locked-after=false
outer의 finally남은1도 반납 / 이제0locked-after=false

실선: 같은 스레드의 중첩·복귀hold는 현재흐름의 보유 횟수대기자수·획득 순서의 증거아님

  1. 획득 전

    현재 스레드의 보류 횟수
    0
    출력 또는 코드 근거
    새 락 · 아직 획득 호출 없음
  2. outer()의 획득 뒤

    현재 스레드의 보류 횟수
    1
    출력 또는 코드 근거
    outer-hold=1 출력
  3. inner()의 재진입 뒤

    현재 스레드의 보류 횟수
    2
    출력 또는 코드 근거
    inner-hold=2, owned=true 출력
  4. inner()의 해제 뒤

    현재 스레드의 보류 횟수
    1
    출력 또는 코드 근거
    안쪽 finally의 해제 1회 · 이 지점은 출력 없음
  5. outer()의 해제 뒤

    현재 스레드의 보류 횟수
    0
    출력 또는 코드 근거
    남은 획득분 해제 · 이후 locked-after=false 출력

이 예제는 한 스레드가 같은 락을 중첩 획득합니다. 다른 스레드의 대기 순서나 여러 락 사이의 교착 상태를 시험하지 않습니다.

이 예의 조건

  • hold는 현재 스레드 보유 횟수이며 대기자 수가 아닙니다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원래 outer/inner/finally에 따라 락 획득 횟수는 0→1→2→1→0입니다. 출력 없는 inner 반납 시점과 출력 지점 세 개를 구별하고 대기자 수로 읽지 않습니다.
3처음에는 0
11한 번 반납
12아직 1
15남은 1도 반납
16이제 0
19hold는 현재 흐름의 보유 횟수
20대기자 수·획득 순서의 증거 아님
원 도표의 설명과 주석
구분내용
설명원래 outer/inner/finally에 따라 락 획득 횟수는 0→1→2→1→0입니다. 출력 없는 inner 반납 시점과 출력 지점 세 개를 구별하고 대기자 수로 읽지 않습니다.
전체 읽기 정보중첩 획득 두 번을 각각 해제해야 락 획득 횟수가 0이 된다 중첩 획득 두 번을 각각 해제해야 락 획득 횟수가 0이 된다 ReentrantHoldCount의 0, 1, 2, 1, 0 락 획득 횟수와 실제 출력이 있는 지점을 구분합니다. 재진입 · 획득과 해제 중첩 획득 두 번을 각각 해제해야 락 획득 횟수가 0이 된다 같은 스레드가 두 번 얻은 락은 두 번 해제합니다. 락 획득 횟수 0→1→2→1→0을 코드 지점과 함께 읽습니다. 획득 전 현재 스레드의 락 획득 횟수 0 출력 또는 코드 근거 새 락 · 아직 획득 호출 없음 outer() 의 획득 뒤 현재 스레드의 락 획득 횟수 1 출력 또는 코드 근거 outer-hold=1 출력 inner() 의 재진입 뒤 현재 스레드의 락 획득 횟수 2 출력 또는 코드 근거 inner-hold=2, owned=true 출력 inner() 의 해제 뒤 현재 스레드의 락 획득 횟수 1 출력 또는 코드 근거 안쪽 finally의 해제 1회 · 이 지점은 출력 없음 outer() 의 해제 뒤 현재 스레드의 락 획득 횟수 0 출력 또는 코드 근거 남은 획득분 해제 · 이후 locked-after=false 출력 이 예제는 한 스레드가 같은 락을 중첩 획득합니다. 다른 스레드의 대기 순서나 여러 락 사이의 교착 상태를 시험하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 획득

outer가 처음 락을 얻습니다.

  • 이전 hold: 0
  • outer-hold: 1
  • 한 번의 획득이 해제 책임 하나를 만듭니다.
재진입

inner가 같은 스레드로 다시 획득합니다.

  • inner-hold: 2
  • owned: true
  • 소유 스레드 재진입이 보유 횟수를 증가시킵니다.
균형 반납

inner finally가 1로 낮추고 outer finally가 0으로 낮춥니다.

  • inner 반납: 1, 출력 없음
  • outer 반납 뒤: locked-after=false
  • 각 획득분을 각각 반납해야 락이 풀립니다.

중첩 획득 두 번을 각각 해제해야 락 획득 횟수가 0이 된다

한 스레드의 재진입 락 획득 횟수 0→1→2→1→0과 finally 반납의 실제 순서입니다. 같은 락·소유자에서 획득분 균형을 따라가기 좋습니다.

중첩 획득 두 번을 각각 해제해야 보류 횟수가 0이 된다

ReentrantHoldCount의 0, 1, 2, 1, 0 보류 횟수와 실제 출력이 있는 지점을 구분합니다.

재진입 · 획득과 해제

중첩 획득 두 번을 각각 해제해야 보류 횟수가 0이 된다

같은 스레드가 두 번 얻은 락은 두 번 해제합니다. 보류 횟수 0→1→2→1→0을 코드 지점과 함께 읽습니다.

같은 스레드의 획득분을 반납원outer/inner/finally에따라보유횟수0→1→2→1→0입니다. 출력없는inner반납시점과출력지점세개를구별하고대기자수로읽지않습니다.
처음에는0현재 흐름의 획득 없음
연결 관계처음에는0 · 현재 흐름의 획득 없음 → outer가 획득 · outer-hold=1
outer가 획득outer-hold=1
연결 관계outer가 획득 · outer-hold=1 → inner가 재진입 · inner-hold=2 · owned=true
inner가 재진입inner-hold=2owned=true
연결 관계inner가 재진입 · inner-hold=2 · owned=true → inner의 finally · 한 번 반납 / 아직1 · 이 지점은 출력 없음
inner의 finally한 번 반납 / 아직1이 지점은 출력 없음
연결 관계inner의 finally · 한 번 반납 / 아직1 · 이 지점은 출력 없음 → outer의 finally · 남은1도 반납 / 이제0 · locked-after=false
outer의 finally남은1도 반납 / 이제0locked-after=false

실선: 같은 스레드의 중첩·복귀hold는 현재흐름의 보유 횟수대기자수·획득 순서의 증거 아님

같은 스레드의 획득분을 반납원outer/inner/finally에따라보유횟수0→1→2→1→0입니다. 출력없는inner반납시점과출력지점세개를구별하고대기자수로읽지않습니다.
처음에는0현재 흐름의 획득 없음
연결 관계처음에는0 · 현재 흐름의 획득 없음 → outer가 획득 · outer-hold=1
outer가 획득outer-hold=1
연결 관계outer가 획득 · outer-hold=1 → inner가 재진입 · inner-hold=2 · owned=true
inner가 재진입inner-hold=2owned=true
연결 관계inner가 재진입 · inner-hold=2 · owned=true → inner의 finally · 한 번 반납 / 아직1 · 이 지점은 출력 없음
inner의 finally한 번 반납 / 아직1이 지점은 출력 없음
연결 관계inner의 finally · 한 번 반납 / 아직1 · 이 지점은 출력 없음 → outer의 finally · 남은1도 반납 / 이제0 · locked-after=false
outer의 finally남은1도 반납 / 이제0locked-after=false

실선: 같은 스레드의 중첩·복귀hold는 현재흐름의 보유 횟수대기자수·획득 순서의 증거아님

  1. 획득 전

    현재 스레드의 보류 횟수
    0
    출력 또는 코드 근거
    새 락 · 아직 획득 호출 없음
  2. outer()의 획득 뒤

    현재 스레드의 보류 횟수
    1
    출력 또는 코드 근거
    outer-hold=1 출력
  3. inner()의 재진입 뒤

    현재 스레드의 보류 횟수
    2
    출력 또는 코드 근거
    inner-hold=2, owned=true 출력
  4. inner()의 해제 뒤

    현재 스레드의 보류 횟수
    1
    출력 또는 코드 근거
    안쪽 finally의 해제 1회 · 이 지점은 출력 없음
  5. outer()의 해제 뒤

    현재 스레드의 보류 횟수
    0
    출력 또는 코드 근거
    남은 획득분 해제 · 이후 locked-after=false 출력

이 예제는 한 스레드가 같은 락을 중첩 획득합니다. 다른 스레드의 대기 순서나 여러 락 사이의 교착 상태를 시험하지 않습니다.

이 예의 조건

  • hold는 현재 스레드 보유 횟수이며 대기자 수가 아닙니다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원래 outer/inner/finally에 따라 락 획득 횟수는 0→1→2→1→0입니다. 출력 없는 inner 반납 시점과 출력 지점 세 개를 구별하고 대기자 수로 읽지 않습니다.
3처음에는 0
11한 번 반납
12아직 1
15남은 1도 반납
16이제 0
19hold는 현재 흐름의 보유 횟수
20대기자 수·획득 순서의 증거 아님
원 도표의 설명과 주석
구분내용
설명원래 outer/inner/finally에 따라 락 획득 횟수는 0→1→2→1→0입니다. 출력 없는 inner 반납 시점과 출력 지점 세 개를 구별하고 대기자 수로 읽지 않습니다.
전체 읽기 정보중첩 획득 두 번을 각각 해제해야 락 획득 횟수가 0이 된다 중첩 획득 두 번을 각각 해제해야 락 획득 횟수가 0이 된다 ReentrantHoldCount의 0, 1, 2, 1, 0 락 획득 횟수와 실제 출력이 있는 지점을 구분합니다. 재진입 · 획득과 해제 중첩 획득 두 번을 각각 해제해야 락 획득 횟수가 0이 된다 같은 스레드가 두 번 얻은 락은 두 번 해제합니다. 락 획득 횟수 0→1→2→1→0을 코드 지점과 함께 읽습니다. 획득 전 현재 스레드의 락 획득 횟수 0 출력 또는 코드 근거 새 락 · 아직 획득 호출 없음 outer() 의 획득 뒤 현재 스레드의 락 획득 횟수 1 출력 또는 코드 근거 outer-hold=1 출력 inner() 의 재진입 뒤 현재 스레드의 락 획득 횟수 2 출력 또는 코드 근거 inner-hold=2, owned=true 출력 inner() 의 해제 뒤 현재 스레드의 락 획득 횟수 1 출력 또는 코드 근거 안쪽 finally의 해제 1회 · 이 지점은 출력 없음 outer() 의 해제 뒤 현재 스레드의 락 획득 횟수 0 출력 또는 코드 근거 남은 획득분 해제 · 이후 locked-after=false 출력 이 예제는 한 스레드가 같은 락을 중첩 획득합니다. 다른 스레드의 대기 순서나 여러 락 사이의 교착 상태를 시험하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 획득

outer가 처음 락을 얻습니다.

  • 이전 hold: 0
  • outer-hold: 1
  • 한 번의 획득이 해제 책임 하나를 만듭니다.
재진입

inner가 같은 스레드로 다시 획득합니다.

  • inner-hold: 2
  • owned: true
  • 소유 스레드 재진입이 보유 횟수를 증가시킵니다.
균형 반납

inner finally가 1로 낮추고 outer finally가 0으로 낮춥니다.

  • inner 반납: 1, 출력 없음
  • outer 반납 뒤: locked-after=false
  • 각 획득분을 각각 반납해야 락이 풀립니다.

획득 횟수는 이 락을 기다리는 스레드 수가 아닙니다. 기다리는 스레드 수를 짐작하려면 getQueueLength()를 쓰는데, 문서에 따르면 이 값은 내부 구조를 훑는 동안에도 바뀔 수 있는 추정치입니다. getHoldCount(), isLocked(), getQueueLength() 같은 조회 메서드는 모니터링이나 테스트, 디버깅을 위한 것이고 동기화 제어에 쓰라고 만든 것이 아닙니다. 예를 들어 isLocked()가 false인 것을 확인하고 무언가를 하면, 확인과 행동 사이에 다른 스레드가 락을 얻을 수 있습니다. 락을 얻었는지는 tryLock()의 반환값처럼 획득 메서드가 알려 주는 결과와 코드 구조로 판단합니다.

기본 ReentrantLock은 공정하지 않다

ReentrantLock에는 대기 큐가 있으니 먼저 기다린 스레드가 먼저 얻을 것 같지만, 인자 없는 생성자로 만든 락은 그런 순서를 약속하지 않습니다. 아래 프로그램은 기본 락은 공정하다는 가정이 틀렸는지를 wrong-default-fair로 출력합니다.

lab/DefaultFairLockAssumption.java
import java.util.concurrent.locks.ReentrantLock;

public final class DefaultFairLockAssumption {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock();
        System.out.println("wrong-default-fair=" + !lock.isFair() + ", isFair=" + lock.isFair());
    }
}

실행 결과는 wrong-default-fair=true, isFair=false입니다. isFair()가 false이므로 그 부정인 wrong-default-fair는 true이고, 기본 락이 공정하다는 가정이 틀렸다는 뜻입니다. 공정 모드를 원하면 new ReentrantLock(true)처럼 생성자에 true를 넘겨야 하고, 이렇게 정한 공정성은 나중에 바꿀 수 없습니다.

문서가 말하는 공정성은 정확히 이렇습니다. 공정 모드에서는 경쟁이 있을 때 가장 오래 기다린 스레드에게 락을 주는 쪽을 택하고, 비공정 모드에서는 특정한 순서를 약속하지 않습니다. 하지만 락이 공정하다고 스레드 스케줄링까지 공정해지는 것은 아닙니다. 공정한 락을 쓰는 여러 스레드 가운데 하나가 연달아 여러 번 락을 얻는 동안, 다른 스레드들은 락을 쥐지도 진행하지도 못할 수 있습니다. 또 스레드가 대기 큐에 들어가는 순서 자체가 스케줄러에 따라 달라지므로, 스레드를 시작한 순서나 요청이 들어온 순서대로 처리된다는 뜻도 아닙니다.

공정 모드에는 예외가 하나 있습니다. 인자 없는 tryLock()은 공정성 설정을 따르지 않아서, 락이 비어 있기만 하면 다른 스레드가 기다리고 있어도 바로 얻습니다. 문서는 공정 순서를 지키면서 기다리지 않고 시도하려면 tryLock(0, TimeUnit.SECONDS)를 쓰라고 권합니다. 거의 같은 동작이지만 interrupt를 확인한다는 점이 다릅니다.

더 깊이: tryLock()이 대기자를 앞지르는 모습

이 예외는 실제로 확인할 수 있습니다. 아래 프로그램은 공정 락을 쥔 채 대기자 스레드 하나를 큐에 넣어 두고, 락을 놓자마자 main이 다시 시도합니다. main이 락을 얻었는데 대기자가 아직 큐에 있다면 대기자를 앞지른 것입니다. 이 시도를 tryLock()과 tryLock(0, TimeUnit.SECONDS)로 200번씩 반복하며, 대기자가 큐에 들어갔는지 확인하는 hasQueuedThread는 실험을 위해서만 쓰는 조회 메서드입니다.

src/FairTryLockBarging.java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public final class FairTryLockBarging {
    public static void main(String[] args) throws InterruptedException {
        int trials = 200;
        int untimed = 0;
        int timedZero = 0;
        for (int i = 0; i < trials; i++) {
            if (bargesAheadOfWaiter(false)) untimed++;
            if (bargesAheadOfWaiter(true)) timedZero++;
        }
        System.out.println("tryLock() barged=" + untimed + "/" + trials);
        System.out.println("tryLock(0, SECONDS) barged=" + timedZero + "/" + trials);
    }

    private static boolean bargesAheadOfWaiter(boolean timed) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock(true);
        lock.lock();
        Thread waiter =
                new Thread(
                        () -> {
                            lock.lock();
                            lock.unlock();
                        });
        waiter.start();
        while (!lock.hasQueuedThread(waiter)) {
            Thread.onSpinWait(); // 실험용: 대기자가 큐에 들어갈 때까지 기다린다
        }
        lock.unlock();
        boolean acquired = timed ? lock.tryLock(0, TimeUnit.SECONDS) : lock.tryLock();
        boolean barged = acquired && lock.hasQueuedThread(waiter);
        if (acquired) lock.unlock();
        waiter.join();
        return barged;
    }
}

한 번 실행한 결과입니다. 각 줄의 숫자는 200번 시도 가운데 main이 락을 얻었는데도 대기자가 아직 큐에 남아 있었던 횟수, 즉 대기자를 앞지른 횟수입니다. 두 메서드 모두 기다리지 않고 바로 시도하는데 결과가 같은지 보세요.

tryLock() barged=82/200
tryLock(0, SECONDS) barged=0/200

main이 락을 놓으면 공정 락은 큐 맨 앞의 대기자를 깨웁니다. 대기자가 깨어나 락을 가져가기 전에 main의 tryLock()이 먼저 실행되면, 큐를 무시하고 비어 있는 락을 가져갑니다. 이렇게 앞지른 횟수는 실행할 때마다 달라서, 10번 실행했을 때 200번 가운데 적게는 38번, 많게는 100번이었습니다. 반면 tryLock(0, TimeUnit.SECONDS)는 공정 락의 규칙대로 앞서 기다리는 스레드가 있으면 락을 얻지 않고 곧바로 false를 돌려주므로, 같은 10번의 실행에서 한 번도 앞지르지 않았습니다.

공정 모드가 바꾸는 것과 그 비용

unlock()은 락을 놓고, 대기 큐에서 기다리던 스레드가 있으면 하나를 깨웁니다. 깨어난 스레드는 다시 실행될 차례를 받은 뒤에야 락을 얻으려고 다시 시도합니다. 그런데 그 사이에 이미 실행 중이던 다른 스레드가 lock()을 호출하면 어떻게 될까요? 비공정 모드에서는 이 스레드가 큐를 무시하고 비어 있는 락을 바로 가져갈 수 있는데, 이것을 끼어들기(barging)라고 부릅니다.

끼어들기는 불공평해 보이지만 처리량에는 유리합니다. 끼어든 스레드는 잠들었다 깨어나는 과정 없이 바로 일을 하고, 깨어난 스레드는 다시 큐에서 기다리면 됩니다. 그 대신 운이 나쁜 스레드는 오래 기다릴 수 있습니다.

공정 모드에서는 새로 온 스레드도 앞서 기다리는 스레드가 있으면, 락이 비어 있더라도 큐 뒤에 섭니다. 그래서 락이 넘어갈 때마다 잠들어 있던 스레드가 깨어나 실행될 때까지 기다려야 하고, 그만큼 처리량이 떨어집니다. ReentrantLock 문서도 많은 스레드가 쓰는 공정한 락은 기본 설정보다 전체 처리량이 낮을 수 있고 흔히 훨씬 느리다고 적습니다. 대신 락을 얻기까지 걸리는 시간의 편차가 작고, 어떤 스레드가 끝내 락을 얻지 못하는 기아(starvation)가 생기지 않는다는 장점이 있습니다.

두 설정을 나란히 만들어 보면 다음과 같습니다. 정책 이름 throughput과 bounded-wait는 각 설정을 고르는 이유를 붙인 이름일 뿐입니다.

src/FairnessConfigurationReport.java
import java.util.concurrent.locks.ReentrantLock;

public final class FairnessConfigurationReport {
    public static void main(String[] args) {
        report("throughput", new ReentrantLock());
        report("bounded-wait", new ReentrantLock(true));
    }

    private static void report(String policy, ReentrantLock lock) {
        lock.lock();
        try {
            System.out.println(
                    policy + ": fair=" + lock.isFair() + ", queue=" + lock.getQueueLength());
        } finally {
            lock.unlock();
        }
    }
}
throughput: fair=false, queue=0
bounded-wait: fair=true, queue=0
두 정책 이름은 설정값을 보여 주며 성능을 측정하지 않는다

FairnessConfigurationReport의 두 생성자와 실제 설정 출력을 비교하되 정책 이름을 벤치마크 결과로 읽지 않습니다.

공정성 · 설정과 측정

두 정책 이름은 설정값을 보여 주며 성능을 측정하지 않는다

두 이름은 생성자 설정을 보여 줍니다. 요청자가 없는 이 출력으로 처리량이나 대기 상한을 판단하지 않습니다.

공정 생성자와 실제 설정 조회 throughput과bounded-wait는설정이름입니다. 두락에다른요청자가없어false/true및queue0을출력할뿐대기상한·FIFO·처리량측정이아닙니다. throughput 설정 이름 기본 생성자 new ReentrantLock() 원 설정 보고 fair=false / queue=0 bounded-wait 설정 이름 공정 생성자 new ReentrantLock(true) 원 설정 보고 fair=true / queue=0 실선: 선택한 생성자→설정 조회 대기자가 없는 두 독립 락 처리량·대기 상한·FIFO 측정 아님
공정 생성자와 실제 설정 조회 throughput과bounded-wait는설정이름입니다. 두락에다른요청자가없어false/true및queue0을출력할뿐대기상한·FIFO·처리량측정이아닙니다. throughput 설정 이름 기본 생성자 new ReentrantLock() 원 설정 보고 fair=false / queue=0 bounded-wait 설정 이름 공정 생성자 new ReentrantLock(true) 원 설정 보고 fair=true / queue=0 실선: 선택한 생성자→설정 조회 대기자가 없는 두 독립 락 처리량·대기 상한·FIFO 측정 아님

throughput

생성자
new ReentrantLock()
출력된 설정과 큐
fair=false · queue=0

bounded-wait

생성자
new ReentrantLock(true)
출력된 설정과 큐
fair=true · queue=0

두 락에는 다른 요청자가 없습니다. queue는 일반적으로 추정치이며, 이 출력은 처리량·대기 시간·FIFO 순서를 측정하지 않습니다.

이 출력은 설정값을 보여 줄 뿐 성능을 잰 것이 아닙니다. 각 락을 쓰는 스레드가 main 하나뿐이라 기다리는 스레드가 없으므로 queue=0이 나옵니다. bounded-wait라는 이름이 붙었다고 이 실행이 대기 시간의 상한을 보여 주는 것도 아닙니다.

처리량 차이를 직접 재 보기

비용을 눈으로 보려면 여러 스레드가 실제로 경쟁하게 해야 합니다. 아래 프로그램은 스레드 네 개가 0.3초 동안 같은 락을 얻고 놓기를 반복하게 하고, 모두 합쳐 몇 번 임계 영역에 들어갔는지 셉니다. 락만 비공정과 공정으로 바꿔 두 번 잽니다.

src/FairnessThroughput.java
import java.util.concurrent.locks.ReentrantLock;

public final class FairnessThroughput {
    public static void main(String[] args) throws InterruptedException {
        System.out.println("nonfair=" + run(new ReentrantLock()));
        System.out.println("fair=" + run(new ReentrantLock(true)));
    }

    private static long run(ReentrantLock lock) throws InterruptedException {
        long[] counts = new long[4];
        Thread[] workers = new Thread[counts.length];
        long end = System.nanoTime() + 300_000_000L; // 0.3초 동안 경쟁한다
        for (int t = 0; t < workers.length; t++) {
            int id = t;
            workers[t] = new Thread(() -> {
                while (System.nanoTime() - end < 0) {
                    lock.lock();
                    try {
                        counts[id]++;
                    } finally {
                        lock.unlock();
                    }
                }
            });
            workers[t].start();
        }
        long total = 0;
        for (int t = 0; t < workers.length; t++) {
            workers[t].join();
            total += counts[t];
        }
        return total;
    }
}

이 글을 쓴 2코어 가상 머신에서 한 번 실행한 결과입니다. 두 숫자는 네 스레드가 0.3초 동안 임계 영역에 들어간 횟수를 모두 더한 값이므로, 클수록 같은 시간에 더 많은 일을 처리한 것입니다.

nonfair=4578420
fair=668797

같은 측정을 90번 반복하니 80번은 비공정 락이 더 많이 들어갔습니다. 공정 락이 앞선 나머지 10번은 모두 다른 프로그램들이 함께 CPU를 많이 쓰던 시간대에 나왔고, 값 자체도 실행마다 크게 흔들려서 공정 락은 약 1만 7천 번에서 340만 번 사이를 오갔습니다. 문서가 말하는 경향은 보이지만, 이 정도 실험으로는 얼마나 느린지 말할 수 없고 조건이 달라지면 방향마저 뒤집힐 수 있다는 뜻입니다. 워밍업도 없이 짧게 잰 값이므로 차이의 방향을 보는 데만 쓰세요.

공정 모드의 장점인 대기 시간 편차는 이런 짧은 실행으로 확인하기가 더 어렵습니다. 같은 방식으로 한 번 획득할 때 기다린 최대 시간도 재 보았지만, 실행마다 어느 쪽이 작은지가 바뀌어 결론을 낼 수 없었습니다. 공정성이 필요한지 판단하려면 평균 처리량만 보지 말고, 같은 임계 영역과 같은 경합 수준에서 공정 설정만 바꿔 가며 획득 대기 시간의 분포를 충분히 모아야 합니다. 특히 최댓값이나 99번째 백분위수(p99) 같은 꼬리 지연과, 기아를 어디까지 허용할 수 있는지를 함께 봅니다. 짧은 실행에서 스레드 이름이 찍힌 순서는 스케줄러의 영향이 커서 공정성의 증거가 되지 못합니다.

사용자별로 요청 순서를 지켜야 하는 업무라면 락의 공정성만으로는 부족합니다. 공정성은 대기 큐에 들어간 순서를 존중할 뿐인데, 그 순서 자체가 스케줄링에 따라 달라지기 때문입니다. 이런 요구는 작업 큐에서 사용자 같은 키별로 순서를 지키도록 따로 구현해야 합니다.

ReentrantLock을 기다리는 스레드는 WAITING으로 보인다

앞 글에서 본 것처럼 synchronized 모니터에 들어가려고 기다리는 스레드는 BLOCKED입니다. 반면 ReentrantLock을 기다리는 스레드는 내부에서 LockSupport.park로 잠들기 때문에 WAITING으로 보이고, 시간 제한 tryLock으로 기다리는 중이면 TIMED_WAITING으로 보입니다. 아래는 모니터를 기다리는 스레드 monitor-waiter와 ReentrantLock을 기다리는 스레드 lock-waiter를 하나씩 만들어 두고, JDK 21의 jstack으로 찍은 스레드 덤프에서 두 스레드 부분만 옮긴 것입니다. 첫 줄의 일부와 호출 스택 줄은 생략했고, 주소 같은 값은 실행마다 다릅니다.

"monitor-waiter" ... waiting for monitor entry
   java.lang.Thread.State: BLOCKED (on object monitor)
	- waiting to lock <0x00000000a8519078> (a java.lang.Object)

"lock-waiter" ... waiting on condition
   java.lang.Thread.State: WAITING (parking)
	- parking to wait for  <0x00000000a8519160> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)

그래서 덤프에 BLOCKED 스레드가 없다고 해서 락 경합이 없다고 결론 내리면 안 됩니다. ReentrantLock 경합은 WAITING (parking) 상태와 parking to wait for 줄로 나타나고, 그 줄에 락의 내부 동기화 객체가 찍힙니다. 비공정 락이면 ReentrantLock$NonfairSync, 공정 락이면 ReentrantLock$FairSync입니다. 반대로 WAITING이라고 모두 락을 기다리는 것도 아니므로, 상태 이름만 보지 말고 무엇을 기다리는지를 함께 읽어야 합니다. 스레드 덤프를 읽는 방법은 BLOCKED·WAITING 상태를 다루는 글에서 자세히 봅니다.

Lock과 synchronized 중 무엇을 쓸까

짧고 단순한 임계 영역이고 시간 제한이나 취소가 필요 없다면 synchronized가 낫습니다. 블록을 벗어나면 자동으로 풀리므로 해제를 빠뜨릴 일이 없고 코드도 짧습니다. ReentrantLock은 synchronized로 표현할 수 없는 것이 필요할 때 고릅니다. 획득 실패를 업무 결과로 처리해야 할 때(tryLock), 락 대기를 시간이나 interrupt로 끝낼 수 있어야 할 때(tryLock(time, unit), lockInterruptibly()), 한 락에 조건을 여러 개 두어야 할 때(newCondition()), 락을 얻은 곳과 다른 범위에서 놓아야 할 때가 그렇습니다. 공정 모드는 실제로 기아를 막아야 한다는 요구가 있을 때만 켭니다.

어느 쪽을 고르든 같은 상태는 같은 락 객체로 보호해야 합니다. 아래 코드는 흔한 실수입니다.

void increment() {
    ReentrantLock lock = new ReentrantLock(true); // 호출할 때마다 새 락을 만든다
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

호출할 때마다 새 락을 만들기 때문에 스레드마다 서로 다른 락을 얻고, 그래서 서로를 전혀 막지 않습니다. 공정성 설정이 같아도 마찬가지입니다. 락은 필드에 하나만 두고 private final로 선언합니다. private이어야 바깥 코드가 이 락을 마음대로 잡아 클래스 안의 메서드를 예상치 못하게 기다리게 만들 수 없고, final이어야 도중에 다른 객체로 바뀌어 두 스레드가 서로 다른 락을 쥐는 일이 없습니다. 여러 필드가 함께 지켜야 하는 조건이 있다면 그 필드들은 모두 같은 락 아래에서 읽고 씁니다.

Lock 객체에 synchronized (lock)을 쓰는 것도 피해야 합니다. ReentrantLock도 평범한 객체라서 synchronized의 대상이 될 수는 있지만, 그것은 그 객체의 모니터를 얻는 것일 뿐 lock.lock()과는 아무 관계가 없습니다. 이 글을 쓰며 main이 lock()으로 락을 쥔 동안 다른 스레드가 같은 객체로 synchronized 블록에 들어가 보니, 막힘 없이 바로 들어갔습니다. Lock 문서도 혼란을 피하려면 구현 내부가 아닌 곳에서 Lock 객체를 이렇게 쓰지 말라고 권합니다.

더 깊이: 가상 스레드와 synchronized

JDK 23까지는 가상 스레드가 synchronized 블록 안에서 블로킹되면, 그 가상 스레드를 실행하던 플랫폼 스레드(캐리어)가 함께 묶여(pinning) 다른 가상 스레드를 실행하지 못했습니다. 그래서 가상 스레드를 쓰는 코드에서는 synchronized를 ReentrantLock으로 바꾸라는 조언이 많았습니다. JDK 24에 들어간 JEP 491은 이 제약을 대부분 없애, synchronized 안에서 블로킹된 가상 스레드도 캐리어를 내놓을 수 있게 했습니다.

JEP는 새 코드라면 쓸 수 있는 곳에서는 더 편하고 실수가 적은 synchronized를 쓰고, 더 많은 유연성이 필요할 때 ReentrantLock 같은 API를 쓰라고 권합니다. 이미 ReentrantLock으로 옮긴 코드를 되돌릴 필요는 없다고도 덧붙입니다. 다만 네이티브 메서드나 외부 함수 호출을 거쳐 다시 들어온 자바 코드 안에서 블로킹하는 경우처럼, 여전히 캐리어가 묶이는 경우가 몇 가지 남아 있습니다. 가상 스레드 자체는 가상 스레드를 다루는 글에서 봅니다.

정리

  • Lock은 획득과 해제를 메서드로 나눈 대신 자동 해제가 없습니다. lock() 바로 뒤에 try를 열고 finally에서 unlock()합니다.
  • lock(), lockInterruptibly(), tryLock(), tryLock(time, unit)은 기다리는 방식과 interrupt에 대한 반응이 다릅니다. 어느 방법이든 실제로 얻은 것만 해제합니다.
  • ReentrantLock은 같은 스레드의 재진입을 허용하고 획득 횟수를 셉니다. 얻은 횟수만큼 해제해야 락이 풀립니다.
  • 기본 락은 비공정입니다. 공정 모드는 끼어들기를 막아 대기 시간 편차를 줄이는 대신 처리량을 잃고, 인자 없는 tryLock()은 공정 모드에서도 끼어듭니다.
  • ReentrantLock을 기다리는 스레드는 BLOCKED가 아니라 WAITING으로 보입니다.
  • 단순한 임계 영역에는 synchronized로 충분하고, 시간 제한이나 취소, 한 번만 시도하기 같은 제어가 필요할 때 ReentrantLock을 고릅니다.

연습 문제

  1. 첫 번째 실행 인자가 fair이면 공정 락을, 그 밖에는 기본 비공정 락을 만드는 프로그램을 작성하세요. 락을 쥔 상태에서 공정성 설정과 현재 스레드의 획득 횟수를 출력하고, 락은 반드시 finally에서 해제한 뒤 마지막에 락이 풀렸는지도 출력합니다.
  2. 하나의 공정 ReentrantLock을 현재 스레드가 이미 한 번 쥐고 있습니다. 이 스레드가 tryLock()을 호출하고, 성공했다면 그 몫을 한 번 해제합니다. 반환값과 해제 뒤의 획득 횟수는 얼마이며, 이제 다른 스레드가 락을 얻을 수 있나요?
  3. 스레드 A가 락을 쥔 동안, 락을 쥐지 않은 스레드 B가 인자 없는 tryLock()을 호출합니다. B가 받는 반환값과 B의 획득 횟수를 구하고, B가 실패 경로에서 실수로 unlock()을 호출하면 어떻게 되는지 답하세요.
  4. 락은 비어 있고, 현재 스레드는 interrupt 플래그가 true인 상태입니다. 인자 없는 tryLock()과 tryLock(0, TimeUnit.MILLISECONDS)를 각각 따로 실행했을 때 락을 얻는지, 호출 뒤의 플래그는 어떤지, 해제해야 할 것이 있는지를 비교하세요.
1번 해설

실행 인자로 공정성을 고른 뒤, 락을 쥔 상태에서 설정과 획득 횟수를 출력하고 finally에서 해제합니다.

exercise/ConfiguredReentrantLockSolution.java
import java.util.concurrent.locks.ReentrantLock;

public final class ConfiguredReentrantLockSolution {
    public static void main(String[] args) {
        boolean fair = args.length > 0 && args[0].equalsIgnoreCase("fair");
        ReentrantLock lock = new ReentrantLock(fair);
        lock.lock();
        try {
            System.out.println("fair=" + lock.isFair() + ", hold=" + lock.getHoldCount());
        } finally {
            lock.unlock();
        }
        System.out.println("released=" + !lock.isLocked());
    }
}

인자 없이 실행하면 다음과 같이 출력됩니다.

fair=false, hold=1
released=true

fair를 인자로 주어 실행하면 첫 줄만 달라집니다.

fair=true, hold=1
released=true

두 실행 모두 락을 한 번 얻었으므로 hold=1이고, finally에서 해제한 뒤에는 락이 풀려 released=true입니다. 달라지는 것은 생성자에 넘긴 공정성 설정뿐입니다. equalsIgnoreCase로 비교하므로 FAIR처럼 대소문자를 바꿔 넣어도 공정 락이 됩니다.

이 프로그램에는 스레드가 하나뿐이라 기다리는 스레드가 없습니다. 그래서 fair=true가 출력되었다고 해서 대기 순서나 대기 시간에 대해 알 수 있는 것은 없습니다. 또 공정성은 생성자에서 정해지면 바꿀 수 없습니다. 실행 중에 공정성을 바꾸고 싶다고 락 필드를 새 객체로 바꾸면, 옛 락을 쥔 스레드와 새 락을 얻은 스레드가 동시에 임계 영역에 들어갈 수 있습니다. 꼭 바꿔야 한다면 그 락을 쓰는 구성 요소를 새로 만들고, 옛 락을 쓰는 스레드가 없을 때 넘어가야 합니다.

2번부터 4번까지 해설

2번은 재진입입니다. 현재 스레드가 이미 쥔 락이므로 tryLock()은 공정 모드와 상관없이 true를 돌려주고, 획득 횟수는 1에서 2가 됩니다. 한 번 해제하면 다시 1이 되는데, 바깥에서 얻은 몫이 남아 있으므로 락은 아직 잠겨 있고 다른 스레드는 얻을 수 없습니다. 바깥 코드도 해제해서 횟수가 0이 되어야 풀립니다.

3번에서 B는 기다리지 않고 곧바로 false를 받고, B의 획득 횟수는 0입니다. 이 상태에서 B가 unlock()을 호출하면 IllegalMonitorStateException이 던져집니다. A의 몫을 대신 풀어 주지도 않으므로 A는 여전히 락을 쥐고 있습니다. 그래서 실패 경로에서는 보호 대상에 손대지도, 해제를 시도하지도 않아야 합니다.

4번에서 인자 없는 tryLock()은 interrupt를 확인하지 않습니다. 락이 비어 있으므로 true를 돌려주며 락을 얻고, 플래그는 true 그대로 남습니다. 얻었으므로 한 번 해제해야 합니다.

반면 tryLock(0, TimeUnit.MILLISECONDS)는 들어가자마자 interrupt를 먼저 확인해서 InterruptedException을 던지고 플래그를 지웁니다. 얻은 것이 없으므로 해제할 것도 없습니다. 둘 다 기다리지 않는다는 점은 같지만 interrupt를 다루는 방식은 다릅니다. 이 글을 쓰며 세 문제의 상황을 그대로 실행해 이 결과들을 확인했습니다.

다음 글

다음 글에서는 ReentrantLock으로 계좌처럼 여러 단계를 거치는 상태 변경을 보호합니다. 락을 쥔 스레드가 해제 없이 끝나면 어떤 일이 생기는지 재현하고, 확인과 변경, 조회가 모두 같은 락을 써야 하는 이유를 봅니다.