BLOCKED·WAITING 상태로 스레드 덤프 읽기
멈춘 스레드가 BLOCKED와 WAITING 가운데 어느 상태로 무엇을 기다리는지 스레드 덤프에서 찾는 방법과, getState()를 실행 순서를 맞추는 데 쓰면 안 되는 이유를 배웁니다.
여러 스레드가 함께 일하는 프로그램이 어느 순간 더 진행하지 않는데 CPU 사용률은 거의 0이라면, 스레드들이 무언가를 기다리며 멈춰 있을 가능성이 큽니다. 이때 가장 먼저 하는 일은 스레드 덤프(thread dump)를 떠서 각 스레드가 어떤 상태로 어디에 멈춰 있는지 보는 것입니다. 스레드 정보와 생명 주기에서 본 여섯 가지 상태 가운데 이런 상황에서 주로 만나는 것은 BLOCKED, WAITING, TIMED_WAITING입니다.
그런데 상태 이름만으로는 원인을 알 수 없습니다. 상태는 스레드가 지금 어디에서 멈춰 있는지를 알려 줄 뿐, 왜 그 기다림이 끝나지 않는지는 알려 주지 않습니다. 이 글에서는 먼저 synchronized를 쓰는 코드에서 두 상태가 어떻게 다른지 실행으로 확인하고, 실제 스레드 덤프에서 누가 무엇을 기다리는지 찾아내는 방법을 익힙니다. 또 상태를 읽는 getState()를 스레드 사이의 순서를 맞추는 데 쓰면 왜 위험한지, 교착 상태(deadlock)를 찾는 기능이 아무것도 찾지 못했는데도 프로그램이 멈추는 경우는 무엇인지 살펴봅니다.
모니터 앞의 두 가지 기다림
synchronized를 쓰는 코드에서 스레드는 두 곳에서 기다릴 수 있습니다. 첫째는 모니터의 입구입니다. 다른 스레드가 모니터를 쥐고 있으면 synchronized 블록에 들어가려는 스레드는 모니터가 풀릴 때까지 기다리는데, 이때의 상태가 BLOCKED입니다. 둘째는 wait와 notify로 조건 기다리기에서 본 대기 집합(wait set)입니다. 모니터를 가진 스레드가 조건이 맞지 않아 wait()를 부르면 모니터를 내려놓고 대기 집합에 들어갑니다. 시간 제한 없이 기다리면 WAITING이고, wait(100)처럼 시간 제한을 주면 TIMED_WAITING입니다.
두 기다림은 끝나는 방법이 다릅니다. BLOCKED는 모니터를 쥔 스레드가 블록을 빠져나가면 끝나고, WAITING은 다른 스레드가 같은 객체에 notify()나 notifyAll()을 불러야 끝납니다. 게다가 알림을 받았다고 바로 실행되는 것도 아닙니다. 대기 집합에서 나온 스레드는 모니터를 다시 얻어야 wait()에서 돌아올 수 있으므로, 그동안은 BLOCKED가 됩니다. Thread.State API 문서도 BLOCKED를 synchronized 블록이나 메서드에 들어가려고, 또는 Object.wait를 부른 뒤 다시 들어가려고 모니터 락을 기다리는 상태라고 설명합니다.
| 상태 | 어디서 기다리는가 | 무엇이 기다림을 끝내는가 |
|---|---|---|
| BLOCKED | 모니터 입구 | 모니터를 쥔 스레드가 블록을 빠져나감 |
| WAITING | 대기 집합(wait()) | 같은 객체에 대한 notify()·notifyAll() |
| TIMED_WAITING | wait(100)의 대기 집합, 또는 sleep(100) | 알림이나 시간 만료(sleep은 시간 만료만) |
인터럽트에 대한 반응도 다릅니다. wait()나 sleep()으로 기다리는 스레드는 인터럽트되면 InterruptedException을 받고 기다림을 끝냅니다. 하지만 BLOCKED 스레드는 인터럽트되어도 인터럽트 플래그만 켜질 뿐 계속 모니터를 기다립니다. 실제로 BLOCKED 스레드에 interrupt()를 불러 보면 상태는 BLOCKED 그대로였습니다. 그러니 덤프에 오래 BLOCKED로 남은 스레드가 있다면, 그 모니터를 쥔 스레드를 찾아 왜 블록을 빠져나가지 않는지 살펴야 합니다.
다음 프로그램은 두 가지 기다림을 차례로 만들어 냅니다. owner-then-waiter 스레드(아래에서는 줄여서 owner)는 모니터에 들어가 래치를 내린 뒤, 모니터를 쥔 채 80밀리초 동안 잠듭니다. 그다음 released가 false이므로 wait()로 모니터를 내려놓습니다. entrant 스레드는 같은 모니터에 들어가 released를 true로 바꾸고 notifyAll()을 부릅니다. main은 owner가 모니터에 들어갔다는 것을 래치로 확인한 뒤에 entrant를 시작하고, 20밀리초 뒤 entrant의 상태를 한 번 읽습니다.
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());
}
}래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다
래치 복귀→중간 상태 조회→두 join 뒤 종료 조회의 실제 시간표입니다. owner 보유 지속·BLOCKED 관측을 보장하지 않는 한계를 각 단계에 남깁니다.
MonitorStateTimeline에서 ownerEntered 신호, entrant 순간 상태, 두 join 뒤의 종료 상태가 각각 보장하는 범위입니다.
래치·상태·종료 · 관측 경계
래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다
래치는 모니터 진입을 알리고, 중간 상태는 한 순간을 보여 줍니다. 두 종료 상태는 각각 join한 뒤 확인합니다.
설명과 원래 예시 값
ownerEntered.await()복귀- 확인 가능한 사실
- owner가 모니터 안에서 countDown을 실행했습니다.
- 확인하지 않은 사실
- 복귀 순간에도 owner가 계속 모니터를 쥐고 있다는 보장은 아닙니다.
during-hold출력- 확인 가능한 사실
- main의 sleep(20) 뒤 entrant 상태를 한 번 읽습니다.
- 확인하지 않은 사실
- owner의 sleep(80) 중인지, BLOCKED였는지는 실행 시점에 따라 다릅니다.
두 join 뒤 출력
- 확인 가능한 사실
- 정상 완료하면 두 final 상태는 TERMINATED입니다.
- 확인하지 않은 사실
- 두 join은 시간 제한이 없어 전체 종료 상한을 검증하지 않습니다.
released는 같은 모니터 안에서 바뀌고 기다리는 쪽은 while로 재검사합니다. 중간 WAITING 상태나 두 스레드의 동시 상태를 따로 기록한 프로그램은 아닙니다.
이 예의 조건
- 중간 조회는 한 순간이며 두 join에 시간 제한이 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다 래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다 MonitorStateTimeline에서 ownerEntered 신호, entrant 순간 상태, 두 join 뒤의 종료 상태가 각각 보장하는 범위입니다. 래치·상태·종료 · 관측 경계 래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다 래치는 모니터 진입을 알리고, 중간 상태는 한 순간을 보여 줍니다. 두 종료 상태는 각각 join한 뒤 확인합니다. ownerEntered.await() 복귀 확인 가능한 사실 owner가 모니터 안에서 countDown을 실행했습니다. 확인하지 않은 사실 복귀 순간에도 owner가 계속 모니터를 쥐고 있다는 보장은 아닙니다. during-hold 출력 확인 가능한 사실 main의 sleep(20) 뒤 entrant 상태를 한 번 읽습니다. 확인하지 않은 사실 owner의 sleep(80) 중인지, BLOCKED였는지는 실행 시점에 따라 다릅니다. 두 join 뒤 출력 확인 가능한 사실 정상 완료하면 두 final 상태는 TERMINATED입니다. 확인하지 않은 사실 두 join은 시간 제한이 없어 전체 종료 상한을 검증하지 않습니다. released는 같은 모니터 안에서 바뀌고 기다리는 쪽은 while로 재검사합니다. 중간 WAITING 상태나 두 스레드의 동시 상태를 따로 기록한 프로그램은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| owner 신호 owner가 모니터 안에서 countDown합니다. |
|
|
| entrant 조회 main sleep 20 뒤 during-hold 상태를 한 번 읽습니다. |
|
|
| 종료 회수 released 조건을 while로 재검사하고 두 join을 끝냅니다. |
|
|
래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다
래치 복귀→중간 상태 조회→두 join 뒤 종료 조회의 실제 시간표입니다. owner 보유 지속·BLOCKED 관측을 보장하지 않는 한계를 각 단계에 남깁니다.
MonitorStateTimeline에서 ownerEntered 신호, entrant 순간 상태, 두 join 뒤의 종료 상태가 각각 보장하는 범위입니다.
래치·상태·종료 · 관측 경계
래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다
래치는 모니터 진입을 알리고, 중간 상태는 한 순간을 보여 줍니다. 두 종료 상태는 각각 join한 뒤 확인합니다.
설명과 원래 예시 값
ownerEntered.await()복귀- 확인 가능한 사실
- owner가 모니터 안에서 countDown을 실행했습니다.
- 확인하지 않은 사실
- 복귀 순간에도 owner가 계속 모니터를 쥐고 있다는 보장은 아닙니다.
during-hold출력- 확인 가능한 사실
- main의 sleep(20) 뒤 entrant 상태를 한 번 읽습니다.
- 확인하지 않은 사실
- owner의 sleep(80) 중인지, BLOCKED였는지는 실행 시점에 따라 다릅니다.
두 join 뒤 출력
- 확인 가능한 사실
- 정상 완료하면 두 final 상태는 TERMINATED입니다.
- 확인하지 않은 사실
- 두 join은 시간 제한이 없어 전체 종료 상한을 검증하지 않습니다.
released는 같은 모니터 안에서 바뀌고 기다리는 쪽은 while로 재검사합니다. 중간 WAITING 상태나 두 스레드의 동시 상태를 따로 기록한 프로그램은 아닙니다.
이 예의 조건
- 중간 조회는 한 순간이며 두 join에 시간 제한이 없습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다 래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다 MonitorStateTimeline에서 ownerEntered 신호, entrant 순간 상태, 두 join 뒤의 종료 상태가 각각 보장하는 범위입니다. 래치·상태·종료 · 관측 경계 래치 신호·중간 상태·종료 조회는 서로 다른 사실을 보여준다 래치는 모니터 진입을 알리고, 중간 상태는 한 순간을 보여 줍니다. 두 종료 상태는 각각 join한 뒤 확인합니다. ownerEntered.await() 복귀 확인 가능한 사실 owner가 모니터 안에서 countDown을 실행했습니다. 확인하지 않은 사실 복귀 순간에도 owner가 계속 모니터를 쥐고 있다는 보장은 아닙니다. during-hold 출력 확인 가능한 사실 main의 sleep(20) 뒤 entrant 상태를 한 번 읽습니다. 확인하지 않은 사실 owner의 sleep(80) 중인지, BLOCKED였는지는 실행 시점에 따라 다릅니다. 두 join 뒤 출력 확인 가능한 사실 정상 완료하면 두 final 상태는 TERMINATED입니다. 확인하지 않은 사실 두 join은 시간 제한이 없어 전체 종료 상한을 검증하지 않습니다. released는 같은 모니터 안에서 바뀌고 기다리는 쪽은 while로 재검사합니다. 중간 WAITING 상태나 두 스레드의 동시 상태를 따로 기록한 프로그램은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| owner 신호 owner가 모니터 안에서 countDown합니다. |
|
|
| entrant 조회 main sleep 20 뒤 during-hold 상태를 한 번 읽습니다. |
|
|
| 종료 회수 released 조건을 while로 재검사하고 두 join을 끝냅니다. |
|
|
during-hold=BLOCKED
owner-final=TERMINATED
entrant-final=TERMINATED첫 줄은 entrant가 모니터 입구에서 기다리는 모습입니다. main이 상태를 읽을 때 owner는 모니터를 쥔 채 sleep(80) 중이므로, synchronized (monitor)에 들어가려던 entrant는 BLOCKED입니다. 잠든 스레드도 쥐고 있던 모니터는 놓지 않는다는 점에 주의하세요. 80밀리초가 지나 owner가 wait()를 부르면 모니터가 풀리고, entrant가 들어가 released를 true로 바꾸고 알립니다. owner는 대기 집합에서 나온 뒤 entrant가 블록을 빠져나가기를 기다려 모니터를 다시 얻고, while에서 released가 true인 것을 확인한 뒤 끝납니다. 나머지 두 줄은 main이 두 스레드를 모두 join()한 뒤에 읽었으므로 언제나 TERMINATED입니다.
이 프로그램에서 실행 순서를 정하는 것은 래치와 released 조건이지 getState()가 아닙니다. 래치는 owner가 모니터에 들어갔다는 사실을 알려 주고, released는 owner가 언제 기다림을 끝내도 되는지를 정합니다. getState()는 그 사이의 한 순간을 기록할 뿐이라서 첫 줄은 실행 환경에 따라 달라질 수 있습니다. JDK 21에서 열다섯 번 실행했을 때는 모두 BLOCKED였습니다. 하지만 시스템이 몹시 바빠 entrant가 20밀리초 안에 입구에 닿지 못하면 RUNNABLE이 나올 수 있고, 반대로 main이 80밀리초 넘게 늦어지면 이미 끝난 entrant의 TERMINATED가 나올 수도 있습니다.
getState()로 실행 순서를 맞추면 생기는 문제
getState()로 상태를 읽을 수 있으니, 이것을 신호처럼 쓰고 싶어질 수 있습니다. 아래 프로그램의 main은 작업자가 WAITING이 될 때까지, 즉 문 앞에서 기다리기 시작할 때까지 상태를 반복해서 확인한 뒤 문을 엽니다.
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는 시작을 관측한다
상태 폴링 뒤 open=true/notifyAll→재검사→join 완료의 진행입니다. 신호보다 영속 조건이 진행을 정함을 단계로 따라가기 좋습니다.
ThreadStateHandshake의 상태 폴링, 보호된 open 플래그, wait 반복이 각각 맡는 역할을 구분합니다.
조건과 관측 · 역할 구분
진행은 open 조건이 정하고 getState는 시작을 관측한다
open 플래그가 실제 진행을 정합니다. getState의 바쁜 반복은 준비 완료를 저장하는 신호가 아닙니다.
설명과 원래 예시 값
main의 getState 반복
- 하는 일
- WAITING을 읽을 때까지 바쁜 반복
- 보장 범위
- 업무 준비 신호를 저장하지 않으며 시간 제한도 없습니다.
awaitOpen
- 하는 일
- gate 안에서 open이 false이면 wait
- 보장 범위
- 조건 검사와 대기 등록이 같은 모니터 규칙을 따릅니다.
open
- 하는 일
- gate 안에서 open=true 뒤 notifyAll
- 보장 범위
- 대기보다 먼저 호출돼도 true 상태가 남아 이후 대기를 건너뛸 수 있습니다.
원문 main은 상태 조회 뒤 open을 호출합니다. 앞서 open하는 대체 순서의 안전성은 코드의 조건 규칙이며 이 main이 그 순서를 실행한 것은 아닙니다.
이 예의 조건
- 원 main은 worker를 시작하고 WAITING이 관측될 때까지 폴링한 뒤 open을 호출합니다.
- open을 먼저 호출하는 대체 순서는 코드 조건의 별도 경로이며 원 main의 후속 실행으로 연결하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 진행은 open 조건이 정하고 getState는 시작을 관측한다 진행은 open 조건이 정하고 getState는 시작을 관측한다 ThreadStateHandshake의 상태 폴링, 보호된 open 플래그, wait 반복이 각각 맡는 역할을 구분합니다. 조건과 관측 · 역할 구분 진행은 open 조건이 정하고 getState는 시작을 관측한다 open 플래그가 실제 진행을 정합니다. getState의 바쁜 반복은 준비 완료를 저장하는 신호가 아닙니다. main의 getState 반복 하는 일 WAITING을 읽을 때까지 바쁜 반복 보장 범위 업무 준비 신호를 저장하지 않으며 시간 제한도 없습니다. awaitOpen 하는 일 gate 안에서 open이 false이면 wait 보장 범위 조건 검사와 대기 등록이 같은 모니터 규칙을 따릅니다. open 하는 일 gate 안에서 open=true 뒤 notifyAll 보장 범위 대기보다 먼저 호출돼도 true 상태가 남아 이후 대기를 건너뛸 수 있습니다. 원문 main은 상태 조회 뒤 open을 호출합니다. 앞서 open하는 대체 순서의 안전성은 코드의 조건 규칙이며 이 main이 그 순서를 실행한 것은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| worker의 조건 대기 worker가 gate 안에서 open=false를 검사하고 wait로 모니터를 반납합니다. |
|
|
| main의 WAITING 폴링 main은 worker의 getState가 WAITING일 때까지 바쁘게 조회합니다. |
|
|
| open 기록과 알림 main은 gate 안에서 open=true를 기록하고 notifyAll합니다. |
|
|
| worker 종료 회수 worker가 true를 재확인해 대기를 끝내고 main의 join이 정상 반환합니다. |
|
|
진행은 open 조건이 정하고 getState는 시작을 관측한다
상태 폴링 뒤 open=true/notifyAll→재검사→join 완료의 진행입니다. 신호보다 영속 조건이 진행을 정함을 단계로 따라가기 좋습니다.
ThreadStateHandshake의 상태 폴링, 보호된 open 플래그, wait 반복이 각각 맡는 역할을 구분합니다.
조건과 관측 · 역할 구분
진행은 open 조건이 정하고 getState는 시작을 관측한다
open 플래그가 실제 진행을 정합니다. getState의 바쁜 반복은 준비 완료를 저장하는 신호가 아닙니다.
설명과 원래 예시 값
main의 getState 반복
- 하는 일
- WAITING을 읽을 때까지 바쁜 반복
- 보장 범위
- 업무 준비 신호를 저장하지 않으며 시간 제한도 없습니다.
awaitOpen
- 하는 일
- gate 안에서 open이 false이면 wait
- 보장 범위
- 조건 검사와 대기 등록이 같은 모니터 규칙을 따릅니다.
open
- 하는 일
- gate 안에서 open=true 뒤 notifyAll
- 보장 범위
- 대기보다 먼저 호출돼도 true 상태가 남아 이후 대기를 건너뛸 수 있습니다.
원문 main은 상태 조회 뒤 open을 호출합니다. 앞서 open하는 대체 순서의 안전성은 코드의 조건 규칙이며 이 main이 그 순서를 실행한 것은 아닙니다.
이 예의 조건
- 원 main은 worker를 시작하고 WAITING이 관측될 때까지 폴링한 뒤 open을 호출합니다.
- open을 먼저 호출하는 대체 순서는 코드 조건의 별도 경로이며 원 main의 후속 실행으로 연결하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 진행은 open 조건이 정하고 getState는 시작을 관측한다 진행은 open 조건이 정하고 getState는 시작을 관측한다 ThreadStateHandshake의 상태 폴링, 보호된 open 플래그, wait 반복이 각각 맡는 역할을 구분합니다. 조건과 관측 · 역할 구분 진행은 open 조건이 정하고 getState는 시작을 관측한다 open 플래그가 실제 진행을 정합니다. getState의 바쁜 반복은 준비 완료를 저장하는 신호가 아닙니다. main의 getState 반복 하는 일 WAITING을 읽을 때까지 바쁜 반복 보장 범위 업무 준비 신호를 저장하지 않으며 시간 제한도 없습니다. awaitOpen 하는 일 gate 안에서 open이 false이면 wait 보장 범위 조건 검사와 대기 등록이 같은 모니터 규칙을 따릅니다. open 하는 일 gate 안에서 open=true 뒤 notifyAll 보장 범위 대기보다 먼저 호출돼도 true 상태가 남아 이후 대기를 건너뛸 수 있습니다. 원문 main은 상태 조회 뒤 open을 호출합니다. 앞서 open하는 대체 순서의 안전성은 코드의 조건 규칙이며 이 main이 그 순서를 실행한 것은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| worker의 조건 대기 worker가 gate 안에서 open=false를 검사하고 wait로 모니터를 반납합니다. |
|
|
| main의 WAITING 폴링 main은 worker의 getState가 WAITING일 때까지 바쁘게 조회합니다. |
|
|
| open 기록과 알림 main은 gate 안에서 open=true를 기록하고 notifyAll합니다. |
|
|
| worker 종료 회수 worker가 true를 재확인해 대기를 끝내고 main의 join이 정상 반환합니다. |
|
|
done=true몇 번을 실행해도 done=true가 나오니 문제없어 보입니다. 하지만 이 반복문이 하는 일을 따져 보면 문제가 세 가지 있습니다. 첫째, WAITING은 작업자가 어디선가 시간 제한 없이 기다리고 있다는 뜻일 뿐, awaitOpen()의 wait()에 도착했다는 뜻이 아닙니다. 나중에 작업자 코드에 큐에서 값을 꺼내거나 다른 스레드를 join()하는 기다림이 추가되면, main은 그 엉뚱한 기다림을 보고 준비가 끝났다고 판단합니다.
둘째, 작업자가 WAITING에 끝내 이르지 못하면 main은 영원히 반복문을 돕니다. 작업자가 awaitOpen()에 들어가기 전에 예외로 끝나면 상태는 TERMINATED가 되므로 반복 조건이 계속 참이고, 이 반복문에는 시간 제한도 없습니다. 작업자가 시작하자마자 예외를 던지게 바꾸고 반복문에 2초 제한을 걸어 실행해 보면, main은 2초 동안 상태를 천만 번 넘게 확인한 끝에 TERMINATED를 읽고 멈췄습니다. 셋째, 그동안 main은 CPU를 계속 씁니다. Thread.onSpinWait()는 지금 바쁜 대기 중이라는 힌트를 줄 뿐 스레드를 재우지 않습니다.
그렇다면 이 프로그램이 매번 성공한 이유는 무엇일까요? 상태 확인 덕분이 아니라 open 플래그 덕분입니다. open()은 gate 안에서 open을 true로 바꾸고, awaitOpen()은 같은 gate 안에서 open을 확인한 뒤에만 기다립니다. 그래서 open()이 작업자가 기다리기 전에 불리더라도 true가 남아 있어, 작업자는 기다리지 않고 지나갑니다. 다시 말해 반복문을 통째로 지워도 이 프로그램은 올바르게 동작합니다. Thread.getState API 문서도 이 메서드가 시스템 상태를 모니터링하려고 만든 것이지 동기화를 제어하려고 만든 것이 아니라고 밝힙니다.
작업자가 특정 지점에 도착했다는 사실을 정말로 알아야 한다면, 그 사실을 작업자가 직접 남기게 합니다. CountDownLatch가 그런 도구입니다.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
public final class LatchHandshake {
private final Object gate = new Object();
private final CountDownLatch reached = new CountDownLatch(1);
private boolean open;
void awaitOpen() throws InterruptedException {
reached.countDown(); // "여기까지 왔다"는 사실을 래치에 남깁니다
synchronized (gate) {
while (!open) gate.wait();
}
}
void open() {
synchronized (gate) {
open = true;
gate.notifyAll();
}
}
public static void main(String[] args) throws InterruptedException {
LatchHandshake demo = new LatchHandshake();
Thread worker = Thread.ofPlatform().start(() -> {
try { demo.awaitOpen(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
boolean reached = demo.reached.await(1, TimeUnit.SECONDS);
System.out.println("reached=" + reached);
demo.open();
worker.join();
System.out.println("done=" + !worker.isAlive());
}
}reached=true
done=true작업자는 awaitOpen()에 들어오자마자 reached.countDown()으로 래치를 내리고, main은 reached.await(1, TimeUnit.SECONDS)로 그 사실을 기다립니다. 래치는 한 번 내려가면 계속 내려가 있으므로, main이 늦게 확인해도 그 사실을 놓치지 않습니다. 작업자가 래치를 내리기 전에 예외로 끝나면 await가 1초 뒤 false를 돌려주므로 main이 영원히 기다리지도 않습니다. 실제로 countDown() 앞에서 예외를 던지게 바꾸면 reached=false가 출력됩니다.
래치가 알려 주는 것은 작업자가 countDown()을 실행했다는 것까지이고, 이미 wait()에 들어갔다는 것은 아닙니다. 그래도 문제가 없는 이유는 앞에서 본 것처럼 open 플래그가 순서와 상관없이 올바른 결과를 만들어 주기 때문입니다. 스레드 사이의 순서는 이렇게 래치나 조건 같은 동기화 도구로 맞추고, getState()는 상황을 들여다보는 데만 씁니다.
스레드 덤프에서 기다리는 대상 찾기
이제 실제 스레드 덤프를 읽어 보겠습니다. 다음 프로그램은 서로 다른 이유로 기다리는 스레드 여섯 개를 만듭니다. 앞의 둘은 synchronized 모니터를, 가운데 둘은 조건을, 뒤의 둘은 ReentrantLock을 두고 기다립니다. 상태를 출력한 뒤 30초 동안 살아 있으므로, 그동안 다른 터미널에서 덤프를 뜰 수 있습니다.
import java.util.List;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public final class WaitingThreadsDemo {
private static final Object monitor = new Object();
private static final Object gate = new Object();
private static final ReentrantLock lock = new ReentrantLock();
private static final Condition notEmpty = lock.newCondition();
private static boolean gateOpen; // gate가 보호합니다. 이 예제에서는 끝내 true가 되지 않습니다.
private static boolean itemReady; // lock이 보호합니다. 역시 true가 되지 않습니다.
private static Thread start(String name, Runnable task) throws InterruptedException {
Thread thread = Thread.ofPlatform().daemon().name(name).start(task);
Thread.sleep(100); // 다음 스레드를 시작하기 전에 이 스레드가 자리를 잡을 시간을 줍니다
return thread;
}
private static void sleepLong() {
try {
Thread.sleep(60_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
List<Thread> threads = List.of(
start("owner", () -> {
synchronized (monitor) { sleepLong(); }
}),
start("entrant", () -> {
synchronized (monitor) { }
}),
start("waiter", () -> {
synchronized (gate) {
try {
while (!gateOpen) gate.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}),
start("consumer", () -> {
lock.lock();
try {
while (!itemReady) notEmpty.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}),
start("lock-owner", () -> {
lock.lock();
try { sleepLong(); } finally { lock.unlock(); }
}),
start("lock-entrant", () -> {
lock.lock();
lock.unlock();
}));
for (Thread thread : threads) {
System.out.printf("%-12s %s%n", thread.getName(), thread.getState());
}
System.out.println("pid=" + ProcessHandle.current().pid());
Thread.sleep(30_000); // 이 사이에 다른 터미널에서 스레드 덤프를 뜹니다
}
}owner TIMED_WAITING
entrant BLOCKED
waiter WAITING
consumer WAITING
lock-owner TIMED_WAITING
lock-entrant WAITING
pid=22802마지막 줄의 프로세스 번호(pid)는 실행할 때마다 다릅니다. 상태만 보면 waiter, consumer, lock-entrant가 모두 WAITING이라 서로 구별되지 않습니다. 락을 얻으려고 기다리는 lock-entrant가 BLOCKED가 아니라 WAITING인 점도 눈여겨보세요. 스레드 정보와 생명 주기에서 본 것처럼 BLOCKED는 synchronized 모니터를 기다릴 때만 쓰이고, ReentrantLock은 내부에서 LockSupport.park()로 기다리기 때문입니다. 셋을 구별하려면 스택을 봐야 합니다. 스레드를 만들 때 이름을 붙여 둔 것도 덤프에서 스레드를 찾기 쉽게 하려는 것입니다.
덤프는 JDK에 함께 들어 있는 jcmd로 뜹니다. 출력된 pid를 넣어 jcmd 22802 Thread.print -l을 실행하면 모든 스레드의 상태와 스택이 출력되고, jstack -l 22802도 같은 내용을 보여 줍니다. -l은 ReentrantLock 같은 락의 소유자까지 보여 달라는 옵션입니다. 아래는 JDK 21에서 뜬 덤프 가운데 모니터를 기다리는 세 스레드 부분이며, ...은 줄인 부분입니다.
"owner" #18 [22821] daemon prio=5 os_prio=0 cpu=0.22ms elapsed=1.73s tid=0x00007ff61863f3a0 nid=22821 waiting on condition [0x00007ff61ca18000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep0([email protected]/Native Method)
at java.lang.Thread.sleep([email protected]/Thread.java:509)
at WaitingThreadsDemo.sleepLong(WaitingThreadsDemo.java:21)
at WaitingThreadsDemo.lambda$main$0(WaitingThreadsDemo.java:30)
- locked <0x00000000a851a8d8> (a java.lang.Object)
...
"entrant" #19 [22843] daemon prio=5 os_prio=0 cpu=0.30ms elapsed=1.63s tid=0x00007ff618640aa0 nid=22843 waiting for monitor entry [0x00007ff61c918000]
java.lang.Thread.State: BLOCKED (on object monitor)
at WaitingThreadsDemo.lambda$main$1(WaitingThreadsDemo.java:33)
- waiting to lock <0x00000000a851a8d8> (a java.lang.Object)
...
"waiter" #20 [22845] daemon prio=5 os_prio=0 cpu=0.28ms elapsed=1.53s tid=0x00007ff618641e10 nid=22845 in Object.wait() [0x00007ff61c818000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait0([email protected]/Native Method)
- waiting on <0x00000000a851a8e8> (a java.lang.Object)
at java.lang.Object.wait([email protected]/Object.java:366)
at java.lang.Object.wait([email protected]/Object.java:339)
at WaitingThreadsDemo.lambda$main$2(WaitingThreadsDemo.java:38)
- locked <0x00000000a851a8e8> (a java.lang.Object)
...각 스레드는 이름과 번호가 적힌 머리 줄로 시작하고, 다음 줄에 상태가, 그 아래에 가장 최근에 호출한 메서드부터 스택이 나옵니다. 스택 사이사이의 -로 시작하는 줄이 락에 관한 정보입니다. owner의 스택에는 - locked <0x00000000a851a8d8>가 있습니다. 이 주소의 객체, 즉 monitor의 모니터를 쥔 채 sleep 중이라는 뜻입니다. entrant에는 - waiting to lock <0x00000000a851a8d8>가 있습니다. 같은 주소의 모니터에 들어가려고 기다린다는 뜻이고, 그래서 상태가 BLOCKED입니다. 이렇게 BLOCKED 스레드가 기다리는 주소를 덤프 안에서 검색해 - locked로 쥐고 있는 스레드를 찾으면 누가 길을 막고 있는지 알 수 있습니다.
waiter의 - waiting on <0x00000000a851a8e8>는 gate 객체의 대기 집합에서 알림을 기다린다는 뜻입니다. 그 아래에 같은 주소로 - locked 줄도 찍히는데, 이것은 이 프레임이 synchronized (gate)에 들어올 때 모니터를 얻었다는 기록입니다. 바로 위의 waiting on 줄이 보여 주듯 wait() 중에는 이 모니터를 내려놓은 상태이므로, 다른 스레드가 gate에 들어오는 것을 막지 않습니다. 머리 줄 끝의 waiting for monitor entry와 in Object.wait()도 두 기다림을 구별하는 단서입니다.
다음은 같은 덤프에서 ReentrantLock과 Condition을 기다리는 세 스레드 부분입니다.
"consumer" #21 [22846] daemon prio=5 os_prio=0 cpu=1.83ms elapsed=1.43s tid=0x00007ff618642fe0 nid=22846 waiting on condition [0x00007ff61c718000]
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park([email protected]/Native Method)
- parking to wait for <0x00000000a851a9f0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
...
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await([email protected]/AbstractQueuedSynchronizer.java:1746)
at WaitingThreadsDemo.lambda$main$3(WaitingThreadsDemo.java:47)
...
"lock-owner" #22 [22847] daemon prio=5 os_prio=0 cpu=0.24ms elapsed=1.32s tid=0x00007ff618644200 nid=22847 waiting on condition [0x00007ff61c618000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
...
at WaitingThreadsDemo.lambda$main$4(WaitingThreadsDemo.java:56)
...
Locked ownable synchronizers:
- <0x00000000a851a9d0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
"lock-entrant" #23 [22870] daemon prio=5 os_prio=0 cpu=0.32ms elapsed=1.22s tid=0x00007ff6186455e0 nid=22870 waiting on condition [0x00007ff61c518000]
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park([email protected]/Native Method)
- parking to wait for <0x00000000a851a9d0> (a java.util.concurrent.locks.ReentrantLock$NonfairSync)
...
at java.util.concurrent.locks.ReentrantLock.lock([email protected]/ReentrantLock.java:322)
at WaitingThreadsDemo.lambda$main$5(WaitingThreadsDemo.java:59)
...consumer와 lock-entrant는 둘 다 WAITING (parking)이고 parking to wait for 줄이 있지만, 괄호 안의 타입이 다릅니다. consumer가 기다리는 것은 ConditionObject, 즉 Condition의 대기실이고, 스택에도 ConditionObject.await가 보입니다. 이 스레드를 깨우려면 누군가 그 조건에 신호를 보내야 합니다. 반면 lock-entrant가 기다리는 것은 ReentrantLock$NonfairSync, 즉 비공정 ReentrantLock 자체이고 스택에는 ReentrantLock.lock이 보입니다. 락이 풀리기를 기다리는 것입니다.
그 락을 누가 쥐고 있는지는 -l 옵션이 출력한 Locked ownable synchronizers 부분에서 찾습니다. lock-owner 아래에 lock-entrant가 기다리는 것과 같은 주소 0x00000000a851a9d0이 있으므로, lock-owner가 락을 쥔 채 잠들어 있어서 lock-entrant가 들어가지 못하는 것입니다. 모니터는 - locked와 - waiting to lock의 주소로, ReentrantLock은 parking to wait for와 Locked ownable synchronizers의 주소로 짝을 맞춘다고 기억해 두면 됩니다.
같은 WAITING이라도 스택을 보면 무엇을 기다리는지가 드러납니다. 예를 들어 Thread.join()으로 다른 스레드가 끝나기를 기다리는 스레드는 WAITING (on object monitor)로 나옵니다. 스택에는 Thread.join이 부른 Object.wait가 보이고, - waiting on 줄이 가리키는 대상은 java.lang.Thread 객체입니다. 이런 스레드는 대상 스레드가 끝나야 깨어나므로, 누가 notify()를 보내 준다고 해결되지 않습니다.
덤프 하나는 한 순간을 찍은 사진이라는 점도 기억해야 합니다. 작업을 기다리는 스레드 풀의 일꾼처럼 WAITING이 정상인 스레드도 많습니다. 그래서 몇 초 간격으로 덤프를 두세 번 떠서, 일이 진행되어야 할 시간인데도 같은 위치에 계속 머무는 스레드를 찾는 편이 정확합니다. 덤프를 뜨는 도구와 다른 진단 방법은 스레드 덤프와 프로파일러로 장애 원인 찾기에서 더 다룹니다.
교착 탐지에 잡히지 않는 멈춤
JVM은 교착 상태를 찾아 주는 기능도 제공합니다. java.lang.management 패키지의 ThreadMXBean에 있는 findDeadlockedThreads()는 두 스레드가 서로 상대가 쥔 락을 기다리는 것처럼, 락을 기다리는 관계가 원을 이루는 스레드들을 찾아 그 ID를 돌려줍니다. 그런 스레드가 없으면 null을 돌려줍니다.
그런데 이 기능이 아무것도 찾지 못했다고 해서 프로그램이 멈추지 않았다는 뜻은 아닙니다. 다음 프로그램은 wait와 notify로 조건 기다리기의 '더 깊이'에서 설명한 상황, 즉 다른 객체의 모니터를 쥔 채 wait()하는 상황을 만듭니다.
import java.lang.management.ManagementFactory;
import java.util.Arrays;
public final class NestedMonitorStall {
private final Object outer = new Object();
private final Object inner = new Object();
private boolean ready;
void awaitReady() throws InterruptedException {
synchronized (outer) {
synchronized (inner) {
while (!ready) inner.wait(); // inner만 내려놓고 outer는 계속 쥐고 있습니다
}
}
}
void markReady() {
synchronized (outer) { // waiter가 쥔 outer 앞에서 멈춥니다
synchronized (inner) {
ready = true;
inner.notifyAll();
}
}
}
public static void main(String[] args) throws InterruptedException {
NestedMonitorStall stall = new NestedMonitorStall();
Thread waiter = Thread.ofPlatform().daemon().name("waiter").start(() -> {
try { stall.awaitReady(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread.sleep(100);
Thread marker = Thread.ofPlatform().daemon().name("marker").start(stall::markReady);
Thread.sleep(100);
System.out.println("waiter=" + waiter.getState() + " marker=" + marker.getState());
long[] deadlocked = ManagementFactory.getThreadMXBean().findDeadlockedThreads();
System.out.println("deadlocked=" + Arrays.toString(deadlocked));
}
}waiter=WAITING marker=BLOCKED
deadlocked=nullwaiter는 outer와 inner의 모니터를 차례로 얻은 뒤 inner.wait()를 부릅니다. wait()는 호출 대상인 inner의 모니터만 내려놓으므로 waiter는 outer를 쥔 채 잠듭니다. marker는 ready를 바꾸기 전에 outer부터 얻어야 하는데, waiter가 놓지 않으니 입구에서 BLOCKED로 멈춥니다. waiter를 깨울 수 있는 유일한 스레드가 waiter 때문에 들어오지 못하니, 두 스레드는 영원히 멈춘 상태입니다. 두 스레드를 데몬으로 만든 것은 이 예제가 끝날 수 있게 하기 위해서입니다.
그런데도 findDeadlockedThreads()는 null을 돌려줍니다. 교착 탐지는 락을 기다리는 스레드들의 관계에서 원을 찾는데, waiter는 락이 아니라 알림을 기다리는 WAITING 상태라서 그 원에 들어가지 않기 때문입니다. 탐지 기능이 보기에는 marker가 락을 기다리고 있을 뿐이고, 그 락을 쥔 waiter는 다른 누군가의 알림으로 깨어날 수도 있는 스레드입니다. 이때 스레드 덤프를 떠 보면 waiter의 스택에는 inner에 대한 - waiting on과 함께 - locked 줄이 두 개(inner와 outer) 보이고, marker의 스택에는 outer를 가리키는 - waiting to lock이 보입니다. 앞 절에서 익힌 대로 주소의 짝을 맞춰 보면, 길을 막고 있는 것은 wait() 중인 waiter가 놓지 않은 outer라는 것을 알 수 있습니다.
이 예처럼 조건을 기다리다 멈추는 경우는 교착 탐지만으로는 찾을 수 없습니다. 그래서 실제 서비스에서는 처리한 작업 수처럼 진행 상황을 나타내는 값을 따로 두고, 그 값이 한동안 늘지 않는지를 함께 살핍니다. 3번 연습 문제에서 그런 감시 코드를 만들어 봅니다.
프로그램 안에서 기다리는 스레드 모으기
덤프를 프로그램 안에서 직접 모을 수도 있습니다. ThreadMXBean.dumpAllThreads(true, true)는 살아 있는 모든 플랫폼 스레드의 정보를 ThreadInfo 배열로 돌려줍니다. 두 인자는 각각 쥐고 있는 모니터와 ReentrantLock 같은 락의 정보도 함께 모을지를 정합니다. 아래 도구는 그중 기다리는 상태인 스레드만 골라 이름, 상태, 기다리는 대상, 그 대상을 쥔 스레드, 스택 맨 위 프레임을 한 줄씩 출력합니다.
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();
}
}name=Finalizer state=WAITING lock=java.lang.ref.NativeReferenceQueue$Lock@548c4f57 owner=null [email protected]/java.lang.Object.wait0(Native Method)
name=Common-Cleaner state=TIMED_WAITING lock=java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject@214c265e owner=null [email protected]/jdk.internal.misc.Unsafe.park(Native Method)
name=diagnostic-sleeper state=TIMED_WAITING lock=null owner=null [email protected]/java.lang.Thread.sleep0(Native Method)우리가 만든 스레드는 마지막 줄의 diagnostic-sleeper 하나인데, 앞의 두 줄이 더 나왔습니다. Finalizer와 Common-Cleaner는 JVM과 JDK가 스스로 만든 관리용 스레드로, 할 일이 생기기를 늘 기다리고 있습니다. Finalizer는 Object.wait()로 대기 집합에서, Common-Cleaner는 Condition에서 시간 제한을 두고 기다립니다. 이처럼 기다리는 스레드가 있다는 것만으로는 이상이 아닙니다. 실제 도구에서는 이름 접두어로 애플리케이션 스레드만 골라내는 편이 좋습니다. 목록에 없는 나머지 스레드는 그 순간 RUNNABLE이었기 때문에 걸러졌습니다.
diagnostic-sleeper의 lock이 null인 것은 sleep()이 어떤 객체를 기다리는 것이 아니라 시간만 기다리기 때문입니다. owner가 모두 null인 이유도 있습니다. getLockOwnerName()은 스레드가 다른 스레드가 쥔 락을 얻으려고 기다릴 때만 그 소유자의 이름을 돌려주는데, 세 스레드 모두 락이 아니라 알림이나 시간을 기다리고 있기 때문입니다. 앞의 WaitingThreadsDemo 안에서 이 도구를 실행했다면 entrant와 lock-entrant의 줄에는 각각 owner와 lock-owner가 찍혔을 것입니다. 해시 값(@ 뒤의 16진수)과 JDK 버전 표기는 환경마다 다를 수 있습니다.
더 깊이: 가상 스레드는 이 목록에 나오지 않는다
ThreadMXBean이 다루는 것은 플랫폼 스레드입니다. JDK 21에서 잠든 가상 스레드를 하나 만들어 확인해 보면, 그 스레드의 getState()는 TIMED_WAITING이었지만 dumpAllThreads()의 결과에는 나오지 않았고, 그 스레드의 ID로 getThreadInfo()를 불러도 null이 돌아왔습니다. jcmd의 Thread.print가 출력하는 덤프에도 가상 스레드는 나오지 않습니다.
가상 스레드까지 포함한 덤프가 필요하면 jcmd <pid> Thread.dump_to_file -format=json <파일 경로>처럼 Thread.dump_to_file 명령으로 파일에 저장합니다. 실제로 이 명령으로 만든 파일에는 이름을 붙인 가상 스레드가 들어 있었습니다. 다만 상태를 읽어 진단하는 방법과 순서를 동기화 도구로 맞춰야 한다는 원칙은 가상 스레드에서도 그대로입니다.
상태를 볼 때와 순서를 맞출 때 쓰는 도구
지금까지 본 내용을 하려는 일에 따라 정리하면 다음과 같습니다.
| 하려는 일 | 쓸 도구 | 피할 방법 |
|---|---|---|
| 작업자가 어떤 지점에 도착했는지 알기 | CountDownLatch | getState()를 바쁘게 반복 확인 |
| 값이 도착하기를 기다리기 | BlockingQueue, Condition | 공유 변수나 스레드 상태를 반복 확인 |
| 멈춘 원인 찾기 | 스레드 덤프, ThreadMXBean | 상태 이름 하나만 보고 단정 |
| 정해진 시간 안에 끝났는지 확인 | 시간 제한 join(millis), Future.get(timeout, unit) | 시간 제한 없는 join() |
스레드 상태는 진단하기 좋게 단순하게 요약한 값이라서, 스레드가 실제로 거치는 단계를 모두 나타내지는 못합니다. 앞에서 본 것처럼 같은 WAITING이라도 기다리는 대상이 제각각이고, 스레드 정보와 생명 주기의 '더 깊이'에서 본 것처럼 입력을 기다리는 스레드가 RUNNABLE로 나오기도 합니다. 프로그램의 동작을 이런 값에 묶어 두지 않아야, 플랫폼 스레드를 가상 스레드로 바꾸는 것처럼 실행 방식이 바뀌어도 코드가 그대로 동작합니다.
정리
- BLOCKED는
synchronized모니터의 입구에서, WAITING과 TIMED_WAITING은 알림이나 시간을 기다리는 상태입니다.wait()에서 알림을 받은 스레드는 모니터를 다시 얻을 때까지 BLOCKED가 됩니다. - 상태는 스레드가 어디서 멈췄는지를 알려 줄 뿐입니다. 왜 멈췄는지는 덤프에서 주소의 짝을 맞춰 찾습니다. 모니터는
- locked와- waiting to lock,ReentrantLock은parking to wait for와Locked ownable synchronizers를 봅니다. getState()는 한 순간을 읽은 값이라서 스레드 사이의 순서를 맞추는 데 쓰면 안 됩니다. 순서는CountDownLatch나 보호된 조건 변수처럼 사실을 남기는 동기화 도구로 맞춥니다.findDeadlockedThreads()가 아무것도 찾지 못해도 조건을 기다리다 멈춘 스레드가 있을 수 있습니다. 처리량 같은 진행 지표를 함께 봐야 합니다.
연습 문제
- 어떤 프로그램의 덤프에서 아래 스레드를 발견했습니다. 스택 맨 위는
Object.wait인데 상태는 BLOCKED입니다. 이 스레드는 무엇을 기다리고 있나요? 같은 덤프에서 그 원인이 되는 스레드는 어떻게 찾을 수 있을까요?
"waiter" #18 [1805] prio=5 os_prio=0 cpu=0.18ms elapsed=1.82s tid=0x00007f475863e830 nid=1805 in Object.wait() [0x00007f47318fe000]
java.lang.Thread.State: BLOCKED (on object monitor)
at java.lang.Object.wait0([email protected]/Native Method)
- waiting to re-lock in wait() <0x00000000a8519218> (a java.lang.Object)
at java.lang.Object.wait([email protected]/Object.java:366)
at java.lang.Object.wait([email protected]/Object.java:339)
at NotifyHoldsMonitor.lambda$main$0(NotifyHoldsMonitor.java:9)
- locked <0x00000000a8519218> (a java.lang.Object)- 본문의
NestedMonitorStall이 멈추지 않도록 고치세요.ready는 어느 락이 보호해야 하는지부터 생각해 보세요. - 작업자 스레드들이 처리한 건수를 각자의
AtomicLong에 올립니다. 감시 코드는 일정한 간격을 두고 값을 두 번 읽어, 그 사이에 건수가 늘지 않았는데 아직 살아 있는 작업자만 보고해야 합니다. 보고에는 이름, 상태, 스택 맨 위 프레임 한 줄을 넣되, 작업자를 강제로 멈추지는 마세요. 이상을 찾아 알리는 일과 그 뒤에 무엇을 할지 정하는 일은 나눕니다.
1번 풀이
이 스레드는 이미 알림을 받아 대기 집합에서 나왔고, 이제 모니터를 다시 얻으려고 기다리는 중입니다. - waiting to re-lock in wait() 줄이 그것을 말해 줍니다. wait()에서 돌아오려면 0x00000000a8519218 객체의 모니터를 다시 얻어야 하므로, 스택 맨 위가 Object.wait인데도 상태는 BLOCKED입니다. 아래쪽의 - locked 줄은 처음 synchronized 블록에 들어올 때 모니터를 얻었다는 기록입니다.
원인을 찾으려면 같은 덤프에서 이 주소를 검색해, - locked <0x00000000a8519218>이 있는 다른 스레드를 찾으면 됩니다. 이 덤프를 뜬 프로그램에서는 main 스레드였습니다. 아래 코드처럼 main은 notifyAll()을 부른 뒤에도 synchronized 블록 안에서 30초 동안 잠들어 모니터를 놓지 않았습니다.
public final class NotifyHoldsMonitor {
private static final Object monitor = new Object();
private static boolean ready;
public static void main(String[] args) throws InterruptedException {
Thread waiter = Thread.ofPlatform().name("waiter").start(() -> {
synchronized (monitor) {
try {
while (!ready) monitor.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
Thread.sleep(200);
synchronized (monitor) {
ready = true;
monitor.notifyAll();
Thread.sleep(30_000); // 알린 뒤에도 모니터를 쥔 채 오래 머뭅니다
}
waiter.join();
}
}같은 덤프의 main 부분은 다음과 같았습니다.
"main" #1 [1788] prio=5 os_prio=0 cpu=32.02ms elapsed=1.86s tid=0x00007f475801a9c0 nid=1788 waiting on condition [0x00007f475e1fe000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep0([email protected]/Native Method)
at java.lang.Thread.sleep([email protected]/Thread.java:509)
at NotifyHoldsMonitor.main(NotifyHoldsMonitor.java:19)
- locked <0x00000000a8519218> (a java.lang.Object)알림은 스레드를 대기 집합에서 꺼내 줄 뿐 모니터를 넘겨주지 않습니다. 알림을 보낸 뒤에는 블록을 빨리 빠져나가야 깨어난 스레드가 실제로 실행될 수 있습니다.
2번 풀이
ready를 읽고 쓰는 곳은 모두 inner 안이므로 ready를 보호하는 락은 inner입니다. 그러니 markReady()가 outer를 얻을 이유가 없습니다. 아래처럼 inner만 얻게 바꾸면, waiter가 outer를 쥐고 있어도 marker는 들어가서 ready를 바꾸고 알릴 수 있습니다.
public final class NestedMonitorFixed {
private final Object outer = new Object();
private final Object inner = new Object();
private boolean ready;
void awaitReady() throws InterruptedException {
synchronized (outer) {
synchronized (inner) {
while (!ready) inner.wait();
}
}
}
void markReady() {
synchronized (inner) {
ready = true;
inner.notifyAll();
}
}
public static void main(String[] args) throws InterruptedException {
NestedMonitorFixed fixed = new NestedMonitorFixed();
Thread waiter = Thread.ofPlatform().name("waiter").start(() -> {
try { fixed.awaitReady(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
Thread.sleep(100);
fixed.markReady();
waiter.join(1_000);
System.out.println("waiterAlive=" + waiter.isAlive());
}
}waiterAlive=falsewaiter가 알림을 받고 끝났으므로 waiterAlive=false입니다. 하지만 waiter는 여전히 기다리는 동안 outer를 쥐고 있습니다. outer가 필요한 다른 코드가 있다면 그 코드는 계속 막힙니다. 근본적으로는 wait()를 부르는 동안 다른 락을 쥐지 않도록 구조를 바꾸는 것이 좋습니다. 예를 들어 outer가 필요한 작업은 기다림이 끝난 뒤에 따로 하거나, 두 상태를 락 하나로 함께 보호하는 방법이 있습니다.
3번 풀이
처리 건수는 일이 실제로 진행되는지를 보여 주고, 스레드 상태와 스택은 멈춘 위치를 보여 줍니다. 건수가 늘지 않는데 스레드가 살아 있다면 멈춤을 의심할 만하지만, 그것만으로 원인을 확정할 수는 없습니다. 외부 서비스의 응답을 기다리는 정상적인 구간일 수도 있기 때문입니다. 그래서 감시 코드는 의심되는 스레드를 알리기만 하고, 다시 시작하거나 경고를 보내는 결정은 다른 곳에 맡깁니다. 예제에서는 짧은 간격으로 실행할 수 있도록 간격을 메서드 인자로 받습니다.
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();
}
}stalled name=idle-worker state=TIMED_WAITING top=java.base/java.lang.Thread.sleep0(Native Method)main의 작업자 idle-worker는 건수를 올리지 않고 300밀리초 동안 잠들기만 하므로, 50밀리초 뒤 다시 읽은 건수도 0입니다. 살아 있고 건수가 늘지 않았으니 보고 대상이 되고, 상태는 sleep 중이라 TIMED_WAITING입니다. 이번 실행에서는 이 한 줄이 나왔지만, 작업자가 확인 전에 끝났다면 아무것도 출력되지 않습니다. 또 isAlive(), getStackTrace(), getState()는 각각 다른 순간에 읽으므로, 그 사이에 작업자가 끝나면 스택이 비어 <unknown>이나 TERMINATED가 찍힐 수도 있습니다. 같은 JDK인데도 본문의 WaitingThreadReporter 출력과 달리 프레임에 모듈 버전(@21.0.12.1)이 없습니다. Thread.getStackTrace()로 얻은 프레임과 ThreadInfo에서 얻은 프레임은 이렇게 표기가 조금 다를 수 있습니다.
ProgressStallReporterSolution은 처리 카운터의 두 조회와 스레드 생존·스택·상태를 순서대로 읽어 정체 후보를 출력합니다.
정체 보고 · 후보 판정
무증가 보고는 정체 후보이며 정지 원인의 확정은 아니다
두 카운터 값이 같고 스레드가 살아 있으면 보고 후보입니다. 스택과 상태 조회는 정지 원인을 확정하지 않습니다.
처음 카운터 조회
- 검사 또는 출력
- 각 completed 값을 before에 저장
- 판단의 한계
- 이 main의 count는 0이며 작업자는 증가시키지 않습니다.
interval 대기 뒤
- 검사 또는 출력
- 살아 있고 카운터가 같으면 보고 후보
- 판단의 한계
- main의 interval은 50ms입니다. 정상 외부 대기도 같은 조건을 만족할 수 있습니다.
스택과 상태 조회
- 검사 또는 출력
- 이름·상태·상위 프레임을 출력
- 판단의 한계
- 별도 조회 사이에 종료할 수 있어 빈 스택 또는 TERMINATED도 가능합니다.
main의 작업자는 sleep(300)을 호출하지만 보고 시점까지 생존한다는 보장은 없습니다. 따라서 출력이 없거나 후보 한 줄이 나올 수 있으며 보고 메서드는 스레드를 중단시키지 않습니다.
실제 서비스라면 건수가 한 번 늘지 않았다고 바로 판단하지 않습니다. 여러 번 연속으로 늘지 않았는지와 그 작업이 보통 얼마나 걸리는지를 확인하고, 작업을 중단해도 안전한지까지 따져 본 뒤에 다시 시작할지 경고만 보낼지를 정합니다.
다음 글
ArrayBlockingQueue로 생산자·소비자 만들기에서는 앞 글에서 직접 만든 두 Condition 큐를 표준 ArrayBlockingQueue로 바꾸고, 인터럽트 처리와 종료 메시지, 큐 용량처럼 라이브러리가 대신 정해 주지 않는 부분을 다룹니다. 표준 구현의 안쪽도 이 장에서 손으로 짠 것과 같은 락과 두 대기실로 이루어져 있다는 것을 확인할 수 있습니다.