본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
20장 : 생산자·소비자와 조건 대기

BLOCKED·WAITING 상태

모니터 입구 대기와 조건 대기를 분리해 스레드 덤프를 해석하고, 관찰용 상태를 동기화 프로토콜로 오용하지 않는 진단 절차를 만듭니다.

synchronized 기반 프로그램에는 서로 다른 대기가 겹칩니다. 모니터를 얻지 못한 스레드는 JVM의 락 획득 경로에서 BLOCKED로 보이고, 모니터를 가진 뒤 조건이 맞지 않아 wait()를 호출한 스레드는 객체 대기 집합에서 WAITING 또는 시간 제한이 있으면 TIMED_WAITING으로 보입니다. 알림을 받은 대기자는 조건 대기에서 빠져나와도 모니터를 다시 얻기 전까지 실행을 계속할 수 없습니다.

이 구분은 장애 분석에 유용하지만 애플리케이션 제어 수단은 아닙니다. Thread.getState()로 WAITING을 확인한 뒤 신호를 보내는 코드는 관찰과 행동 사이에 상태가 달라질 수 있습니다. 상태 값은 순간 스냅샷이며 업무 이벤트가 아닙니다. 제어에는 CountDownLatch, 큐, 조건 술어처럼 명시적인 동기화 규칙을 사용하고 스레드 상태는 원인 추적에만 씁니다.

getState를 준비 신호로 사용한 불안정한 시작

아래 프로그램은 작업자가 WAITING이 될 때까지 반복한 뒤 notifyAll을 보냅니다. 실습에서는 자주 동작하지만 정식 프로토콜로는 취약합니다. 상태를 읽은 직후 작업자가 아직 대기 집합 등록을 완료하지 않았거나 다른 이유로 복귀할 수 있고, 바쁜 반복은 CPU를 소비합니다. 제한 시간도 없어 논리가 바뀌면 감시자까지 멈춥니다.

bad/ThreadStateHandshake.java
public final class ThreadStateHandshake {
    private final Object gate = new Object();
    private boolean open;

    void awaitOpen() throws InterruptedException {
        synchronized (gate) {
            while (!open) gate.wait();
        }
    }

