본문 바로가기

잠든 스레드와 교착

synchronized 안의 sleep 재시도가 왜 진행 가능성을 파괴하는지 상태와 타임라인으로 밝히고 잠금 밖 재시도와 올바른 대기 조건을 구분합니다.

공유 큐를 보호하려고 synchronized를 붙인 다음, 가득 차면 빈자리가 생길 때까지 sleep으로 쉬면 될 것처럼 보입니다.

문제는 잠드는 위치입니다.

Thread.sleep은 실행만 잠시 멈출 뿐 현재 소유한 모니터를 반납하지 않습니다.

생산자가 큐 잠금을 쥔 채 잠들면 빈칸을 만들 소비자는 take에 들어갈 수 없고, 생산자는 소비를 기다리며 같은 조건을 영원히 다시 확인합니다.

이 현상은 서로 다른 두 락을 반대 순서로 획득하는 고전적 교착과 모양이 다릅니다.

단 하나의 락만 있어도 조건을 바꿀 주체를 임계 영역 밖에 세워 두면 진행이 멈춥니다.

따라서 안전성만 확인해서는 부족합니다.

용량 불변식이 지켜져도 어느 스레드도 작업을 완료하지 못한다면 버퍼는 사용할 수 없습니다.


빈칸을 만들 소비자가 입구에서 막히는 실험

다음 프로그램은 용량 1 큐에 첫 값을 넣은 뒤 두 번째 생산을 시작합니다.

두 번째 생산자는 모니터 안에서 잠들며 빈자리를 기다립니다.

이어서 소비자를 실행해도 take 호출은 객체 락을 얻지 못합니다.

각 join에 제한 시간을 두고 두 스레드의 순간 상태를 출력한 뒤 생산자에게 인터럽트를 요청합니다. 정리 뒤 종료를 다시 기다리는 코드는 없습니다.

bad/SleepWhileHoldingMonitor.java
import java.util.ArrayDeque;
import java.util.Queue;

public final class SleepWhileHoldingMonitor {
    private final Queue<String> queue = new ArrayDeque<>();

    public synchronized void put(String value) {
        while (queue.size() == 1) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
        queue.add(value);
    }

    public synchronized String take() {
        return queue.remove();
    }

    public static void main(String[] args) throws InterruptedException {
        SleepWhileHoldingMonitor buffer = new SleepWhileHoldingMonitor();
        buffer.put("first");
        Thread producer = Thread.ofPlatform().name("stuck-producer").start(() -> buffer.put("second"));
        Thread.sleep(50);
        Thread consumer = Thread.ofPlatform().name("blocked-consumer").start(buffer::take);
        producer.join(150);
        consumer.join(150);
        System.out.println("producer=" + producer.getState());
        System.out.println("consumer=" + consumer.getState());
        producer.interrupt();
    }
}

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

생산자가 모니터를 쥔 채 sleep하는 조건에서 소비자는 BLOCKED, interrupt 뒤 해제 후 제거가 가능합니다. 스케줄의 가변성을 유지한 조건부 진행·락 해제 추적이 유용합니다.

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다.

보유 중 sleep · 진행 조건

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다.

보유한 모니터와 소비자의 진행생산자가 먼저모니터를얻은 조건에서 sleep은잠금을놓지않으므로 소비자take가BLOCKED입니다. 생산자interrupt 뒤put반환·해제해야 first를제거할수있습니다.buffer 모니터생산자 먼저 보유생산자sleep 대기락 보유소비자 take모니터 필요BLOCKED생산자 interruptput 반환 · 모니터 해제해제 뒤 소비자 진입first 제거
보유한 모니터와 소비자의 진행생산자가 먼저모니터를얻은 조건에서 sleep은잠금을놓지않으므로 소비자take가BLOCKED입니다. 생산자interrupt 뒤put반환·해제해야 first를제거할수있습니다.buffer 모니터생산자 먼저 보유생산자sleep 대기락 보유소비자take모니터필요BLOCKED생산자 interruptput 반환 · 모니터 해제해제 뒤 소비자 진입first 제거
설명과 원래 예시 값

생산자

큐가 가득 찬 반복
sleep 동안에도 buffer 모니터를 보유합니다.
이번 실행의 조회
TIMED_WAITING
출력 뒤 interrupt
sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다.

소비자

큐가 가득 찬 반복
take에 진입하려면 같은 모니터가 필요합니다.
이번 실행의 조회
BLOCKED
출력 뒤 interrupt
생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다.

main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다.

