멀티 스레드 - 3. 스레드 상태 (Thread State)

revo·2026년 4월 21일

자바

목록 보기
28/30
post-thumbnail

Java의 스레드는 6가지 상태를 가진다. Thread.State enum으로 정의되어 있다.


NEW

Thread t = new Thread(() -> {
    System.out.println("실행");
});

System.out.println(t.getState()); // NEW

Thread 객체가 힙에 생성됐지만 아직 start()가 호출되지 않은 상태다.
JVM은 알고 있지만 OS는 아직 이 스레드의 존재를 모른다.


RUNNABLE

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에서 볼 수 없다.


BLOCKED

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()  → 락 반납하고 잠듦  ← 아래에서 설명

WAITING

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()를 써야 하는 이유가 이거다.

notify() vs notifyAll()

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이 되는 게 아니다.


TIMED_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 종료 시 재개

TERMINATED

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 객체를 새로 만들어야 한다.


yield() — CPU 양보

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]

0개의 댓글