안동민 개발노트

본문 시작

BLOCKED·WAITING 상태

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

synchronized 기반 프로그램에는 서로 다른 대기가 겹칩니다.

모니터를 얻지 못한 스레드는 JVM의 락 획득 경로에서 BLOCKED로 보이고, 모니터를 가진 뒤 조건이 맞지 않아 wait()를 호출한 스레드는 객체 대기 집합에서 WAITING 또는 시간 제한이 있으면 TIMED_WAITING으로 보입니다.

알림을 받은 대기자는 조건 대기에서 빠져나와도 모니터를 다시 얻기 전까지 실행을 계속할 수 없습니다.

이 구분은 장애 분석에 유용하지만 애플리케이션 제어 수단은 아닙니다.

Thread.getState()로 WAITING을 확인한 뒤 신호를 보내는 코드는 관찰과 행동 사이에 상태가 달라질 수 있습니다.

상태 값은 순간 스냅샷이며 업무 이벤트가 아닙니다.

제어에는 CountDownLatch, 큐, 조건 술어처럼 명시적인 동기화 규칙을 사용하고 스레드 상태는 원인 추적에만 씁니다.


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

아래 프로그램은 작업자가 WAITING이 될 때까지 반복한 뒤 notifyAll을 보냅니다.

실습에서는 자주 동작하지만 정식 프로토콜로는 취약합니다.

상태 조회는 순간 관측이며 바쁜 반복은 CPU를 소비합니다. 이 원문에서는 같은 gate가 보호하는 open 조건이 실제 대기와 알림의 안전성을 담당합니다.

제한 시간도 없어 논리가 바뀌면 감시자까지 멈춥니다.

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 조건이 정하고 getState는 시작을 관측한다

ThreadStateHandshake의 상태 폴링, 보호된 open 플래그, wait 반복이 각각 맡는 역할을 구분합니다.

진행은 open 조건이 정하고 getState는 시작을 관측한다
원문의 위치하는 일보장 범위
main의 getState 반복WAITING을 읽을 때까지 바쁜 반복업무 준비 신호를 저장하지 않으며 시간 제한도 없습니다.
awaitOpengate 안에서 open이 false이면 wait조건 검사와 대기 등록이 같은 모니터 규칙을 따릅니다.
opengate 안에서 open=true 뒤 notifyAll대기보다 먼저 호출돼도 true 상태가 남아 이후 대기를 건너뛸 수 있습니다.
main의 getState 반복
하는 일: WAITING을 읽을 때까지 바쁜 반복
보장 범위: 업무 준비 신호를 저장하지 않으며 시간 제한도 없습니다.
awaitOpen
하는 일: gate 안에서 open이 false이면 wait
보장 범위: 조건 검사와 대기 등록이 같은 모니터 규칙을 따릅니다.
open
하는 일: gate 안에서 open=true 뒤 notifyAll
보장 범위: 대기보다 먼저 호출돼도 true 상태가 남아 이후 대기를 건너뛸 수 있습니다.

원문 main은 상태 조회 뒤 open을 호출합니다. 앞서 open하는 대체 순서의 안전성은 코드의 조건 규칙이며 이 main이 그 순서를 실행한 것은 아닙니다.

준비 완료를 반드시 알아야 한다면 작업자가 명시적 래치의 카운트를 줄이게 해야 합니다.


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

한 줄의 상태만 보고 교착이라고 단정하지 않습니다.

먼저 어떤 스레드가 모니터를 소유하는지 찾고, 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());
    }
}
래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다

MonitorStateTimeline에서 ownerEntered 신호, entrant 순간 상태, 두 join 뒤의 종료 상태가 각각 보장하는 범위입니다.

