
오늘도 커리어리 Q&A를 보던 도중, 정말 흥미로운 질문이 올라왔다. JVM Thread에 대한 질문이었는데, 아무리 생각해도 나 또한 질문자 분의 생각과 똑같았다. 근데 VisualVM은 아니라고 하니까... 너무 궁금해서 다음 환경에서 바로 테스트 해보았다.
테스트는 Postman을 통해 Client의 GET /hello API를 호출하기로 하였다. 테스트 시나리오는 다음과 같다.
@RestController(value = "")
public class ThreadClientController {
@GetMapping(value = "/hello")
public ResponseEntity<String> hello() {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders httpHeaders = new HttpHeaders();
HttpEntity<String> httpEntity = new HttpEntity<>(httpHeaders);
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:8081/hello",
HttpMethod.GET,
httpEntity,
String.class
);
if (response.getStatusCode().is2xxSuccessful()) {
return ResponseEntity.ok(response.getBody());
}
return ResponseEntity.internalServerError().build();
}
}
@RestController(value = "")
public class ThreadServerController {
@GetMapping(value = "/hello")
public ResponseEntity<String> hello() throws InterruptedException {
Thread.sleep(5000);
return ResponseEntity.ok("hello");
}
}
# application.yaml
server.port=8081

"http-nio-8080-exec-2" #42 [33795] daemon prio=5 os_prio=31 cpu=3.49ms elapsed=163.02s tid=0x000000012b9a7e00 nid=33795 runnable [0x000000016f55f000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.SocketDispatcher.read0(java.base@19.0.2/Native Method)
at sun.nio.ch.SocketDispatcher.read(java.base@19.0.2/SocketDispatcher.java:47)
at sun.nio.ch.NioSocketImpl.tryRead(java.base@19.0.2/NioSocketImpl.java:251)
at sun.nio.ch.NioSocketImpl.implRead(java.base@19.0.2/NioSocketImpl.java:302)
at sun.nio.ch.NioSocketImpl.read(java.base@19.0.2/NioSocketImpl.java:340)
at sun.nio.ch.NioSocketImpl$1.read(java.base@19.0.2/NioSocketImpl.java:789)
독자님의 시간은 소중하기 때문에 미리 결론부터 말씀드립니다.
- 커리어리 질문자 분과 저의 생각은 Blocking I/O 시나리오 였습니다.
- Non-Blocking I/O에서는 네트워크 I/O 요청 이후, 결과가 돌아올 때까지 작업 처리가 가능하기 때문에 Runnable 입니다.
- Tomcat은 8.0부터 Non-Blocking I/O Connector가 기본이고, 9.0부터는 Blocking I/O Connector가 deprecated 되었습니다.
- RestTemplate는 BIO / NIO 문제랑 별개로 동기입니다.
5. 아래에 나올 글은 새벽에 NIO라는 글자를 보고 바보같이 Blocking I/O를 생각하고 있던 사람의 글입니다.

