[Thread] 스프링 톰캣 스레드 덤프 파헤치기: 상태값과 구조 분석
안녕하세요. 개발자 stark입니다.
오늘 구독 중인 개발 블로그의 글을 살펴보던 중 망나니개발자님께서 적어주신 스프링 tomcat의 스레드 덤프 분석글을 읽은 후 저도 스레드 덤프를 분석해보고 싶다는 생각이 들어서 내용을 보며 분석을 시도해 봤습니다. 망규님께서 좋은 설명들을 적어주신 덕분에 직접 따라 해 보면서도 그 내용도 쉽게 분석하며 이해할 수 있었습니다.
이렇게 열심히 덤프 분석을 따라 하던 중 궁금한 점이 생겼습니다. 덤프 안에는 생전 처음 보는 Poller, Acceptor, RMI 스레드가 있어서 무엇인지 알고 싶었으며 각 스레드는 상태를 가지고 있었는데 그중 WAITING과 TIMED_WAITING라는 상태의 차이점을 자세히 알고 싶었습니다. 또한 어떻게 WAITING 상태였던 스레드가 깨어나서 동작하는지에 대해서도 알고 싶었습니다.
그리고 dump 파일을 자세히 읽다 보면 (parking)이라는 상태표시가 적혀있었는데 이것이 무엇인지도 너무 알고 싶어서 참을 수가 없었습니다. 이렇게 보니 저는 참 호기심이 많은 것 같습니다. 그래서 그 내용을 하나하나 알아보면서 간단히 내용을 정리해 봤습니다. 이런 좋은 내용을 배우게 해 주신 망나니개발자님께 감사의 말씀을 드리며 글을 시작하도록 하겠습니다.
Reference: 망나니개발자님의 글입니다.
[Java] 스프링 톰캣의 스레드 덤프를 통한 스레드 상태에 대한 이해(Thread State with Spring Application Tom
1. 자바 스레드의 여러 가지 상태들[ 여러 종류의 스레드 상태들(Thread State) ]자바 공식 문서(자바 23 기준)에 따르면 다음과 같은 자바 스레드 상태가 존재한다.NEW스레드가 생성되었으나 아직 시
mangkyu.tistory.com
프로젝트에서 스레드 덤프 파일을 생성해 봅시다.
스레드 덤프 파일을 만드는 명령어는 망규님께서 글에 적어주신 명령어를 그대로 사용하였습니다. 제가 그 설명을 그대로 가져다 적는 것은 예의가 아니라고 생각하기에 따라서 사용했던 명령어만 적어두겠습니다. 위의 글에 설명을 정말 멋지게 잘 적어주셨으니 이해하고 싶다면 꼭 위의 링크에 들어가셔서 명령어 설명을 읽어주셨으면 좋겠습니다.
저는 8100번 포트로 tomcat을 실행하고 있기에 :8100을 넣어주었습니다. (자신의 서버가 실행되는 port를 적어주세요.)
jstack $(lsof -t -iTCP:8100 -sTCP:LISTEN) > thread_dump.txt
명령어 실행 시 아래 tree처럼 프로젝트 root 경로에 thread_dump.txt 파일이 생성될 것입니다.
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── example
│ │ │ └── test
│ │ │ ├── TestApplication.java
│ │ │ └── controller
│ │ │ ├── TestController.java
│ │ │ └── WebConfig.java
└── thread_dump.txt
스레드 덤프 파일 분석방법
스레드 덤프 파일을 이해하는 방법을 간단히 설명드리겠습니다.
- 아래와 같은 덤프파일을 분석해 봅시다.
"http-nio-8100-exec-1" #43 [39939] daemon prio=5 os_prio=31 cpu=0.08ms elapsed=58.78s tid=0x000000011e2a7e00 nid=39939 waiting on condition [0x000000030905a000]
java.lang.Thread.State: WAITING (parking)
1. 스레드 기본 정보
- "http-nio-8100-exec-1": 스레드 이름
- http: HTTP 프로토콜 사용
- nio: Non-blocking I/O 방식 사용
- 8100: 서버 포트 번호
- exec-1: 실행 스레드 번호(1번)
- #43: JVM 내에서의 스레드 ID
2. 스레드 우선순위 정보
- [39939]: OS에서 부여한 스레드 ID
- daemon: 데몬 스레드임을 나타냄 (메인 스레드 종료 시 함께 종료)
- prio=5: JVM에서의 스레드 우선순위 (1~10, 기본값 5)
- os_prio=31: 운영체제에서의 스레드 우선순위 (OS마다 범위가 다름)
3. 실행 정보
- cpu=0.08ms: 이 스레드가 사용한 CPU 시간
- elapsed=58.78s: 스레드가 생성된 후 경과한 시간
- tid=0x000000011e2a7e00: JVM 내부의 스레드 식별자 (16진수)
- nid=39939: OS에서의 네이티브 스레드 ID
4. 스레드 상태 정보
- waiting on condition: 특정 조건을 기다리고 있는 상태
- [0x000000030905a000]: 스레드의 스택 메모리 주소
- java.lang.Thread.State: WAITING (parking): 스레드의 현재 상태
- WAITING: 다른 스레드의 작업이 끝나기를 기다리는 상태
- parking: LockSupport.park()에 의해 대기 상태로 진입했음을 의미
톰캣 스레드 덤프 파일 살펴보기
저는 아무 설정도 하지 않은 default 상태의 스프링부트를 실행한 다음 덤프파일을 만들었습니다. 덤프파일을 살펴보니 정말 많은 스레드 정보가 적혀있었는데 그중 nio 스레드와 처음 보는 Poller, Acceptor라는 스레드를 살펴봤습니다. 참고로 스프링 부트는 기본적으로 톰캣의 NIO(Non-blocking I/O) 커넥터를 사용합니다. NIO는 효율적인 I/O 처리를 위한 Java의 패키지로, 하나의 스레드가 여러 연결을 처리할 수 있게 해 줍니다.
nio 스레드는 스프링 톰캣에 기본적으로 설정된 스레드풀 개수만큼인 10개가 존재하였습니다(아래의 10개입니다). 근데 대체 Poller, Acceptor 스레드는 뭔지 감이 잡히지도 않았습니다. 그래서 저는 이게 무슨 역할을 하기 위해 존재하는 것인지에 대해 알아봤습니다.
"http-nio-8100-exec-1" #43 [39939] daemon prio=5 os_prio=31 cpu=0.08ms elapsed=58.78s tid=0x000000011e2a7e00 nid=39939 waiting on condition [0x000000030905a000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-2" #44 [39427] daemon prio=5 os_prio=31 cpu=0.07ms elapsed=58.78s tid=0x000000011d9cb400 nid=39427 waiting on condition [0x0000000309266000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-3" #45 [35331] daemon prio=5 os_prio=31 cpu=0.05ms elapsed=58.78s tid=0x000000011eca1800 nid=35331 waiting on condition [0x0000000309472000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-4" #46 [35587] daemon prio=5 os_prio=31 cpu=0.05ms elapsed=58.78s tid=0x000000011ecb1200 nid=35587 waiting on condition [0x000000030967e000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-5" #47 [35843] daemon prio=5 os_prio=31 cpu=0.04ms elapsed=58.78s tid=0x000000011d9e5c00 nid=35843 waiting on condition [0x000000030988a000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-6" #48 [38147] daemon prio=5 os_prio=31 cpu=0.05ms elapsed=58.78s tid=0x000000011d9e4000 nid=38147 waiting on condition [0x0000000309a96000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-7" #49 [36099] daemon prio=5 os_prio=31 cpu=0.05ms elapsed=58.78s tid=0x000000012fa74800 nid=36099 waiting on condition [0x0000000309ca2000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-8" #50 [36355] daemon prio=5 os_prio=31 cpu=0.04ms elapsed=58.78s tid=0x000000010f26f600 nid=36355 waiting on condition [0x0000000309eae000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-9" #51 [37123] daemon prio=5 os_prio=31 cpu=0.05ms elapsed=58.78s tid=0x000000011fcbc600 nid=37123 waiting on condition [0x000000030a0ba000]
java.lang.Thread.State: WAITING (parking)
"http-nio-8100-exec-10" #52 [36867] daemon prio=5 os_prio=31 cpu=0.05ms elapsed=58.78s tid=0x000000012fa4d400 nid=36867 waiting on condition [0x000000030a2c6000]
java.lang.Thread.State: WAITING (parking)
아래와 같이 Poller, Acceptor 스레드는 각각 1개씩 존재했습니다. (Mac OS 기준)
"http-nio-8100-Poller" #53 [65283] daemon prio=5 os_prio=31 cpu=6.28ms elapsed=58.78s tid=0x000000011e2b7400 nid=65283 runnable [0x000000030a4d2000]
java.lang.Thread.State: RUNNABLE
"http-nio-8100-Acceptor" #54 [44035] daemon prio=5 os_prio=31 cpu=0.14ms elapsed=58.78s tid=0x000000011e2c0000 nid=44035 runnable [0x000000030a6de000]
java.lang.Thread.State: RUNNABLE
각 스레드의 역할과 흐름을 이해해 봅시다.
Acceptor 스레드의 역할
- 마치 호텔의 리셉셔니스트처럼, 새로운 TCP 연결 요청을 수락하는 역할을 합니다.
- 항상 RUNNABLE 상태로 새로운 연결을 기다립니다.
- 기본적으로 하나의 스레드만 존재하며, 연결 수락만 담당합니다.
Poller 스레드의 역할
- 마치 우체부처럼, 연결된 소켓에서 발생하는 I/O 이벤트를 감시합니다.
- 읽기/쓰기가 가능한 상태인지 지속적으로 확인합니다.
- 기본적으로 하나의 스레드만 존재하며, 항상 RUNNABLE 상태입니다.
Worker 스레드(nio-exec)의 역할
- 실제 HTTP 요청을 처리하는 작업자 스레드들입니다.
- 기본적으로 10개의 스레드가 생성됩니다. (톰켓 최소 스레드풀 설정은 10개)
- 요청이 없을 때는 WAITING 상태로 대기하다가, Poller가 감지한 I/O 이벤트가 있으면 RUNNABLE 상태로 변경되어 요청을 처리합니다.
전체 요청 흐름 이해하기
1. Client → TCP 연결 요청
- 클라이언트가 8100 포트로 연결을 시도
2. Acceptor (RUNNABLE) → TCP 연결 수락
- 새로운 연결 요청을 받아들임
- 연결된 소켓을 Poller에게 전달
3. Poller (RUNNABLE) → I/O 이벤트 감시
- 연결된 소켓들의 I/O 이벤트를 감시
- 요청이 오면 Worker 스레드에게 작업 할당
4. Worker Thread (WAITING → RUNNABLE)
- Poller로부터 작업을 받아 실제 HTTP 요청 처리
- 처리가 끝나면 다시 WAITING 상태로 대기
왜 이러한 구조를 사용할까요?
이러한 구조는 높은 성능과 자원의 효율적인 사용을 가능하게 합니다. Acceptor는 연결 수락만, Poller는 이벤트 감시만 전담하고, 실제 작업은 Worker 스레드들이 분담함으로써 각자의 역할에 최적화된 처리가 가능합니다. 특히 NIO를 사용함으로써 적은 수의 스레드로도 많은 연결을 처리할 수 있습니다. 이는 마치 호텔에서 리셉셔니스트(Acceptor)가 손님을 맞이하고, 벨보이(Poller)가 손님의 요청을 확인하며, 실제 서비스는 여러 명의 직원들(Worker 스레드)이 처리하는 것과 비슷한 구조입니다.
스레드의 상태 (WAITING, TIMED_WAITING) 이해하기
Java 스레드는 다양한 상태를 가질 수 있습니다. 스레드 덤프를 떠보면 WAITING과 TIMED_WAITING이 존재한다는 것을 알 수 있습니다. 이 두 가지 종류의 상태는 종종 혼동되지만, 그 동작 방식과 사용 사례가 다릅니다.
먼저 WAITING 상태를 살펴봅시다. WAITING 상태는 특정 조건이 충족될 때까지 스레드가 무기한 대기할 때 발생합니다. 주요 사례는 다음과 같습니다.
- Object.wait() 호출
- Thread.join() 호출
- LockSupport.park() 호출
아래의 내용은 실제로 제가 생성한 dump 파일의 내용을 가져온 것입니다.
- 톰켓에서는 기본적인 설정으로 스레드풀에 10개의 스레드를 생성합니다. 이름을 보면 http-nio-8100-exec-(번호) 이렇게 번호가 1번인 것을 볼 수 있습니다. 이것이 10번까지 존재한다고 생각해 주시면 됩니다. (만약 설정을 바꾸면 스레드 개수도 변경됩니다.)
"http-nio-8100-exec-1" #43 [39939] daemon prio=5 os_prio=31 cpu=0.08ms elapsed=58.78s tid=0x000000011e2a7e00 nid=39939 waiting on condition [0x000000030905a000]
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@21.0.3/Native Method)
- parking to wait for <0x0000000403644da0> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(java.base@21.0.3/LockSupport.java:371)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(java.base@21.0.3/AbstractQueuedSynchronizer.java:519)
at java.util.concurrent.ForkJoinPool.unmanagedBlock(java.base@21.0.3/ForkJoinPool.java:3780)
at java.util.concurrent.ForkJoinPool.managedBlock(java.base@21.0.3/ForkJoinPool.java:3725)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(java.base@21.0.3/AbstractQueuedSynchronizer.java:1707)
at java.util.concurrent.LinkedBlockingQueue.take(java.base@21.0.3/LinkedBlockingQueue.java:435)
at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:117)
at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:33)
at org.apache.tomcat.util.threads.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1114)
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1175)
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)
at java.lang.Thread.runWith(java.base@21.0.3/Thread.java:1596)
at java.lang.Thread.run(java.base@21.0.3/Thread.java:1583)
- 이 경우, 스레드는 LinkedBlockingQueue의 take() 메서드를 호출하며 대기 중입니다. 이는 큐에 요소가 추가될 때까지 무기한 대기하는 동작을 나타냅니다.
다음으로 TIMED_WAITING 상태를 살펴봅시다.
- TIMED_WAITING 상태는 특정 시간 동안만 스레드가 대기하는 경우 발생합니다. 주요 사례는 다음과 같습니다.
- Thread.sleep() 호출
- Object.wait(timeout) 호출
- LockSupport.parkNanos() 또는 parkUntil() 호출
- ScheduledThreadPoolExecutor에서 작업 스케줄링
아래의 내용은 실제로 제가 생성한 dump 파일의 내용을 가져온 것입니다.
- 이번에는 위의 nio 스레드와 다르게 RMI(Remote Method Invocation)의 스레드입니다. RMI 스레드에 대한 설명은 하단의 목차에서 자세히 다룰 예정입니다. RMI 스레드는 TIMED_WAITING 상태로 되어있는 것을 볼 수 있습니다.
"RMI TCP Connection(idle)" #56 [65027] daemon prio=5 os_prio=31 cpu=14.60ms elapsed=58.73s tid=0x000000011ecb5200 nid=65027 waiting on condition [0x000000030a8ea000]
java.lang.Thread.State: TIMED_WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@21.0.3/Native Method)
- parking to wait for <0x000000040110e0f8> (a java.util.concurrent.SynchronousQueue$Transferer)
at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.3/LockSupport.java:410)
at java.util.concurrent.LinkedTransferQueue$DualNode.await(java.base@21.0.3/LinkedTransferQueue.java:452)
at java.util.concurrent.SynchronousQueue$Transferer.xferLifo(java.base@21.0.3/SynchronousQueue.java:194)
at java.util.concurrent.SynchronousQueue.xfer(java.base@21.0.3/SynchronousQueue.java:233)
at java.util.concurrent.SynchronousQueue.poll(java.base@21.0.3/SynchronousQueue.java:336)
at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.3/ThreadPoolExecutor.java:1069)
at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.3/ThreadPoolExecutor.java:1130)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.3/ThreadPoolExecutor.java:642)
at java.lang.Thread.runWith(java.base@21.0.3/Thread.java:1596)
at java.lang.Thread.run(java.base@21.0.3/Thread.java:1583)
- 또 다른 TIMED_WAITING 예시는 다음과 같습니다. 이것도 위와 마찬가지로 RMI의 스레드입니다.
"RMI Scheduler(0)" #57 [44803] daemon prio=5 os_prio=31 cpu=0.06ms elapsed=58.72s tid=0x000000010f2ac400 nid=44803 waiting on condition [0x000000030aaf6000]
java.lang.Thread.State: TIMED_WAITING (parking)
at jdk.internal.misc.Unsafe.park(java.base@21.0.3/Native Method)
- parking to wait for <0x000000040110c130> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.parkNanos(java.base@21.0.3/LockSupport.java:269)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(java.base@21.0.3/AbstractQueuedSynchronizer.java:1758)
at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.3/ScheduledThreadPoolExecutor.java:1182)
at java.util.concurrent.ScheduledThreadPoolExecutor$DelayedWorkQueue.take(java.base@21.0.3/ScheduledThreadPoolExecutor.java:899)
at java.util.concurrent.ThreadPoolExecutor.getTask(java.base@21.0.3/ThreadPoolExecutor.java:1070)
at java.util.concurrent.ThreadPoolExecutor.runWorker(java.base@21.0.3/ThreadPoolExecutor.java:1130)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(java.base@21.0.3/ThreadPoolExecutor.java:642)
at java.lang.Thread.runWith(java.base@21.0.3/Thread.java:1596)
at java.lang.Thread.run(java.base@21.0.3/Thread.java:1583)
두 가지 상태의 주요 차이점은 다음과 같습니다.
WAITING | 무기한 대기 | 작업 큐, 동기화 블록 등 |
TIMED_WAITING | 제한된 시간 동안 대기 | 타임아웃 설정된 작업 |
WAITING과 TIMED_WAITING의 필요성과 깨어나는 방식
먼저 WAITING 상태의 필요성에 대해 이야기해 보겠습니다.
카페에서 주문한 커피가 나올 때까지 기다려본 경험이 있으실 것입니다. WAITING 상태는 이와 매우 비슷합니다. 스레드가 특정 조건이 충족될 때까지 효율적으로 대기하도록 해줍니다. 예를 들어, 생산자-소비자 패턴에서 소비자 스레드는 큐가 비어있을 때 새로운 데이터가 들어올 때까지 WAITING 상태로 대기합니다. 이를 통해 CPU 자원을 불필요하게 낭비하지 않고, 다른 스레드가 작업을 수행할 수 있도록 해줍니다.
다음으로 TIMED_WAITING 상태에 대해서도 이야기해 보겠습니다.
TIMED_WAITING 상태는 마치 택시를 기다릴 때 "10분만 더 기다려보고 안 오면 다른 거 타고 갈게"라고 하는 것과 비슷합니다. 이 상태값은 특정 시간제한만큼 대기하다가 다른 작업을 할 수 있다는 것을 의미합니다. 이는 시스템의 안정성과 응답성을 높이는 데 매우 중요한 역할을 합니다. 예를 들어, 데이터베이스 연결을 시도할 때 무한정 기다리는 것이 아니라, 5초 동안만 시도하고 실패하면 다른 처리를 하도록 할 수 있습니다.
이제 스레드가 이러한 상태들에서 어떻게 깨어나서 동작하는지를 살펴보겠습니다.
WAITING 상태의 스레드는 마치 깊은 잠에 빠진 것처럼 보이지만, 실제로는 특정 신호를 기다리고 있습니다. 다른 스레드가 notify() 또는 notifyAll() 메서드를 호출하면, 마치 알람 소리에 잠에서 깨어나듯이 대기 상태에서 벗어나서 실행 가능한 상태가 됩니다. 또한, 누군가가 스레드를 interrupt() 메서드로 강제로 깨울 수도 있습니다.
TIMED_WAITING 상태는 조금 더 유연합니다. 정해진 시간이 지나면 자동으로 깨어나므로, 무한정 대기하는 상황을 방지할 수 있습니다. 예를 들어, Thread.sleep(1000)을 호출하면 1초 동안 대기하다가 자동으로 깨어납니다. 물론 이 동안에도 다른 스레드가 notify()를 호출하거나 interrupt()를 발생시키면 즉시 깨어날 수 있습니다.
parking 메커니즘
스레드 덤프를 분석하다 보면 종종 "parking" 상태의 스레드들을 발견하게 됩니다. 이는 마치 주차장에 차를 주차해 두는 것처럼 스레드를 잠시 '주차'해둔 상태를 의미합니다. 스레드가 parking 상태에 있다는 것은 그 스레드가 현재 실행을 멈추고 대기 상태에 들어갔다는 것을 나타냅니다.
아래의 내용은 실제 dump내용을 일부를 발췌한 것입니다. (parking)이 적혀있는 것을 알 수 있습니다.
"RMI Scheduler(0)" #57 [44803] daemon prio=5 os_prio=31 cpu=0.06ms elapsed=58.72s tid=0x000000010f2ac400 nid=44803 waiting on condition [0x000000030aaf6000]
java.lang.Thread.State: TIMED_WAITING (parking)
"RMI TCP Connection(idle)" #56 [65027] daemon prio=5 os_prio=31 cpu=14.60ms elapsed=58.73s tid=0x000000011ecb5200 nid=65027 waiting on condition [0x000000030a8ea000]
java.lang.Thread.State: TIMED_WAITING (parking)
대체 왜 이런 parking 메커니즘이 필요할까요?
Java에서 멀티스레드 프로그래밍을 할 때, 모든 스레드가 항상 동시에 실행될 필요는 없습니다. 예를 들어, 데이터베이스에서 데이터를 가져오는 동안 대기하거나, 다른 스레드의 작업이 끝날 때까지 기다려야 하는 경우가 있습니다. 이때 스레드를 parking 상태로 만들어 CPU 자원을 효율적으로 사용할 수 있게 됩니다.
Java에서는 이러한 parking 메커니즘을 LockSupport라는 클래스를 통해 제공합니다. 이는 synchronized나 wait/notify보다 더 저수준의 기능을 제공하는데, 실제로 Java의 많은 고수준 동시성 도구들(ReentrantLock, Semaphore 등)이 내부적으로 이 parking 메커니즘을 사용하고 있습니다.
아래는 실제 LockSupport 코드를 일부 발췌해 온 것입니다.
- 코드를 자세히 살펴보고 싶다면 Java에서 검색해 보시면 됩니다.
public class LockSupport {
public static void unpark(Thread thread) {
if (thread != null) {
if (thread.isVirtual()) {
VirtualThreads.unpark(thread);
} else {
U.unpark(thread);
}
}
}
public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
try {
if (t.isVirtual()) {
VirtualThreads.park();
} else {
U.park(false, 0L);
}
} finally {
setBlocker(t, null);
}
}
public static void parkNanos(Object blocker, long nanos) {
// ...
}
public static void parkUntil(Object blocker, long deadline) {
// ...
}
}
- 위의 unpark, park 메서드에서는 Unsafe 클래스 내부의 native 메서드인 unpark, park를 호출합니다.
public final class Unsafe {
private static native void registerNatives();
static {
registerNatives();
}
private Unsafe() {}
private static final Unsafe theUnsafe = new Unsafe();
// ... 수많은 코드 생략
@IntrinsicCandidate
public native void unpark(Object thread);
@IntrinsicCandidate
public native void park(boolean isAbsolute, long time);
// ... 수많은 코드 생략
}
parking의 동작 방식은 꽤 단순합니다. 스레드가 LockSupport.park()를 호출하면, 그 스레드는 즉시 실행을 멈추고 대기 상태로 들어갑니다. 마치 차를 주차장에 세워두는 것과 같습니다. 그리고 다른 스레드가 LockSupport.unpark(thread)를 호출하면, 주차된 스레드가 다시 실행 가능한 상태로 돌아옵니다. 이는 주차된 차를 다시 운행하기 위해 꺼내는 것과 비슷합니다.
특히 재미있는 점은 parking이 시간제한을 둘 수 있다는 것입니다. LockSupport.parkNanos()를 사용하면 특정 시간 동안만 스레드를 대기시킬 수도 있습니다. 이는 마치 주차장에서 "2시간만 주차하겠습니다"라고 하는 것과 비슷합니다. 시간이 지나면 자동으로 스레드가 깨어나서 실행을 계속합니다.
스레드 덤프에서 parking 상태가 보인다면, 이는 대부분 정상적인 상황입니다. 멀티스레드 애플리케이션에서는 항상 일부 스레드들이 대기 상태에 있습니다. 하지만 만약 특정 스레드가 너무 오랫동안 parking 상태에 머물러 있다면, 이는 잠재적인 문제를 나타낼 수 있습니다. 예를 들어, 데드락이나 라이브락 같은 동시성 문제가 발생했을 수도 있습니다.
RMI와 스레드 이해하기
먼저 RMI(Remote Method Invocation)가 무엇인지 알아보겠습니다. RMI는 마치 전화 통화처럼 서로 다른 Java 프로그램들이 네트워크를 통해 대화할 수 있게 해주는 기술입니다. 예를 들어, 여러분의 컴퓨터에서 실행 중인 Java 프로그램이 다른 서버에 있는 Java 프로그램의 메서드를 마치 자신의 메서드인 것처럼 호출할 수 있게 해 줍니다.
RMI 시스템에서는 세 가지 주요 스레드를 볼 수 있습니다.
1. "RMI TCP Accept-0" 스레드는 마치 전화 교환원처럼 새로운 RMI 연결 요청을 받아들이는 역할을 합니다. 이 스레드가 RUNNABLE 상태라는 것은 현재 새로운 연결을 받아들일 준비가 되어 있다는 의미입니다.
"RMI TCP Accept-0" #24 [29699] daemon prio=5 os_prio=31 cpu=1.24ms elapsed=59.54s tid=0x000000010f120000 nid=29699 runnable [0x000000017ede6000]
java.lang.Thread.State: RUNNABLE
2. "RMI TCP Connection" 스레드들은 실제로 연결된 프로그램들 간의 통신을 담당합니다. 이들은 마치 전화 통화가 연결된 전화선처럼 데이터를 주고받는 역할을 합니다. 'idle' 상태일 때는 현재 통신이 없다는 의미입니다.
"RMI TCP Connection(idle)" #59 [64519] daemon prio=5 os_prio=31 cpu=6.06ms elapsed=58.71s tid=0x000000012fa66c00 nid=64519 waiting on condition [0x000000030ad02000]
java.lang.Thread.State: TIMED_WAITING (parking)
3. "RMI Scheduler(0)" 스레드는 RMI 시스템의 작업 관리자입니다. 예약된 원격 호출이나 주기적인 작업들을 관리하고 실행하는 역할을 담당합니다. 이 스레드가 TIMED_WAITING 상태인 것은 다음 예약된 작업을 기다리고 있다는 의미입니다.
"RMI Scheduler(0)" #57 [44803] daemon prio=5 os_prio=31 cpu=0.06ms elapsed=58.72s tid=0x000000010f2ac400 nid=44803 waiting on condition [0x000000030aaf6000]
java.lang.Thread.State: TIMED_WAITING (parking)
이러한 RMI 관련 스레드들은 모두 daemon 스레드로 실행되며, 이는 메인 프로그램이 종료되면 함께 종료된다는 것을 의미합니다. 각각의 스레드들이 서로 다른 역할을 수행하면서 전체 RMI 시스템이 안정적으로 작동할 수 있도록 합니다.
또한 RMI 관련 스레드들이 지금처럼 TIMED_WAITING (parking) 상태에 있는 것은 매우 일반적인 현상입니다. 이는 스레드들이 필요할 때만 깨어나서 작업을 처리하고, 그 외의 시간에는 시스템 자원을 절약하기 위해 대기 상태로 있다는 것을 의미합니다. 마치 택시 기사가 손님을 기다리면서 주차해 있는 것과 비슷한 개념입니다.
RMI 시스템은 이러한 스레드들을 통해 안정적이고 효율적인 원격 통신을 가능하게 합니다. TCP Connection 스레드들은 실제 데이터 전송을 담당하고, Scheduler 스레드는 이러한 통신 작업들을 체계적으로 관리하여, 전체 시스템이 원활하게 작동할 수 있도록 돕습니다.
마무리하며
이번 글을 정리하기 전에도 힙덤프를 떠보고 그 내용을 분석했던 적이 있습니다. 그러나 그 당시에는 제가 이렇게까지 상태값을 살펴볼 생각을 하지 못했고 paking이라는 상태가 존재한다는 것도 몰랐습니다. 역시 여러 글을 읽어보면서 넓은 지식을 가지는 것이 굉장히 중요한 것 같습니다. 망나니개발자님 덕분에 이번 기회에 조금 여유를 가지면서 덤프파일을 살펴보니 이전에는 보지 못했던 여러 가지 것들이 보였고 더 깊게 알고 싶다는 생각이 들었던 것입니다.
이렇게 덤프를 분석하며 톰켓에서는 스레드를 어떻게 구성하는지 자세히 알게 되니 애플리케이션의 성능 이슈가 발생했을 때 덤프파일을 살펴보는 것이 얼마나 중요한지 알 것 같습니다. 지금까지는 덤프 분석이 익숙지 않아서 테스트 코드를 작성하거나(스레드 개수 검증) locust로 서버의 tps를 분석하면서 스레드의 상태나 서버의 성능을 분석하곤 했는데 어찌 보면 가장 먼저 덤프를 분석했어야 했던 것 같습니다. 그러니 이제부터라도 덤프 분석에 익숙해지고자 노력해야겠습니다.
긴 글 읽어주셔서 감사합니다. :)