래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다
원문의 경계확인 가능한 사실확인하지 않은 사실
ownerEntered.await() 복귀owner가 모니터 안에서 countDown을 실행했습니다.복귀 순간에도 owner가 계속 모니터를 쥐고 있다는 보장은 아닙니다.
during-hold 출력main의 sleep(20) 뒤 entrant 상태를 한 번 읽습니다.owner의 sleep(80) 중인지, BLOCKED였는지는 실행 시점에 따라 다릅니다.
두 join 뒤 출력정상 완료하면 두 final 상태는 TERMINATED입니다.두 join은 시간 제한이 없어 전체 종료 상한을 검증하지 않습니다.
ownerEntered.await() 복귀
확인 가능한 사실: owner가 모니터 안에서 countDown을 실행했습니다.
확인하지 않은 사실: 복귀 순간에도 owner가 계속 모니터를 쥐고 있다는 보장은 아닙니다.
during-hold 출력
확인 가능한 사실: main의 sleep(20) 뒤 entrant 상태를 한 번 읽습니다.
확인하지 않은 사실: owner의 sleep(80) 중인지, BLOCKED였는지는 실행 시점에 따라 다릅니다.
두 join 뒤 출력
확인 가능한 사실: 정상 완료하면 두 final 상태는 TERMINATED입니다.
확인하지 않은 사실: 두 join은 시간 제한이 없어 전체 종료 상한을 검증하지 않습니다.

released는 같은 모니터 안에서 바뀌고 기다리는 쪽은 while로 재검사합니다. 중간 WAITING 상태나 두 스레드의 동시 상태를 따로 기록한 프로그램은 아닙니다.

시간 기반 관찰은 스케줄러에 따라 달라질 수 있으므로 자동 검증의 필수 단언으로 삼지 않습니다.

코드에서 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초 간격으로 두 번 값을 읽어 증가가 없고 아직 종료되지 않은 스레드만 보고해야 합니다.

진단에는 이름, 상태, 상위 스택 한 줄을 넣되 작업자를 강제로 종료하지는 마세요.

감시와 복구 정책을 분리합니다.

정답과 해설

처리 카운터는 업무 진행을 나타내고 스레드 상태는 정지 위치를 설명합니다.

카운터 무증가와 생존 여부를 함께 보면 정체 후보를 좁힐 수 있지만, 정상 대기와 비정상 정지의 원인까지 확정하지는 못합니다.

예제에서는 짧은 간격으로 실행 가능하게 두되 메서드 인자로 기간을 받습니다.

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();
    }
}
무증가 보고는 정체 후보이며 정지 원인의 확정은 아니다

ProgressStallReporterSolution은 처리 카운터의 두 조회와 스레드 생존·스택·상태를 순서대로 읽어 정체 후보를 출력합니다.

무증가 보고는 정체 후보이며 정지 원인의 확정은 아니다
원문의 단계검사 또는 출력판단의 한계
처음 카운터 조회각 completed 값을 before에 저장이 main의 count는 0이며 작업자는 증가시키지 않습니다.
interval 대기 뒤살아 있고 카운터가 같으면 보고 후보main의 interval은 50ms입니다. 정상 외부 대기도 같은 조건을 만족할 수 있습니다.
스택과 상태 조회이름·상태·상위 프레임을 출력별도 조회 사이에 종료할 수 있어 빈 스택 또는 TERMINATED도 가능합니다.
처음 카운터 조회
검사 또는 출력: 각 completed 값을 before에 저장
판단의 한계: 이 main의 count는 0이며 작업자는 증가시키지 않습니다.
interval 대기 뒤
검사 또는 출력: 살아 있고 카운터가 같으면 보고 후보
판단의 한계: main의 interval은 50ms입니다. 정상 외부 대기도 같은 조건을 만족할 수 있습니다.
스택과 상태 조회
검사 또는 출력: 이름·상태·상위 프레임을 출력
판단의 한계: 별도 조회 사이에 종료할 수 있어 빈 스택 또는 TERMINATED도 가능합니다.

main의 작업자는 sleep(300)을 호출하지만 보고 시점까지 생존한다는 보장은 없습니다. 따라서 출력이 없거나 후보 한 줄이 나올 수 있으며 보고 메서드는 스레드를 중단시키지 않습니다.

외부 서비스 응답을 기다리는 정상 구간일 수 있으므로 여러 연속 구간, 작업 유형별 기대 시간, 취소 안전성을 확인한 뒤 복구합니다.

보고서가 진단 증거를 제공하고 정책 계층이 재시작이나 경고를 결정하도록 책임을 나눕니다.


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

BLOCKED와 WAITING은 스레드가 지금 어디에 멈췄는지를 알려 주지만 왜 그 상태가 끝나지 않는지는 공유 조건과 소유권 관계가 설명합니다.

덤프에서 모니터 소유자, 조건 호출, 업무 큐 깊이를 연결해야 정지 원인을 찾을 수 있습니다.

프로그램 제어를 상태 관찰에 맡기지 마세요.

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