이 예의 조건

  • 생산자가 먼저 모니터를 획득한 경로입니다. sleep 50은 획득 순서를 보장하지 않습니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다. 보유 중 sleep · 진행 조건 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다. 생산자 큐가 가득 찬 반복 sleep 동안에도 buffer 모니터를 보유합니다. 이번 실행의 조회 TIMED_WAITING 출력 뒤 interrupt sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다. 소비자 큐가 가득 찬 반복 take에 진입하려면 같은 모니터가 필요합니다. 이번 실행의 조회 BLOCKED 출력 뒤 interrupt 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다. main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
가득 찬 큐 보유

생산자가 buffer 모니터를 얻고 sleep합니다.

  • 생산자 조회: TIMED_WAITING
  • 모니터: 계속 보유
  • sleep은 모니터를 반납하지 않습니다.
소비자 진입 대기

소비자는 take에 필요한 같은 모니터를 기다립니다.

  • 소비자 조회: BLOCKED
  • 큐 조건: 소비자가 변경 못 함
  • 보유 중인 생산자가 제거를 기다려도 소비자는 진입할 수 없습니다.
중단 뒤 반납

출력 뒤 interrupt가 sleep을 중단시키고 put이 반환합니다.

  • 생산자: 플래그 복구 뒤 반환
  • 소비자: 반납 뒤 first 제거 가능
  • 원 두 상태 조회는 원자적 스냅샷이 아니며 다른 획득 순서에서는 둘 다 종료할 수 있습니다.

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

생산자가 모니터를 쥔 채 sleep하는 조건에서 소비자는 BLOCKED, interrupt 뒤 해제 후 제거가 가능합니다. 스케줄의 가변성을 유지한 조건부 진행·락 해제 추적이 유용합니다.

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다.

보유 중 sleep · 진행 조건

생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다

생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다.

보유한 모니터와 소비자의 진행생산자가 먼저모니터를얻은 조건에서 sleep은잠금을놓지않으므로 소비자take가BLOCKED입니다. 생산자interrupt 뒤put반환·해제해야 first를제거할수있습니다.buffer 모니터생산자 먼저 보유생산자sleep 대기락 보유소비자 take모니터 필요BLOCKED생산자 interruptput 반환 · 모니터 해제해제 뒤 소비자 진입first 제거
보유한 모니터와 소비자의 진행생산자가 먼저모니터를얻은 조건에서 sleep은잠금을놓지않으므로 소비자take가BLOCKED입니다. 생산자interrupt 뒤put반환·해제해야 first를제거할수있습니다.buffer 모니터생산자 먼저 보유생산자sleep 대기락 보유소비자take모니터필요BLOCKED생산자 interruptput 반환 · 모니터 해제해제 뒤 소비자 진입first 제거
설명과 원래 예시 값

생산자

큐가 가득 찬 반복
sleep 동안에도 buffer 모니터를 보유합니다.
이번 실행의 조회
TIMED_WAITING
출력 뒤 interrupt
sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다.

소비자

큐가 가득 찬 반복
take에 진입하려면 같은 모니터가 필요합니다.
이번 실행의 조회
BLOCKED
출력 뒤 interrupt
생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다.

main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다.

이 예의 조건

  • 생산자가 먼저 모니터를 획득한 경로입니다. sleep 50은 획득 순서를 보장하지 않습니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 SleepWhileHoldingMonitor에서 생산자가 먼저 가득 찬 큐의 모니터를 얻은 경로와 실제 상태 관측의 한계를 구분합니다. 보유 중 sleep · 진행 조건 생산자가 잠금을 쥔 경로에서 소비자는 조건을 바꿀 수 없다 생산자가 먼저 모니터를 얻어 가득 찬 큐에서 sleep하면, 소비자는 그 모니터를 얻기 전까지 제거할 수 없습니다. 생산자 큐가 가득 찬 반복 sleep 동안에도 buffer 모니터를 보유합니다. 이번 실행의 조회 TIMED_WAITING 출력 뒤 interrupt sleep에서 중단을 받으면 상태를 복구하고 put에서 반환합니다. 소비자 큐가 가득 찬 반복 take에 진입하려면 같은 모니터가 필요합니다. 이번 실행의 조회 BLOCKED 출력 뒤 interrupt 생산자가 모니터를 놓은 뒤 first를 제거할 수 있습니다. main의 sleep(50)은 잠금 획득 순서를 보장하지 않습니다. 소비자가 먼저 제거하면 둘 다 끝날 수도 있으며 두 상태 조회도 하나의 원자적 스냅샷은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
가득 찬 큐 보유

생산자가 buffer 모니터를 얻고 sleep합니다.

  • 생산자 조회: TIMED_WAITING
  • 모니터: 계속 보유
  • sleep은 모니터를 반납하지 않습니다.
소비자 진입 대기