    void open() {
        synchronized (gate) {
            open = true;
            gate.notifyAll();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadStateHandshake demo = new ThreadStateHandshake();
        Thread worker = Thread.ofPlatform().start(() -> {
            try { demo.awaitOpen(); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        while (worker.getState() != Thread.State.WAITING) {
            Thread.onSpinWait();
        }
        demo.open();
        worker.join();
        System.out.println("done=" + !worker.isAlive());
    }
}

이 코드의 open 플래그와 모니터 대기는 실제 안전성을 제공하지만, 메인 스레드가 시작 시점을 알아내는 방식이 잘못되었습니다. 준비 완료를 반드시 알아야 한다면 작업자가 명시적 래치의 카운트를 줄이게 해야 합니다. 더 단순하게는 상태를 기다리지 않고 언제든 open을 호출할 수 있도록 조건 플래그를 먼저 저장하는 프로토콜이 신호 유실을 막습니다.

스레드 덤프의 소유자·입구·조건

한 줄의 상태만 보고 교착이라고 단정하지 않습니다. 먼저 어떤 스레드가 모니터를 소유하는지 찾고, BLOCKED 스레드가 그 객체에 들어가려는지 확인합니다. WAITING 스레드는 스택에서 Object.wait, Condition.await, LockSupport.park, Thread.join 중 무엇을 호출했는지 구별합니다. 같은 WAITING이라도 깨우는 사건은 전혀 다릅니다.

  • BLOCKED는 내장 모니터 진입 경쟁에 특화된 상태이며 ReentrantLock 대기는 보통 park 기반 WAITING으로 나타납니다.
  • Object.wait 대기자는 알림을 받아도 모니터 재획득 단계를 거쳐야 사용자 코드를 이어서 실행합니다.
  • join 대기자는 대상 스레드 종료가 사건이고, notify를 보내도 해당 규칙을 대신하지 못합니다.
  • 스택의 잠금 객체 식별자와 소유자 정보를 함께 묶어야 대기 사슬을 재구성할 수 있습니다.
  • 짧은 순간의 덤프 하나보다 시간 간격을 둔 여러 덤프에서 같은 프레임이 고정되는지 보는 편이 유용합니다.
  • 진단 로그에는 업무 조건 값과 큐 크기도 남겨 스레드 상태를 도메인 상태와 연결합니다.

ThreadMXBean은 데드락 탐지와 스레드 정보를 제공하지만, 발견 결과가 없다고 생존성 문제가 없다는 뜻은 아닙니다. 한 생산자가 락을 쥔 채 조건을 기다리는 논리 정지는 순환 락 소유가 없을 수 있습니다. 제한 시간 내 작업량이 증가하는지 보는 진행 지표가 별도로 필요합니다.

두 대기 단계를 의도적으로 관찰하는 안전한 실험

다음 실험은 한 스레드가 모니터를 소유하도록 래치로 확인한 뒤, 다른 스레드를 입구에서 막습니다. 첫 스레드가 wait로 모니터를 반납하면 두 번째가 들어와 상태를 바꾸고 알립니다. 제어는 래치와 조건 플래그가 담당하고 getState는 기록만 담당합니다.

src/MonitorStateTimeline.java
import java.util.concurrent.CountDownLatch;

public final class MonitorStateTimeline {
    private final Object monitor = new Object();
    private final CountDownLatch ownerEntered = new CountDownLatch(1);
    private boolean released;

    void waitForRelease() throws InterruptedException {
        synchronized (monitor) {
            ownerEntered.countDown();
            Thread.sleep(80);
            while (!released) monitor.wait();
        }
    }

    void release() throws InterruptedException {
        ownerEntered.await();
        synchronized (monitor) {
            released = true;
            monitor.notifyAll();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MonitorStateTimeline timeline = new MonitorStateTimeline();
        Thread owner = Thread.ofPlatform().name("owner-then-waiter").start(() -> {
            try { timeline.waitForRelease(); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        timeline.ownerEntered.await();
        Thread entrant = Thread.ofPlatform().name("entrant").start(() -> {
            try { timeline.release(); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        Thread.sleep(20);
        System.out.println("during-hold=" + entrant.getState());
        owner.join();
        entrant.join();
        System.out.println("owner-final=" + owner.getState());
        System.out.println("entrant-final=" + entrant.getState());
    }
}

20밀리초 시점에는 진입을 기다리는 스레드가 대개 BLOCKED이고, 마지막에는 두 스레드 모두 TERMINATED입니다. 시간 기반 관찰은 스케줄러에 따라 달라질 수 있으므로 자동 검증의 필수 단언으로 삼지 않습니다. 확정할 수 있는 조건은 두 스레드가 제한 시간 안에 끝나고 released 전이가 모니터 안에서 이루어졌다는 사실입니다.

ThreadMXBean으로 정지 증거 모으기

운영 진단에서는 이름, 상태, 잠금 이름, 잠금 소유자, 상위 스택을 한 번에 남기는 편이 좋습니다. 아래 도구는 현재 JVM의 모든 스레드를 조회해 대기 중인 항목만 축약 출력합니다. 애플리케이션 데이터나 비밀값을 덤프에 포함하지 않도록 출력 범위를 통제해야 합니다.

src/WaitingThreadReporter.java
import java.lang.management.LockInfo;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.EnumSet;

public final class WaitingThreadReporter {
    private static final EnumSet<Thread.State> WAIT_STATES = EnumSet.of(
            Thread.State.BLOCKED,
            Thread.State.WAITING,
            Thread.State.TIMED_WAITING);

    static void report() {
        ThreadMXBean bean = ManagementFactory.getThreadMXBean();
        ThreadInfo[] infos = bean.dumpAllThreads(true, true);
        for (ThreadInfo info : infos) {
            if (info == null || !WAIT_STATES.contains(info.getThreadState())) continue;
            LockInfo lock = info.getLockInfo();
            StackTraceElement[] stack = info.getStackTrace();
            String top = stack.length == 0 ? "<no-frame>" : stack[0].toString();
            System.out.printf("name=%s state=%s lock=%s owner=%s top=%s%n",
                    info.getThreadName(), info.getThreadState(), lock,
                    info.getLockOwnerName(), top);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread sleeper = Thread.ofPlatform().name("diagnostic-sleeper").start(() -> {
            try { Thread.sleep(200); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        report();
        sleeper.join();
    }
}

출력에는 JVM 내부 관리 스레드도 나타날 수 있습니다. 이름 접두어나 스레드 그룹으로 애플리케이션 작업을 필터링하고, 장애 시점 전후의 큐 깊이와 처리 카운터를 함께 수집합니다. 덤프만으로 데이터 조건을 알 수 없다면 코드에 안전한 진단 스냅샷을 추가합니다.

상태 관찰과 제어를 구분하는 판단표

목적사용 수단피해야 할 방식
작업 시작 합의CountDownLatchgetState 바쁜 반복
값 도착 대기BlockingQueue·Condition스레드 이름 폴링
장애 원인 수집ThreadMXBean·덤프상태 하나로 단정
완료 상한 검증시간 제한 join·Future 시간 제한무제한 join

가상 스레드에서는 운반자 스레드와의 관계까지 고려해야 하지만 애플리케이션 수준의 조건 규칙은 바뀌지 않습니다. 상태 enum은 진단 관점의 추상화이며 정확한 스케줄 단계 전체를 표현하지 않습니다. 실행 제어를 스레드 구현 세부사항과 결합하지 않으면 플랫폼 스레드와 가상 스레드 사이 이동도 쉬워집니다.

연습 문제

작업자들이 처리 건수를 AtomicLong에 올립니다. 감시자는 1초 간격으로 두 번 값을 읽어 증가가 없고 아직 종료되지 않은 스레드만 보고해야 합니다. 진단에는 이름, 상태, 상위 스택 한 줄을 넣되 작업자를 강제로 종료하지는 마세요. 감시와 복구 정책을 분리합니다.

정답과 해설

처리 카운터는 업무 진행을 나타내고 스레드 상태는 정지 위치를 설명합니다. 두 신호를 결합해야 잠시 WAITING인 정상 작업자와 실제로 처리량이 멈춘 작업자를 구별할 수 있습니다. 예제에서는 짧은 간격으로 실행 가능하게 두되 메서드 인자로 기간을 받습니다.

exercise/ProgressStallReporterSolution.java
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;

public final class ProgressStallReporterSolution {
    record Worker(Thread thread, AtomicLong completed) {}

    static void reportStalls(List<Worker> workers, Duration interval) throws InterruptedException {
        long[] before = workers.stream().mapToLong(w -> w.completed().get()).toArray();
        Thread.sleep(interval);
        for (int i = 0; i < workers.size(); i++) {
            Worker worker = workers.get(i);
            Thread thread = worker.thread();
            if (thread.isAlive() && worker.completed().get() == before[i]) {
                StackTraceElement[] stack = thread.getStackTrace();
                String top = stack.length == 0 ? "<unknown>" : stack[0].toString();
                System.out.printf("stalled name=%s state=%s top=%s%n",
                        thread.getName(), thread.getState(), top);
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicLong count = new AtomicLong();
        Thread idle = Thread.ofPlatform().name("idle-worker").start(() -> {
            try { Thread.sleep(300); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        reportStalls(List.of(new Worker(idle, count)), Duration.ofMillis(50));
        idle.join();
    }
}

보고가 발생해도 즉시 스레드를 죽이지 않습니다. 외부 서비스 응답을 기다리는 정상 구간일 수 있으므로 여러 연속 구간, 작업 유형별 기대 시간, 취소 안전성을 확인한 뒤 복구합니다. 보고서가 진단 증거를 제공하고 정책 계층이 재시작이나 경고를 결정하도록 책임을 나눕니다.

대기 상태는 위치이고 조건은 이유

BLOCKED와 WAITING은 스레드가 지금 어디에 멈췄는지를 알려 주지만 왜 그 상태가 끝나지 않는지는 공유 조건과 소유권 관계가 설명합니다. 덤프에서 모니터 소유자, 조건 호출, 업무 큐 깊이를 연결해야 정지 원인을 찾을 수 있습니다.

프로그램 제어를 상태 관찰에 맡기지 마세요. 래치, 조건, 큐처럼 사건의 의미를 표현하는 도구를 사용하고 getState와 ThreadMXBean은 그 프로토콜이 예상대로 움직이는지 관찰하는 창으로 제한합니다.