Java의 스레드는 6가지 상태를 가진다. Thread.State enum으로 정의되어 있다.
Thread t = new Thread(() -> {
System.out.println("실행");
});
System.out.println(t.getState()); // NEW
Thread 객체가 힙에 생성됐지만 아직 start()가 호출되지 않은 상태다.
JVM은 알고 있지만 OS는 아직 이 스레드의 존재를 모른다.
Thread t = new Thread(() -> {
System.out.println(Thread.currentThread().getState()); // RUNNABLE
});
t.start();
System.out.println(t.getState()); // RUNNABLE
start()가 호출되면 JVM이 OS에 스레드 생성을 요청하고, OS가 스레드를 만든다.
RUNNABLE은 두 가지 경우를 모두 포함한다.
경우 1: CPU를 실제로 점유해서 실행 중
경우 2: OS 스케줄러 큐에서 CPU 할당을 기다리는 중
Java는 이 둘을 구분하지 않는다. CPU 점유 여부는 OS 스케줄러가 결정하는 영역이라 Java에서 볼 수 없다.
Object lock = new Object();
Thread t1 = new Thread(() -> {
synchronized (lock) {
try { Thread.sleep(2000); } catch (InterruptedException e) {}
}
});
Thread t2 = new Thread(() -> {
synchronized (lock) { // t1이 락을 잡고 있어서 대기
System.out.println("t2 실행");
}
});
t1.start();
Thread.sleep(100); // t1이 락 잡을 시간
t2.start();
Thread.sleep(100); // t2가 BLOCKED 상태 될 시간
System.out.println(t2.getState()); // BLOCKED
synchronized 블록에 진입하려는데 다른 스레드가 이미 락을 잡고 있을 때 BLOCKED가 된다.
중요한 점은 sleep()은 락을 유지한 채로 잠든다는 거다.
t1: lock 획득 → lock 쥔 채로 sleep(2000) → TIMED_WAITING
t2: lock 획득 시도 → t1이 lock 쥐고 자고 있음 → BLOCKED
t1: 2초 후 sleep 끝 → synchronized 블록 탈출 → lock 반납
t2: BLOCKED → RUNNABLE → lock 획득 → 실행
BLOCKED는 락이 반납되는 순간 자동으로 경쟁에 참여한다. 누가 깨워줄 필요가 없다.
sleep() → 락 유지한 채로 잠듦
wait() → 락 반납하고 잠듦 ← 아래에서 설명
Object lock = new Object();
Thread t = new Thread(() -> {
synchronized (lock) {
try {
lock.wait(); // 락 반납 + 무기한 대기
} catch (InterruptedException e) {}
}
System.out.println("t 재개");
});
t.start();
Thread.sleep(100); // t가 wait() 진입할 시간
System.out.println(t.getState()); // WAITING
synchronized (lock) {
lock.notify(); // t를 깨움 → WAITING → RUNNABLE
}
wait()를 호출하는 순간 락을 반납하고 WAITING 상태가 된다.
t: synchronized (lock) → lock 획득
t: lock.wait() 호출 → lock 반납 → WAITING
// 이 시점에 lock은 아무도 가지고 있지 않음
main: synchronized (lock) → lock 획득 가능
main: lock.notify() 호출 → lock의 WAITING 큐에서 t를 꺼내 깨움
main: synchronized 블록 탈출 → lock 반납
t: WAITING → RUNNABLE → lock 재획득 → "t 재개" 출력
notify()가 t를 찾는 원리는 이렇다.
lock.wait()를 호출하면 t는 lock 객체의 WAITING 큐에 등록된다.
lock 객체
├── 모니터 락
└── WAITING 큐: [t] ← wait() 호출한 스레드가 여기 등록됨
lock.notify()는 같은 lock 객체의 WAITING 큐에서 스레드 하나를 꺼내서 깨운다.
반드시 같은 객체로 synchronized, wait(), notify()를 써야 하는 이유가 이거다.
lock.notify(); // WAITING 큐에서 스레드 하나만 깨움 (어느 것인지 보장 없음)
lock.notifyAll(); // WAITING 큐에 있는 모든 스레드를 깨움
notify()를 쓰면 깨어난 스레드가 작업을 처리할 수 없는 상황인데도 다른 스레드는 여전히 WAITING 상태에 머무를 수 있다. 이걸 놓친 신호(missed signal) 문제라고 한다.
생산자-소비자 패턴 예시:
WAITING 큐: [소비자1, 소비자2, 소비자3]
생산자: notify() → 소비자1만 깨움
소비자1: 데이터 소비 완료
소비자2, 3: 여전히 WAITING → 데이터가 있어도 처리 못함
notifyAll()은 전부 깨워서 각자 조건을 확인하게 만들기 때문에 이 문제가 없다. 그래서 일반적으로 notifyAll()이 더 안전하다.
// 안전한 패턴: while로 조건 재확인
synchronized (lock) {
while (큐가_비어있음) { // if가 아닌 while → notifyAll 후 조건 재검사
lock.wait();
}
데이터_처리();
}
wait()/notify()/notifyAll()은 반드시 synchronized 블록 안에서만 호출해야 한다. 그렇지 않으면 IllegalMonitorStateException이 발생한다.
BLOCKED와 WAITING의 차이:
BLOCKED → 락을 얻으려고 대기 (락 반납되면 자동 경쟁)
WAITING → 락을 가졌다가 스스로 반납하고 대기 (notify()가 와야 재개)
join()도 WAITING을 만든다.
Thread t1 = new Thread(() -> {
try { Thread.sleep(2000); } catch (InterruptedException e) {}
});
t1.start();
t1.join(); // t1.join()을 호출한 main 스레드가 WAITING
// t1이 종료되면 main 스레드 WAITING → RUNNABLE
t1.join()을 호출하는 주체가 WAITING이 된다. t1이 WAITING이 되는 게 아니다.
Thread t = new Thread(() -> {
try {
Thread.sleep(2000); // 2초 동안 대기
} catch (InterruptedException e) {}
});
t.start();
Thread.sleep(100);
System.out.println(t.getState()); // TIMED_WAITING
WAITING과 동일하지만 시간 제한이 있다.
WAITING → notify() 또는 join 대상 스레드 종료 시에만 재개
TIMED_WAITING → 위 조건 OR 지정 시간 만료 시 재개 (둘 중 먼저 오는 조건)
TIMED_WAITING을 만드는 메서드:
Thread.sleep(1000); // 1초 후 자동 재개
object.wait(1000); // 1초 후 자동 재개 OR notify() 시 재개
t.join(1000); // 1초 후 자동 재개 OR t 종료 시 재개
Thread t = new Thread(() -> {
System.out.println("실행");
// run() 종료
});
t.start();
t.join();
System.out.println(t.getState()); // TERMINATED
run()이 정상 종료되거나 예외로 종료되면 TERMINATED가 된다.
TERMINATED된 스레드는 재사용할 수 없다.
t.start(); // IllegalThreadStateException
새로 실행하려면 Thread 객체를 새로 만들어야 한다.
Thread.yield();
현재 스레드가 CPU를 자발적으로 양보하고 다시 스케줄링 대기 상태로 돌아간다.
yield()는 RUNNABLE 상태를 유지한다. WAITING이나 TIMED_WAITING으로 전환되지 않는다.
sleep() → TIMED_WAITING → 지정 시간 후 재개 (CPU 선점 불가)
yield() → 여전히 RUNNABLE → 즉시 재스케줄링 대상 (바로 다시 실행될 수도 있음)
yield()는 결과를 보장하지 않는다. OS 스케줄러에 힌트를 주는 것뿐이고, 무시될 수도 있다.
// 스핀락: 조건이 될 때까지 반복 확인
while (!condition) {
Thread.yield(); // CPU를 잠깐 양보해서 다른 스레드가 condition을 변경할 기회를 줌
}
new Thread()
│
▼
[NEW]
│ start()
▼
[RUNNABLE] ◀──────────────────────────────────────────────────┐
│ │
├─ synchronized 락 대기 ──▶ [BLOCKED] ── 락 획득 ────────┤
│ │
├─ sleep(ms), wait(ms), join(ms) ─▶ [TIMED_WAITING] ── 시간 만료 / 조건 충족 ─┤
│ │
├─ wait(), join() ──▶ [WAITING] ── notify() / join 대상 종료 ────────────────┘
│
│ yield() ──▶ 여전히 [RUNNABLE] (상태 전환 없음)
│
▼
[TERMINATED]