소비자는 take에 필요한 같은 모니터를 기다립니다.

  • 소비자 조회: BLOCKED
  • 큐 조건: 소비자가 변경 못 함
  • 보유 중인 생산자가 제거를 기다려도 소비자는 진입할 수 없습니다.
중단 뒤 반납

출력 뒤 interrupt가 sleep을 중단시키고 put이 반환합니다.

  • 생산자: 플래그 복구 뒤 반환
  • 소비자: 반납 뒤 first 제거 가능
  • 원 두 상태 조회는 원자적 스냅샷이 아니며 다른 획득 순서에서는 둘 다 종료할 수 있습니다.

이 종료 장치는 실패를 해결한 것이 아니라 실험을 끝내기 위한 안전핀입니다.


잠금의 목적과 조건 대기의 목적 분리

모니터는 크기 검사와 큐 변경을 하나의 원자적 구간으로 묶습니다.

반면 대기는 현재 스레드가 할 일이 없을 때 다른 주체가 조건을 바꾸도록 실행권을 넘기는 행위입니다.

두 목적을 섞어서 “잠금 안에서 오래 쉬면 안전하다”고 해석하면 안전성은 얻을지 몰라도 생존성을 잃습니다.

  • sleep은 시간 경과만 기다리며 어떤 객체의 모니터도 자동으로 해제하지 않습니다.
  • yield는 스케줄러에 양보 힌트를 줄 뿐 잠금 소유권이나 조건 신호를 다루지 않습니다.
  • 잠금 밖 폴링은 다른 스레드의 진입을 허용하지만 확인 주기만큼 지연과 불필요한 깨어남을 만듭니다.
  • wait와 Condition.await는 정해진 잠금과 결합되어 대기 진입 시 소유권을 반납합니다.
  • 인터럽트는 취소 통로이므로 잡아서 버리지 말고 반환·전파·상태 복구 중 규칙에 맞는 처리를 해야 합니다.
  • 진행성 검증은 최종 값뿐 아니라 제한 시간 안에 각 역할이 종료되는지도 확인해야 합니다.

잠금 범위는 “공유 상태를 읽고 바꾸는 데 필요한 최소 구간”으로 설명하는 편이 정확합니다.

파일 쓰기, 네트워크 호출, 인위적 지연을 그 안에 두면 큐의 불변식과 관계없는 시간이 모든 참여자를 막습니다.

조건 확인부터 변경까지는 붙여 두되, 후속 처리와 느린 작업은 원소를 꺼낸 뒤 잠금 밖에서 수행합니다.


모니터 밖 재시도 기준

정식 해법은 뒤 절의 조건 대기입니다.

여기서는 잘못의 원인이 락 보유라는 사실을 분리해 확인하기 위해 폴링 버전을 만듭니다.

tryPut과 tryTake는 한 번만 검사하고 즉시 반환합니다.

재시도 루프는 메서드 밖에 있으므로 실패 사이에 다른 역할이 같은 모니터를 얻을 수 있습니다.

LockSupport.parkNanos는 CPU 독점을 줄이지만 조건 신호가 아니므로 학습용 비교 기준으로만 사용합니다.

src/OutsideMonitorRetryBuffer.java
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;
import java.util.concurrent.locks.LockSupport;

public final class OutsideMonitorRetryBuffer<E> {
    private final Queue<E> queue = new ArrayDeque<>();
    private final int capacity;

    public OutsideMonitorRetryBuffer(int capacity) {
        if (capacity < 1) throw new IllegalArgumentException("capacity");
        this.capacity = capacity;
    }

    public synchronized boolean tryPut(E value) {
        if (value == null) throw new NullPointerException("value");
        if (queue.size() == capacity) return false;
        queue.add(value);
        return true;
    }

    public synchronized Optional<E> tryTake() {
        return Optional.ofNullable(queue.poll());
    }

    public boolean putBefore(E value, Duration timeout) throws InterruptedException {
        long deadline = System.nanoTime() + timeout.toNanos();
        do {
            if (tryPut(value)) return true;
            if (Thread.interrupted()) throw new InterruptedException();
            LockSupport.parkNanos(200_000);
        } while (System.nanoTime() < deadline);
        return false;
    }

