VisualVM와 함께 했던 뻘짓 트러블 슈팅

장성호·2023년 12월 21일

JVM

목록 보기
1/3
post-thumbnail

오늘도 커리어리 Q&A를 보던 도중, 정말 흥미로운 질문이 올라왔다. JVM Thread에 대한 질문이었는데, 아무리 생각해도 나 또한 질문자 분의 생각과 똑같았다. 근데 VisualVM은 아니라고 하니까... 너무 궁금해서 다음 환경에서 바로 테스트 해보았다.

  1. 8080 포트에서 실행 중인 Spring MVC 클라이언트
  2. 8081 포트에서 실행 중인 Spring MVC 서버
  3. M1 macOS 14.1

클라이언트 - 서버 환경 구성

테스트는 Postman을 통해 Client의 GET /hello API를 호출하기로 하였다. 테스트 시나리오는 다음과 같다.

  1. Clinet가 Server의 GET /hello API를 요청한다.
  2. Server는 5초 뒤 "hello"라는 문자열을 응답한다.
  3. Client는 호출한 API가 성공하면 Server가 보낸 문자열을 응답한다.
  4. Client는 호출한 API가 실패하면 500 에러가 발생한다.

Client

@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();
    }
}

Server

@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

VisualVM 결과

"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)

해석

독자님의 시간은 소중하기 때문에 미리 결론부터 말씀드립니다.

  1. 커리어리 질문자 분과 저의 생각은 Blocking I/O 시나리오 였습니다.
  2. Non-Blocking I/O에서는 네트워크 I/O 요청 이후, 결과가 돌아올 때까지 작업 처리가 가능하기 때문에 Runnable 입니다.
  3. Tomcat은 8.0부터 Non-Blocking I/O Connector가 기본이고, 9.0부터는 Blocking I/O Connector가 deprecated 되었습니다.
  4. RestTemplate는 BIO / NIO 문제랑 별개로 동기입니다.

5. 아래에 나올 글은 새벽에 NIO라는 글자를 보고 바보같이 Blocking I/O를 생각하고 있던 사람의 글입니다.

홀란스럽다

아니 이게 왜 진짜야

네트워크 I/O 작업 중인데 왜 Blocked이 아니고 Runnable인건지... 내가 Runnable을 잘못 알고 있나라는 생각이 들었다. 혹시나 싶어 JVM에서의 Thread State 정의를 찾아보았다.

Runnable

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

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 lock

Monitor lock

Monitor 개념을 구현한 Java의 동기화 특성이다. synchronized 키워드를 통해 사용할 수 있다.

Monitor

Monitor는 복수의 쓰레드가 공유 자원에 동시에 접근하는 것을 제어하는 데 사용되는 동기화 메커니즘이다. 각 모니터는 일반적으로 Lock 메커니즘과 Condition Variable를 포함한다. 이를 통해 Thread 간 Mutual Exclusion을 가능하게 한다.

Monitor는 컴파일러를 통해 프로그래밍 언어의 일부로 구현된다. 컴파일러는 Monitor에 대한 코드를 생성한다. Thread는 운영 체제와 밀접한 관련이 있다. 따라서 컴파일러는 Critical Section에 대한 접근을 제어하기 위해서, 어떤 운영 체제 기능을 사용할 수 있는지 알기 위한 자원을 추가로 필요로 한다.

정의 상으로는 Thread 실행 도중 외부 자원을 기다리면 Runnable이 맞다고 한다. 근데 저 외부 자원에 Kernel Thread가 포함이 되긴 하나? 아무리 봐도 CPU를 이야기하는 것 같아서...

NioSocketImpl GitHub 까보기

힌트를 찾으려고 로그에서 사용된 NioSocketImpl 함수 코드를 찾아보았다.

tryRead

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);
        }
    }

implRead

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);
        }
    }

read

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구나.

차가운 물 마시고 왔더니 NIO 글자 제대로 읽고 정신차림

Tomcat에서 Non-Blocking I/O Connector를 사용한다는 사실을 잊고 있었다. 로그에 대놓고 NioSocketImpl라고 있었는데... 부끄럽다.

이렇게 뻘짓했던 건 무의식적으로 RestTemplate의 동기 / 비동기 문제랑, BIO / NIO 문제를 혼동해서 그런 것 같다. RestTemplate이 동기적으로 처리한다고 해도, Tomcat에서 생성해놓은 Thread pool은 NIO라서 Blocking이 될 이유는 없던 거였다.

글을 잘 읽고 새벽에 하지 말자

References

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)

profile
일벌리기 좋아하는 사람

2개의 댓글