[ 대상 독자 ]
자바에는 Thread 객체가 있으며, 각 스레드는 고유의 상태(state)를 가집니다.
그중 하나가 바로 BLOCKED 상태입니다.
그렇다면 자바 서버 애플리케이션에서 A 스레드가 Socket을 통해 클라이언트와 통신 중이고, 현재 socket.read()를 호출한 채 클라이언트의 응답을 기다리고 있다면, 이때 A 스레드의 상태는 무엇일까요?
"네트워크 I/O 작업이 진행되지 않고, 클라이언트 응답을 기다리며 block 된 상태이니 자바 스레드 상태도 BLOCKED가 아닐까?"라고 생각하신다면, 이 글이 도움이 될 수 있습니다.
[ Java 스레드의 주요 상태 ]
Java에서는 Thread.State 열거형으로 여섯 가지 스레드 상태를 정의합니다.
1. NEW : 스레드 객체를 생성했지만 아직 start()를 호출하지 않아 시작되지 않은 상태입니다 (new Thread()만 한 경우)
2. RUNNABLE : 실행 중 또는 실행 가능한 상태로, JVM에서 실행 중인 상태를 말합니다. 실제 CPU를 쓰고 있거나, OS 측면에서 CPU 자원을 기다리는 경우도 포함합니다. 예를 들어 계산을 수행 중인 스레드뿐 아니라, 블로킹 I/O 대기 중이더라도 JVM에는 RUNNABLE로 표시될 수 있습니다
3. BLOCKED : 모니터 락 획득을 기다리며 막힌 상태입니다. 어떤 스레드가 synchronized 블록/메서드에 들어가기 위해 락을 요구했으나 다른 스레드가 락을 보유하고 있어 대기 중인 경우입니다. 이 상태는 오직 모니터 락 대기 상황에만 사용됩니다
4. WAITING : 무기한 대기 상태입니다. 다른 스레드의 특정 작업을 영구히 기다리는 경우로, Object.wait() (타임아웃 없이), Thread.join() (타임아웃 없이), LockSupport.park() 등에 의해 진입합니다.
예를 들어 obj.wait() 호출 시 해당 스레드는 WAITING 상태가 되며, 다른 스레드가 obj.notify() 해주기를 기다립니다.
5. TIMED_WAITING : 일정 시간 동안 대기하는 상태입니다. Thread.sleep(millis) 또는 Object.wait(timeout)처럼 정해진 시간까지 기다리는 경우입니다. 시간이 만료되면 자동으로 깨워져 RUNNABLE로 돌아갑니다. 예를 들어 sleep(5000) 호출 시 TIMED_WAITING으로 5초간 머뭅니다.
6. TERMINATED : 스레드의 run() 메서드가 종료되어 실행을 마친 상태입니다. 종료된 스레드는 다시 시작될 수 없으며, getState()로 TERMINATED임을 확인할 수 있습니다.
상태들은 JVM 내부의 상태 표시일 뿐이며, 실제 OS의 스레드 상태를 직접 반영하지는 않습니다
예를들어 Java에서 RUNNABLE이라 해도 OS에서는 대기 중일 수 있습니다.
[ 리눅스 커널의 스레드(프로세스) 상태 ]
Linux 커널은 스레드를 프로세스와 동일하게 취급하며, 몇 가지 주요 상태 상수로 스케줄링 상태를 관리합니다. 주요 커널 태스크 상태는 다음과 같습니다
1. TASK_RUNNING : 실행 중 또는 실행 대기 상태입니다. CPU를 얻어 실행 중이거나, 실행 가능하여 러닝 큐(run queue)에 올라가 CPU 할당을 기다리는 상태입니다
2. TASK_INTERRUPTIBLE : 특정 이벤트가 발생하기를 조건부로 기다리며 자고(sleep) 있는 상태입니다. 요구 조건이 충족되거나 시그널을 수신하면 깨어나 다시 TASK_RUNNING으로 전환됩니다 일반적인 대기/슬립 상태가 이 모드로 구현됩니다.
3. TASK_UNINTERRUPTIBLE : TASK_INTERRUPTIBLE과 거의 같지만, 시그널을 받아도 깨어나지 않는 (interrupt 불가) 상태입니다. 디바이스 I/O 등에서 신호로 중단되면 안 되는 작업을 대기할 때 사용합니다. (D 상태로 표시되며, 강제 종료하기 어려움)
4. TASK_STOPPED : 실행이 정지된 상태입니다. 예를 들어 SIGSTOP 등으로 프로세스/스레드가 일시 중지된 경우입니다
5. TASK_ZOMBIE : 스레드(프로세스)가 종료되었지만 부모가 아직 종료 상태 코드를 수거(wait) 하지 않은 상태입니다 Java의 TERMINATED 쓰레드에 해당하지만, 좀비 프로세스는 부모가 처리할 때까지 커널에 잔존합니다.
6. __TASK_TRACED : 디버거에 의해 추적 중인 상태
7. TASK_DEAD :스레드가 종료되어 메모리에서 해제될 준비가 된 상태입니다.
Linux 스케줄러는 스레드가 실행 가능한 상태(TASK_RUNNING)일 때만 CPU를 할당하며, 대기 상태(TASK_INTERRUPTIBLE 등)로 전환되면 러닝 큐에서 제거하고 다른 태스크를 스케줄링합니다. 이벤트 발생 시 wake_up_process() 등을 통해 다시 TASK_RUNNING으로 만들어 러닝 큐에 넣으면, 이후 CPU가 배정되어 깨어납니다
(+) 서버에 수천 개의 스레드가 떠 있어도, 대부분이 블로킹 I/O나 락 대기 등의 이유로 슬립 상태라면 이들은 CPU를 점유하지 않으며, 컨텍스트 스위칭조차 발생하지 않습니다. 다시 말해, 커널 입장에서 이 스레드들은 잠시 존재하지 않는 것처럼 취급되며, CPU와 런큐는 실행 가능한 소수의 스레드만을 대상으로 작업을 수행하게 됩니다. 이 덕분에 멀티스레드 서버에서 스레드 수가 많아도 실제 성능 저하가 없는 구조가 가능하며, 스레드 수 자체만으로는 CPU 부하를 판단할 수 없습니다.
[ 블로킹 I/O에서의 Java 스레드와 커널 스레드 상태 변화 ]
전통적인 블로킹 I/O에서는 각 소켓 연결마다 전용 스레드가 블로킹 메서드를 호출해 데이터를 주고받습니다.
예를 들어 다음과 같은 코드로 소켓 입력을 읽는다고 가정해봅시다
InputStream in = socket.getInputStream();
byte[] buf = new byte[1024];
int n = in.read(buf); // 네트워크에서 데이터 읽기 (블로킹)
이때 스레드 상태의 흐름을 JVM과 커널 관점에서 살펴보면:
1. 실행 시작 (RUNNABLE/TASK_RUNNING): 새 연결을 처리하기 위해 스레드가 read()를 호출하기 직전까지는 Java에서 RUNNABLE 상태로 CPU를 얻어 실행됩니다. OS에서도 해당 스레드는 TASK_RUNNING 상태로 CPU를 점유하고 있습니다.
2. 블로킹 진입 – 시스템 콜 대기: in.read()는 JNI를 통해 네이티브 소켓 함수를 호출합니다. 예컨대 SocketInputStream.socketRead0() 같은 네이티브 메서드로 진입하면, 커널의 read() 시스템 콜이 실행됩니다.
만약 읽을 데이터가 바로 없다면, 커널은 해당 소켓을 기다리기 위해 스레드를 슬립 상태로 전환합니다. 이 시점에 커널의 스레드 상태는 TASK_INTERRUPTIBLE 상태로 바뀌고 러닝 큐에서 제외됩니다. 즉, I/O 이벤트 대기 큐(소켓의 wait 큐)에 스레드를 넣고 CPU를 다른 프로세스에 양보합니다.
JVM 관점에서는 이 스레드가 모니터 락을 기다리는 것이 아니므로 Java BLOCKED로 표시되지 않습니다. 대신 계속 RUNNABLE로 표시되는 것이 일반적입니다. 실제 jstack 쓰레드 덤프를 보면, 소켓 읽기로 대기 중인 스레드가 "java.lang.Thread.State: RUNNABLE" 상태로 나오며 네이티브 소켓 읽기 함수에서 멈춰있는 것을 확인할 수 있습니다
3. 이벤트 발생 – 스레드 깨움: 네트워크 소켓에 데이터 도착 혹은 연결 생성 등의 이벤트가 발생하면, 커널은 해당 대기 스레드를 깨워 러닝 큐에 복귀시킵니다. 내부적으로 wake_up_process()를 호출해 태스크 상태를 TASK_RUNNING으로 바꾸고 러닝 큐에 넣어둡니다.
4. 실행 재개 (RUNNABLE/TASK_RUNNING): 깨워진 스레드는 다시 스케줄링되어 CPU를 얻으면 커널의 read() 시스템콜이 복귀하고, JVM으로 제어가 돌아옵니다. in.read() 호출이 리턴되어 읽은 바이트 수를 반환하거나, 데이터가 없어 타임아웃일 경우 예외를 던집니다. 이 시점에서 Java 스레드는 여전히 RUNNABLE이며, 애플리케이션 로직을 이어서 실행합니다.
요약하면, 블로킹 I/O 대기 중인 Java 스레드는 JVM에서는 RUNNABLE로 남아 있으나 OS에서는 Interruptible Sleep 상태로 CPU를 사용하지 않고 있습니다. 데이터 도착 시 OS가 스레드를 깨워주며, 다시 RUNNABLE/TASK_RUNNING으로 전환되어 실행을 이어갑니다.
블로킹 I/O 시에는 시스템 콜을 통해 커널이 스레드를 재우고 깨우는 작업을 담당합니다. JVM은 OS 커널에게 스레드 제어를 일시 양도하며, OS가 효율적으로 스케줄링해 줍니다. 이 과정에서 발생하는 컨텍스트 스위치는 I/O 대기 동안 CPU를 유휴상태나 다른 스레드에 활용하도록 해주지만, 스레드가 많아지면 문맥교환 오버헤드가 커질 수 있습니다
[ Java의 BLOCKED vs I/O 대기로 인한 일시정지 상태 ]
Java에서 블로킹됐다는 표현이 혼동을 줄 수 있으므로, BLOCKED 상태와 I/O 대기 상태를 구분해야 합니다.
BLOCKED : 앞서 설명했듯, 모니터 락을 얻기 위해 대기 중인 상태입니다. 예를 들어 A 스레드가 synchronized 블록에 진입하려는데 다른 B 스레드가 락을 점유 중이면, 해당 A 스레드는 BLOCKED로 표시됩니다. 이 경우 원인 자원은 모니터(lock)이며, 네트워크 I/O와는 무관합니다.
I/O로 인한 대기 : 스레드가 소켓 읽기/쓰기처럼 I/O 동작 때문에 기다릴 때는 Java 스레드 상태 상으로 BLOCKED가 아닌 다른 상태로 나타납니다. 대부분의 경우 RUNNABLE로 표시기됩니다.
(+) I/O 대기가 Java에서 WAITING류 상태로 나타나는 경우도 있습니다. 예를 들어 NIO의 Selector 구현은 내부에서 특정 락이나 조건 변수로 대기할 때 WAITING 상태가 될 수 있습니다.
타임아웃이 적용된 I/O의 경우, 구현상 타이머 스레드가 따로 있어 WAITING 상태로 기다리다가 스레드를 깨우거나, 스레드 자체를 TIMED_WAITING (sleep)으로 두는 사례가 있을 수 있습니다.
참고
- https://velog.io/@yyj0110/LKDProcessManagement#:~:text=,%EC%A4%91%20%EC%8B%9C%EA%B7%B8%EB%84%90%EC%9D%84%20%EC%88%98%EC%8B%A0%ED%95%98%EC%98%80%EC%9D%84%20%EA%B2%BD%EC%9A%B0%EC%9D%98%20%EC%83%81%ED%83%9C
- https://stackoverflow.com/questions/20795295/why-jstack-out-says-thread-state-is-runnable-while-socketread
- https://docs.oracle.com/javase/8/docs/api/java/lang/Thread.State.html
- https://www.star.bnl.gov/~liuzx/lki/lki-2.html
- https://www.linuxjournal.com/article/8144
'Server' 카테고리의 다른 글
프로세스 종료와 시그널 (5) | 2025.08.02 |
---|---|
kotlin coroutine 에 대하여 (3) | 2025.07.26 |
java 다이렉트 버퍼에 대하여 (2) | 2025.07.11 |
Multi Plexing 에 대하여 (0) | 2025.07.07 |