    public static void main(String[] args) throws Exception {
        OutsideMonitorRetryBuffer<String> buffer = new OutsideMonitorRetryBuffer<>(1);
        buffer.tryPut("first");
        Thread producer = Thread.ofPlatform().start(() -> {
            try {
                System.out.println("inserted=" + buffer.putBefore("second", Duration.ofSeconds(1)));
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Thread consumer = Thread.ofPlatform().start(() -> {
            LockSupport.parkNanos(Duration.ofMillis(30).toNanos());
            System.out.println("removed=" + buffer.tryTake().orElseThrow());
        });
        producer.join();
        consumer.join();
        System.out.println("tail=" + buffer.tryTake().orElseThrow());
    }
}

재시도는 시도·중단·대기·기한 순서로 검사한다

즉시 tryPut→실패 시 interrupted→모니터 밖 park→기한 비교의 실제 재시도 순서입니다. 첫 시도가 중단·기한 검사보다 앞섬을 단계로 보여 줍니다.

재시도는 시도·중단·대기·기한 순서로 검사한다

OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다.

재시도 · 검사 순서

재시도는 시도·중단·대기·기한 순서로 검사한다

첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다.

즉시 시도 뒤의 중단·기한 분기잠금 안 즉시 tryPut 성공은 true로종료합니다. 실패한 뒤에만 중단을 검사하며 미중단이면 모니터 밖park0.2ms 뒤 기한을비교하여 재시도하거나 false로종료합니다.잠금 안 즉시tryPut(value)성공true 반환실패 뒤중단 검사중단 true플래그 제거예외 종료중단 falsepark 0.2ms기한 전?기한 종료false 반환
즉시 시도 뒤의 중단·기한 분기잠금 안 즉시 tryPut 성공은 true로종료합니다. 실패한 뒤에만 중단을 검사하며 미중단이면 모니터 밖park0.2ms 뒤 기한을비교하여 재시도하거나 false로종료합니다.잠금 안 즉시tryPut(value)성공true 반환실패 뒤중단 검사중단 true플래그제거예외 종료중단 falsepark0.2ms기한 전?기한 종료false 반환
설명과 원래 예시 값
  1. 1 · 즉시 시도

    원문의 검사·호출
    tryPut(value)
    결과와 범위
    검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다.
  2. 2 · 실패 뒤 중단 검사

    원문의 검사·호출
    Thread.interrupted()
    결과와 범위
    true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다.
  3. 3 · 모니터 밖 대기

    원문의 검사·호출
    parkNanos(200_000)
    결과와 범위
    소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다.
  4. 4 · 기한 비교

    원문의 검사·호출
    현재 시간이 deadline보다 이른가
    결과와 범위
    이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다.

첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다.

이 예의 조건

  • 시도 횟수 제한은 없으며 true는 정확한 1초 내 완료 증거가 아닙니다.
  • tryPut 성공은 즉시 반환합니다. 실패 뒤 중단 조회가 false일 때만 모니터 밖 대기와 기한 비교에 도달합니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보재시도는 시도·중단·대기·기한 순서로 검사한다 재시도는 시도·중단·대기·기한 순서로 검사한다 OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다. 재시도 · 검사 순서 재시도는 시도·중단·대기·기한 순서로 검사한다 첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다. 1 · 즉시 시도 원문의 검사·호출 tryPut(value) 결과와 범위 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다. 2 · 실패 뒤 중단 검사 원문의 검사·호출 Thread.interrupted() 결과와 범위 true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다. 3 · 모니터 밖 대기 원문의 검사·호출 parkNanos(200_000) 결과와 범위 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다. 4 · 기한 비교 원문의 검사·호출 현재 시간이 deadline보다 이른가 결과와 범위 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다. 첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 즉시 시도

tryPut이 모니터 안에서 검사·추가합니다.

  • 성공: 즉시 true · 뒤 검사 없음
  • 실패: 중단 조회로 진행
  • 순서: 중단·기한 검사보다 먼저
  • 첫 시도가 성공하면 뒤의 중단·대기·기한 검사를 실행하지 않습니다.
실패 뒤 중단 분기

실패한 시도 뒤 Thread.interrupted를 조회하여 종료 또는 진행을 정합니다.

  • 조회 true: 플래그 소거 · InterruptedException 종료
  • 조회 false: 대기·기한 비교로 진행
  • 진입 조건: tryPut 실패 뒤
  • 중단 조회 true의 경로는 예외로 끝나며 false인 경로만 뒤 대기에 도달합니다.
false 경로의 대기와 기한

중단 조회가 false일 때만 parkNanos(200000) 뒤 시간을 비교합니다.

  • 진입: tryPut 실패 · 중단 조회 false
  • 대기 요청: 0.2ms · 모니터 밖
  • 이른 시각 / 만료: 다음 시도 / false 반환
  • 기한 전 비교 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 완료 시각으로 해석하지 않습니다.

재시도는 시도·중단·대기·기한 순서로 검사한다

즉시 tryPut→실패 시 interrupted→모니터 밖 park→기한 비교의 실제 재시도 순서입니다. 첫 시도가 중단·기한 검사보다 앞섬을 단계로 보여 줍니다.

재시도는 시도·중단·대기·기한 순서로 검사한다

OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다.

재시도 · 검사 순서

재시도는 시도·중단·대기·기한 순서로 검사한다

첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다.

즉시 시도 뒤의 중단·기한 분기잠금 안 즉시 tryPut 성공은 true로종료합니다. 실패한 뒤에만 중단을 검사하며 미중단이면 모니터 밖park0.2ms 뒤 기한을비교하여 재시도하거나 false로종료합니다.잠금 안 즉시tryPut(value)성공true 반환실패 뒤중단 검사중단 true플래그 제거예외 종료중단 falsepark 0.2ms기한 전?기한 종료false 반환
즉시 시도 뒤의 중단·기한 분기잠금 안 즉시 tryPut 성공은 true로종료합니다. 실패한 뒤에만 중단을 검사하며 미중단이면 모니터 밖park0.2ms 뒤 기한을비교하여 재시도하거나 false로종료합니다.잠금 안 즉시tryPut(value)성공true 반환실패 뒤중단 검사중단 true플래그제거예외 종료중단 falsepark0.2ms기한 전?기한 종료false 반환
설명과 원래 예시 값
  1. 1 · 즉시 시도

    원문의 검사·호출
    tryPut(value)
    결과와 범위
    검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다.
  2. 2 · 실패 뒤 중단 검사

    원문의 검사·호출
    Thread.interrupted()
    결과와 범위
    true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다.
  3. 3 · 모니터 밖 대기

    원문의 검사·호출
    parkNanos(200_000)
    결과와 범위
    소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다.
  4. 4 · 기한 비교

    원문의 검사·호출
    현재 시간이 deadline보다 이른가
    결과와 범위
    이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다.

첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다.

이 예의 조건

  • 시도 횟수 제한은 없으며 true는 정확한 1초 내 완료 증거가 아닙니다.
  • tryPut 성공은 즉시 반환합니다. 실패 뒤 중단 조회가 false일 때만 모니터 밖 대기와 기한 비교에 도달합니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보재시도는 시도·중단·대기·기한 순서로 검사한다 재시도는 시도·중단·대기·기한 순서로 검사한다 OutsideMonitorRetryBuffer.putBefore의 do/while을 실제 호출 순서와 모니터 범위로 읽습니다. 재시도 · 검사 순서 재시도는 시도·중단·대기·기한 순서로 검사한다 첫 삽입 시도는 중단과 기한 검사보다 앞섭니다. 실패한 뒤에만 모니터 밖에서 기다리고 다시 시도합니다. 1 · 즉시 시도 원문의 검사·호출 tryPut(value) 결과와 범위 검사·추가는 synchronized 메서드 안입니다. 성공하면 즉시 true를 반환합니다. 2 · 실패 뒤 중단 검사 원문의 검사·호출 Thread.interrupted() 결과와 범위 true이면 플래그를 지우고 예외를 던집니다. 첫 시도보다 뒤입니다. 3 · 모니터 밖 대기 원문의 검사·호출 parkNanos(200_000) 결과와 범위 소비자는 같은 모니터에 진입할 수 있습니다. 0.2ms는 대기 요청값입니다. 4 · 기한 비교 원문의 검사·호출 현재 시간이 deadline보다 이른가 결과와 범위 이르면 다음 시도로, 아니면 false로 끝납니다. 시도 횟수 제한은 없습니다. 첫 시도는 기한 검사보다 먼저입니다. 기한 전 검사 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 1초 내 완료의 증거로 볼 수 없습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 즉시 시도

tryPut이 모니터 안에서 검사·추가합니다.

  • 성공: 즉시 true · 뒤 검사 없음
  • 실패: 중단 조회로 진행
  • 순서: 중단·기한 검사보다 먼저
  • 첫 시도가 성공하면 뒤의 중단·대기·기한 검사를 실행하지 않습니다.
실패 뒤 중단 분기

실패한 시도 뒤 Thread.interrupted를 조회하여 종료 또는 진행을 정합니다.

  • 조회 true: 플래그 소거 · InterruptedException 종료
  • 조회 false: 대기·기한 비교로 진행
  • 진입 조건: tryPut 실패 뒤
  • 중단 조회 true의 경로는 예외로 끝나며 false인 경로만 뒤 대기에 도달합니다.
false 경로의 대기와 기한

중단 조회가 false일 때만 parkNanos(200000) 뒤 시간을 비교합니다.

  • 진입: tryPut 실패 · 중단 조회 false
  • 대기 요청: 0.2ms · 모니터 밖
  • 이른 시각 / 만료: 다음 시도 / false 반환
  • 기한 전 비교 뒤 스케줄이 늦어져 기한 후 삽입할 수도 있으므로 true를 정확한 완료 시각으로 해석하지 않습니다.

이제 소비자는 생산자가 재시도 사이에 잠금을 반납하므로 first를 제거할 수 있습니다.

이번 실행은 removed=first, inserted=true, tail=second 순으로 끝났습니다. 성공 경로의 앞 두 줄 순서는 바뀔 수 있습니다. 기한이 먼저 끝나면 inserted=false 뒤 마지막 꺼내기에서 NoSuchElementException이 날 수 있습니다.

반복 조회 비용은 남으며 실제 재검사 간격과 추가 지연은 스케줄링과 모니터 획득 지연의 영향도 받습니다.


테스트의 제한 시간 관측

생존성 오류를 다루는 테스트가 join()을 제한 없이 호출하면 실패했을 때 테스트 러너도 끝나지 않습니다.

다음 감시기는 제한 시간 후 살아 있는 스레드의 상태를 기록하고 인터럽트합니다.

검사 순간의 종료 여부를 불리언으로 반환하지만, 인터럽트 요청 뒤 실제 종료를 기다리지는 않습니다.

src/ThreadCompletionProbe.java
import java.time.Duration;
import java.util.List;

public final class ThreadCompletionProbe {
    static boolean awaitAll(List<Thread> workers, Duration timeout) throws InterruptedException {
        long deadline = System.nanoTime() + timeout.toNanos();
        for (Thread worker : workers) {
            long remaining = deadline - System.nanoTime();
            if (remaining > 0) worker.join(Duration.ofNanos(remaining));
        }
        boolean completed = workers.stream().noneMatch(Thread::isAlive);
        if (!completed) {
            workers.stream()
                    .filter(Thread::isAlive)
                    .forEach(worker -> System.err.println(worker.getName() + ":" + worker.getState()));
            workers.forEach(Thread::interrupt);
        }
        return completed;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread shortJob = Thread.ofPlatform().name("short-job").start(() -> {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        System.out.println("completed=" + awaitAll(List.of(shortJob), Duration.ofSeconds(1)));
    }
}

상태 출력은 원인 그 자체가 아니라 단서입니다.

BLOCKED는 모니터 진입을 기다린다는 뜻이고 TIMED_WAITING은 시간 제한 동작 안에 있다는 뜻입니다.

스레드 덤프에서 잠금 소유자와 대기자를 함께 연결해야 “생산자가 모니터를 가진 채 잠들어 소비자가 막혔다”는 인과를 확정할 수 있습니다.


어떤 대기 도구를 고를지 결정하는 표

상황락 반납 여부판단
임계 영역과 무관한 지연먼저 잠금 해제밖에서 sleep 가능
공유 조건이 바뀔 때까지 대기반드시 반납wait 또는 Condition
단 한 번의 비차단 시도즉시 반환try 메서드와 결괏값
제한 시간 안의 수용반납하며 시간 계산시간 제한 offer 계열

대기 시간을 짧게 줄인다고 구조적 정지가 해결되지는 않습니다.

1초 대신 1밀리초로 바꾸면 잠금 소유자가 더 자주 깨어날 뿐, 소비자가 들어올 기회는 여전히 없습니다.

반대로 잠금 밖 폴링은 진행은 가능하지만 지연과 CPU 사용이 확인 주기에 묶입니다.

버퍼 조건이 바뀌는 정확한 순간을 알고 있으므로 신호 기반 대기가 더 직접적인 모델입니다.


연습 문제

큐에서 메시지를 꺼내 JSON 문자열로 직렬화하는 소비자가 있습니다.

기존 코드는 synchronized drainOne() 안에서 40밀리초 직렬화를 수행하여 생산자를 계속 막습니다.

큐 제거만 보호하고 변환은 밖에서 실행하도록 리팩터링하세요.

동시에 두 소비자가 같은 메시지를 처리하지 않는지도 설명합니다.

정답과 해설

잠금 안에서는 poll로 소유권을 한 소비자에게 이전합니다.

제거된 큐 항목은 한 소비자에게만 넘어갑니다.

그다음 느린 변환은 지역 변수만 사용하므로 모니터가 필요하지 않습니다.

큐가 비었을 때는 Optional.empty()로 즉시 알려 줍니다.

exercise/SerializeOutsideLockSolution.java
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.Queue;

public final class SerializeOutsideLockSolution {
    record Message(long id, String body) {}

    private final Queue<Message> pending = new ArrayDeque<>();

    synchronized void submit(Message message) {
        pending.add(message);
    }

    private synchronized Optional<Message> claimOne() {
        return Optional.ofNullable(pending.poll());
    }

    Optional<String> drainOne() {
        Optional<Message> claimed = claimOne();
        return claimed.map(this::slowSerialize);
    }

    private String slowSerialize(Message message) {
        try {
            Thread.sleep(40);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("serialization interrupted", e);
        }
        return "{\"id\":" + message.id() + ",\"body\":\"" + message.body() + "\"}";
    }

    public static void main(String[] args) throws InterruptedException {
        SerializeOutsideLockSolution queue = new SerializeOutsideLockSolution();
        queue.submit(new Message(7, "ready"));
        Thread consumer = Thread.ofPlatform().start(() -> System.out.println(queue.drainOne().orElseThrow()));
        Thread producer = Thread.ofPlatform().start(() -> queue.submit(new Message(8, "next")));
        consumer.join();
        producer.join();
        System.out.println(queue.drainOne().isPresent());
    }
}

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

잠금 안 claimOne이 Message를 한 호출자에게 넘긴 뒤 잠금 밖 slowSerialize를 합니다. 모니터 획득·소유 이전·해제 후 변환을 순서대로 구분해 모니터 범위를 확인할 수 있습니다.

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다.

모니터 범위 · 항목 소유

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다.

모니터 안의 큐와 밖의 변환submit과 claimOne은 같은 this 모니터 안에서 큐를 보호합니다. claimOne의 poll로한호출자에게 이전한 Message는 모니터를 해제한 뒤 slowSerialize로변환합니다.같은 this 모니터 안submit항목 추가큐MessageclaimOne · poll한 호출자에게 소유 이전모니터 밖slowSerialize40ms · 문자열 결합
모니터 안의 큐와 밖의 변환submit과 claimOne은 같은 this 모니터 안에서 큐를 보호합니다. claimOne의 poll로한호출자에게 이전한 Message는 모니터를 해제한 뒤 slowSerialize로변환합니다.같은 this 모니터 안submit항목 추가큐큐 항목claimOne · poll한 호출자에게 소유 이전모니터 밖slowSerialize40ms · 문자열 결합
설명과 원래 예시 값

submit

소유 범위
this 모니터 안
수행하는 일
큐에 항목을 추가합니다.

claimOne

소유 범위
같은 this 모니터 안
수행하는 일
poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다.

slowSerialize

소유 범위
모니터 밖
수행하는 일
꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다.

같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다.

이 예의 조건

  • 40ms는 sleep 요청이며 JSON 결합은 임의 body를 이스케이프하지 않습니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다. 모니터 범위 · 항목 소유 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다. submit 소유 범위 this 모니터 안 수행하는 일 큐에 항목을 추가합니다. claimOne 소유 범위 같은 this 모니터 안 수행하는 일 poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다. slowSerialize 소유 범위 모니터 밖 수행하는 일 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다. 같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
항목 등록

submit이 this 모니터 안에서 Message를 큐에 추가합니다.

  • 소유: 큐 항목
  • 잠금: this
  • 같은 참조를 여러 번 등록하면 항목도 여러 개입니다.
항목 인수

claimOne이 같은 모니터 안에서 poll합니다.

  • 큐: 항목 하나 제거
  • 소유: 한 호출자가 Message 인수
  • 보호된 제거가 두 호출자에게 같은 큐 항목을 넘기는 것을 막습니다.
느린 변환

모니터 밖에서 sleep과 문자열 결합을 수행합니다.

  • 잠금: 미보유
  • 출력 한계: 따옴표 이스케이프 없음
  • 느린 작업 동안 다른 호출자가 큐 모니터에 진입할 수 있습니다.

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

잠금 안 claimOne이 Message를 한 호출자에게 넘긴 뒤 잠금 밖 slowSerialize를 합니다. 모니터 획득·소유 이전·해제 후 변환을 순서대로 구분해 모니터 범위를 확인할 수 있습니다.

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다.

모니터 범위 · 항목 소유

큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다

큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다.

모니터 안의 큐와 밖의 변환submit과 claimOne은 같은 this 모니터 안에서 큐를 보호합니다. claimOne의 poll로한호출자에게 이전한 Message는 모니터를 해제한 뒤 slowSerialize로변환합니다.같은 this 모니터 안submit항목 추가큐MessageclaimOne · poll한 호출자에게 소유 이전모니터 밖slowSerialize40ms · 문자열 결합
모니터 안의 큐와 밖의 변환submit과 claimOne은 같은 this 모니터 안에서 큐를 보호합니다. claimOne의 poll로한호출자에게 이전한 Message는 모니터를 해제한 뒤 slowSerialize로변환합니다.같은 this 모니터 안submit항목 추가큐큐 항목claimOne · poll한 호출자에게 소유 이전모니터 밖slowSerialize40ms · 문자열 결합
설명과 원래 예시 값

submit

소유 범위
this 모니터 안
수행하는 일
큐에 항목을 추가합니다.

claimOne

소유 범위
같은 this 모니터 안
수행하는 일
poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다.

slowSerialize

소유 범위
모니터 밖
수행하는 일
꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다.

같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다.

이 예의 조건

  • 40ms는 sleep 요청이며 JSON 결합은 임의 body를 이스케이프하지 않습니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 SerializeOutsideLockSolution에서 submit과 claimOne은 큐를 보호하고 slowSerialize는 꺼낸 값을 변환합니다. 모니터 범위 · 항목 소유 큐 항목을 가져온 뒤 변환은 모니터 밖에서 수행한다 큐에서 항목을 제거하는 일은 잠금 안에 두고, 꺼낸 값의 느린 변환은 잠금 밖에서 수행합니다. submit 소유 범위 this 모니터 안 수행하는 일 큐에 항목을 추가합니다. claimOne 소유 범위 같은 this 모니터 안 수행하는 일 poll로 항목 하나를 제거하여 한 호출자에게 넘깁니다. slowSerialize 소유 범위 모니터 밖 수행하는 일 꺼낸 Message를 사용해 40ms sleep과 문자열 결합을 수행합니다. 같은 참조를 여러 번 submit하면 큐 항목도 여러 개입니다. 이 답안의 JSON 문자열 결합은 따옴표 등을 이스케이프하지 않으므로 임의의 body를 안전하게 직렬화하는 구현은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
항목 등록

submit이 this 모니터 안에서 Message를 큐에 추가합니다.

  • 소유: 큐 항목
  • 잠금: this
  • 같은 참조를 여러 번 등록하면 항목도 여러 개입니다.
항목 인수

claimOne이 같은 모니터 안에서 poll합니다.

  • 큐: 항목 하나 제거
  • 소유: 한 호출자가 Message 인수
  • 보호된 제거가 두 호출자에게 같은 큐 항목을 넘기는 것을 막습니다.
느린 변환

모니터 밖에서 sleep과 문자열 결합을 수행합니다.

  • 잠금: 미보유
  • 출력 한계: 따옴표 이스케이프 없음
  • 느린 작업 동안 다른 호출자가 큐 모니터에 진입할 수 있습니다.

여기서 한 소비자에게 넘기는 단위는 큐에 넣은 항목 하나입니다. 같은 Message 참조나 ID를 submit으로 두 번 넣으면 항목이 두 개가 되어 각각 제거할 수 있습니다. poll의 배타적 제거는 업무 ID 중복 제거·처리 성공 보장이나 실패 뒤 재처리까지 구현한 규약이 아닙니다.

slowSerialize의 sleep(40)은 느린 변환을 흉내 낸 지연 요청이며 정확히 40ms의 실행 시간을 보장하지 않습니다. 뒤의 문자열 결합도 ready·next처럼 따옴표나 역슬래시가 없는 입력의 모양을 보여 줍니다. 임의의 body에는 올바른 JSON 이스케이프를 수행하는 직렬화 도구가 필요합니다. 큐에서 꺼낸 뒤 변환이 실패하면 원 항목은 이미 제거됐으므로, 재처리·보관·폐기 중 어떤 정책을 사용할지 별도로 정합니다.

직렬화 중에도 생산자는 두 번째 메시지를 넣을 수 있습니다.

claimOne이 반환하는 순간 첫 메시지는 한 소비자에게만 귀속됩니다.

처리 실패 때 재큐잉이 필요하다면 별도의 실패 정책과 시도 횟수를 추가해야 하며, 단순히 긴 작업을 다시 잠금 안으로 넣어 원자성을 흉내 내서는 안 됩니다.


정지 원인의 명시

synchronized는 스레드를 안전하게 해 주는 마법 문구가 아니라 한 번에 한 주체만 상태 전이를 수행하게 하는 문입니다.

문 안에서 조건이 저절로 바뀌기를 기다리면, 조건 변경자를 문 밖에 세워 두는 셈입니다.

어떤 코드가 잠금을 소유하고 어떤 코드가 조건을 바꾸는지 타임라인으로 그리면 이 오류를 빠르게 찾을 수 있습니다.

완성된 버퍼는 안전성, 진행성, 취소 가능성을 따로 확인합니다.

크기가 용량을 넘지 않는지, 제한 시간 안에 생산과 소비가 모두 끝나는지, 인터럽트가 작업 중단으로 이어지는지를 각각 관찰해야 합니다.

다음 절의 모니터 대기는 이 세 요구를 한 프로토콜 안에서 다룹니다.