아니 이게 왜 진짜야
네트워크 I/O 작업 중인데 왜 Blocked이 아니고 Runnable인건지... 내가 Runnable을 잘못 알고 있나라는 생각이 들었다. 혹시나 싶어 JVM에서의 Thread State 정의를 찾아보았다.
RUNNABLE
public static final Thread.State RUNNABLE
Thread state for a runnable thread. A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor.
출처: Oracle docs - Enum Thread.State
JVM에서 실행 중이지만 Thread이지만, 운영 체제의 프로세서처럼 외부 자원을 기다리고 있을 수 있다.
BLOCKED
public static final Thread.State BLOCKED
Thread state for a thread blocked waiting for a monitor lock. A thread in the blocked state is waiting for a monitor lock to enter a synchronized block/method or reenter a synchronized block/method after calling Object.wait.
출처: Oracle docs - Enum Thread.State
Monitor lock을 가다리고 있는 Thread이다. 동기화 블록/메소드에 진입하거나 Object.wait 호출 이후 재진입하기 위해서, Monitor lock을 기다리고 있다.
Monitor lockMonitor 개념을 구현한 Java의 동기화 특성이다. synchronized 키워드를 통해 사용할 수 있다.
Monitor는 복수의 쓰레드가 공유 자원에 동시에 접근하는 것을 제어하는 데 사용되는 동기화 메커니즘이다. 각 모니터는 일반적으로 Lock 메커니즘과 Condition Variable를 포함한다. 이를 통해 Thread 간 Mutual Exclusion을 가능하게 한다.
Monitor는 컴파일러를 통해 프로그래밍 언어의 일부로 구현된다. 컴파일러는 Monitor에 대한 코드를 생성한다. Thread는 운영 체제와 밀접한 관련이 있다. 따라서 컴파일러는 Critical Section에 대한 접근을 제어하기 위해서, 어떤 운영 체제 기능을 사용할 수 있는지 알기 위한 자원을 추가로 필요로 한다.
정의 상으로는 Thread 실행 도중 외부 자원을 기다리면 Runnable이 맞다고 한다. 근데 저 외부 자원에 Kernel Thread가 포함이 되긴 하나? 아무리 봐도 CPU를 이야기하는 것 같아서...
힌트를 찾으려고 로그에서 사용된 NioSocketImpl 함수 코드를 찾아보았다.
private int tryRead(FileDescriptor fd, byte[] b, int off, int len)
throws IOException
{
ByteBuffer dst = Util.getTemporaryDirectBuffer(len);
assert dst.position() == 0;
try {
int n = nd.read(fd, ((DirectBuffer)dst).address(), len);
if (n > 0) {
dst.get(b, off, n);
}
return n;
} finally {
Util.offerFirstTemporaryDirectBuffer(dst);
}
}
private int implRead(byte[] b, int off, int len) throws IOException {
int n = 0;
FileDescriptor fd = beginRead();
try {
if (connectionReset)
throw new SocketException("Connection reset");
if (isInputClosed)
return -1;
int timeout = this.timeout;
configureNonBlockingIfNeeded(fd, timeout > 0);
if (timeout > 0) {
// read with timeout
n = timedRead(fd, b, off, len, MILLISECONDS.toNanos(timeout));
} else {
// read, no timeout
n = tryRead(fd, b, off, len);
while (IOStatus.okayToRetry(n) && isOpen()) {
park(fd, Net.POLLIN);
n = tryRead(fd, b, off, len);
}
}
return n;
} catch (InterruptedIOException e) {
throw e;
} catch (ConnectionResetException e) {
connectionReset = true;
throw new SocketException("Connection reset");
} catch (IOException ioe) {
// throw SocketException to maintain compatibility
throw asSocketException(ioe);
} finally {
endRead(n > 0);
}
}
private int read(byte[] b, int off, int len) throws IOException {
Objects.checkFromIndexSize(off, len, b.length);
if (len == 0) {
return 0;
} else {
readLock.lock();
try {
// emulate legacy behavior to return -1, even if socket is closed
if (readEOF)
return -1;
// read up to MAX_BUFFER_SIZE bytes
int size = Math.min(len, MAX_BUFFER_SIZE);
int n = implRead(b, off, size);
if (n == -1)
readEOF = true;
return n;
} finally {
readLock.unlock();
}
}
}
아무리 봐도 lock(), park()이 들어가있는데 어째서 Blocked가 한 번도 발생하지 않는거지..? 코드 봤다가 오히려 더 헷갈리기만 했다.

차가운 물 마시고 왔더니 NIO 글자 제대로 읽고 정신차림
Tomcat에서 Non-Blocking I/O Connector를 사용한다는 사실을 잊고 있었다. 로그에 대놓고 NioSocketImpl라고 있었는데... 부끄럽다.
이렇게 뻘짓했던 건 무의식적으로 RestTemplate의 동기 / 비동기 문제랑, BIO / NIO 문제를 혼동해서 그런 것 같다. RestTemplate이 동기적으로 처리한다고 해도, Tomcat에서 생성해놓은 Thread pool은 NIO라서 Blocking이 될 이유는 없던 거였다.
글을 잘 읽고 새벽에 하지 말자
Oracle docs - Enum Thread.State
GeeksForGeeks - Difference Between Lock and Monitor in Java Concurrency
GeeksForGeeks - Monitors in Process Synchronization
JavaThread 에 대해 깊게 이해해보자 (feat. Openjdk 커널 분석)
스프링부트는 어떻게 다중 유저 요청을 처리할까? (Tomcat9.0 Thread Pool)