본문 바로가기
삵
Java

자바 스레드 덤프 분석하는 법

주주말만기다려·2026년 10월 7일·조회 2

장애 대응을 하다 보면 "서버는 살아 있는데 요청이 안 돌아온다"는 연락을 받을 때가 있다. 로그에는 에러가 한 줄도 없고, 재시작하면 멀쩡해져서 원인은 확인도 못 한 채 넘어간다. 재시작 버튼을 누르기 전에 스레드 덤프 한 장만 떠 뒀어도 원인을 찾을 단서가 남았을 것이다. 그래서 덤프를 뜨고 읽는 순서를 정리해 둔다.

결론부터 말하자면, 덤프는 jcmd <pid> Thread.print -l로 몇 초 간격을 두고 세 번 정도 뜬다. 그다음 스레드 상태(BLOCKED, WAITING, RUNNABLE)와 락 주소(waiting to lock, locked)를 따라가면 멈춘 원인에 접근할 수 있다. CPU가 튀는 상황이라면 top -H로 찾은 스레드 ID를 16진수로 바꿔 덤프의 nid와 맞춰 본다. JDK 21에서 가상 스레드를 쓴다면 Thread.dump_to_file도 함께 뜬다.

1. 개요

스레드 덤프는 JVM 안의 모든 스레드가 그 순간 어떤 코드를 실행하고 있었는지 찍은 스냅샷이다. 스레드마다 이름, 상태, 스택 트레이스, 쥐고 있거나 기다리는 락 정보가 들어 있다. 힙 덤프가 "메모리에 무엇이 있나"를 보여 준다면, 스레드 덤프는 "지금 누가 무엇을 하고 있나"를 보여 준다.

예를 들어 톰캣 요청 처리 스레드 200개가 전부 같은 synchronized 메서드 앞에서 BLOCKED로 서 있다고 하자. 새 요청을 받을 스레드가 없으니 서비스는 멈춘 것처럼 보인다. 이런 상태는 로그로는 드러나지 않고 덤프에서만 보인다.

덤프를 떠야 하는 경우는 다음과 같다.

  • 응답이 멈췄거나 극단적으로 느린데 에러 로그가 없을 때
  • CPU 사용률이 계속 높게 유지될 때
  • 데드락이 의심될 때(특정 기능만 계속 응답이 없을 때)
  • 스레드 수가 계속 늘어날 때

핵심은 재시작하기 전에 뜬다는 것이다. 재시작하면 증거가 사라진다. 장애 대응 런북에 "재시작 전 덤프 3회"를 한 줄 넣어 두기를 권한다.

2. 덤프 뜨는 방법

방법은 jcmd, kill -3, jstack 세 가지이다. 공식 문서가 권장하는 것은 jcmd이고, 나머지 둘은 상황에 따른 대안이다.

2.1 jcmd Thread.print (권장)

Oracle 트러블슈팅 가이드가 권장 도구로 명시한 방식이다. 먼저 대상 JVM의 pid를 찾는다. 인자 없이 jcmd를 실행하거나 jcmd -l을 실행하면 JVM 프로세스 목록이 나온다.

$ jcmd -l
12345 com.example.order.OrderApplication
23456 jdk.jcmd/sun.tools.jcmd.JCmd -l

(형식을 설명하려고 구성한 예시이며 실측이 아니다. pid와 클래스명은 임의값이다.)

pid를 확인했으면 덤프를 파일로 받는다.

$ jcmd 12345 Thread.print -l > /tmp/tdump_12345_1.txt

Thread.print의 옵션은 두 개다.

  • -l: java.util.concurrent 락 정보까지 출력한다. ReentrantLock을 쓰는 코드라면 이 옵션이 있어야 누가 락을 쥐고 있는지 보인다.
  • -e: 확장 스레드 정보를 출력한다.

장애 상황이라면 고민하지 말고 항상 -l을 붙이기를 권한다. 락 정보가 없다고 그 순간으로 돌아가서 다시 뜰 수는 없다.

2.2 kill -3 (SIGQUIT)

리눅스에서 애플리케이션 콘솔에 Ctrl+\ 를 누르면 HotSpot VM이 스레드 덤프를 표준 출력(stdout)으로 내보낸다. kill -3은 같은 시그널(SIGQUIT)을 프로세스에 보내는 방법이고, 기본 설정의 JVM은 덤프만 출력하고 계속 실행된다.

단, JVM을 -Xrs 옵션으로 띄웠다면 이 방법을 쓰면 안 된다. 공식 문서에 따르면 -Xrs를 주면 JVM이 SIGQUIT 핸들러를 설치하지 않아 리눅스에서 SIGQUIT 스레드 덤프를 쓸 수 없다. 핸들러가 없는 프로세스에 SIGQUIT가 가면 OS 기본 동작이 적용되어 프로세스가 종료될 수 있다. 운영 서비스에 kill -3을 보내기 전에 ps -o args -p <pid>로 실행 옵션에 -Xrs가 없는지 확인한다.

$ kill -3 12345

명령을 쳐도 터미널에는 아무것도 나오지 않는다. 덤프가 대상 프로세스의 stdout으로 가기 때문이다. 어디에 찍히는지는 프로세스를 띄운 방식에 따라 다르다.

  • nohup이나 리다이렉트로 띄웠다면 그 출력 파일(nohup.out, catalina.out 등)
  • systemd 서비스이고 stdout이 저널로 가도록 설정돼 있다면 journalctl -u <서비스명>
  • 컨테이너라면 docker logs나 kubectl logs
$ sudo journalctl -u order-app --since "5 min ago" > /tmp/tdump_journal.txt

jcmd가 없는 JRE 전용 환경이나 attach가 막힌 환경에서 최후 수단으로 쓰되, 위의 -Xrs 확인을 먼저 한다. 덤프가 애플리케이션 로그와 섞인다는 단점이 있다.

2.3 jstack

오래 써 온 도구라 손에 익은 분이 많다. 다만 JDK 21 man page에는 jstack이 experimental and unsupported로 표기돼 있고, 이후 JDK에서 빠질 수 있다고 적혀 있다. 트러블슈팅 가이드도 jcmd를 쓰라고 안내한다. 런북을 새로 쓴다면 jcmd 기준으로 적기를 권한다.

$ jstack -l 12345 > /tmp/tdump_12345_1.txt

JDK 21 man page 기준으로 문서화된 옵션은 -l(락 정보 추가)과 -h(도움말)뿐이다.

2.4 권한과 컨테이너 때문에 jcmd가 안 붙나요?

jcmd는 같은 머신에서, JVM을 띄운 것과 같은 effective user/group으로 실행해야 한다. 앱이 tomcat 계정으로 도는데 다른 계정으로 jcmd를 치면 attach에 실패할 수 있다. root로 치면 될 것 같지만 계정이 다르면 붙지 않는 경우가 있다. 앱을 띄운 계정으로 바꿔서 실행하면 풀린다.

$ ps -eo pid,user,cmd | grep [j]ava
$ sudo -u tomcat jcmd 12345 Thread.print -l > /tmp/tdump_12345_1.txt

도커 컨테이너 안에서 도는 JVM은 호스트의 jcmd -l 목록에서 빠진다(공식 문서 명시). ps로 pid를 찾거나, 컨테이너 안에서 jcmd를 실행한다. 컨테이너 안에서는 JVM이 PID 1인 경우가 많지만 이미지 구성에 따라 다르니 먼저 목록을 확인한다.

$ docker exec order-app jcmd -l
$ docker exec order-app jcmd 1 Thread.print -l > /tmp/tdump_container_1.txt

JRE만 담은 슬림 이미지에는 jcmd 자체가 없을 수 있다. 그때는 2.2의 SIGQUIT 방식으로 돌아간다. JVM이 컨테이너의 PID 1이고 -Xrs가 없다면 docker kill --signal=QUIT order-app으로 시그널을 보내고 docker logs에서 덤프를 꺼낸다.

3. 덤프 읽는 법

3.1 헤더와 스레드 한 줄의 구성

jcmd 출력은 맨 위에 pid와 시각이 찍히고, 그 아래에 Full thread dump Java HotSpot(TM) 64-Bit Server VM (...)으로 시작하는 헤더가 온다. 그다음부터 스레드가 하나씩 나열된다. 아래는 형식을 설명하려고 구성한 예시이며 실측이 아니다. 주소와 ID는 모두 임의값이다.

12345:
(덤프 시각)
Full thread dump Java HotSpot(TM) 64-Bit Server VM (... mixed mode):

"http-nio-8080-exec-7" #45 daemon prio=5 tid=0x00007f3a2c01a000 nid=0x3070 waiting for monitor entry [0x00007f39f8bfe000]
   java.lang.Thread.State: BLOCKED (on object monitor)
        at com.example.order.StockService.decrease(StockService.java:42)
        - waiting to lock <0x00000000c5a1b2c8> (a com.example.order.StockService)
        at com.example.order.OrderController.create(OrderController.java:31)
        ...

첫 줄의 항목은 다음과 같다.

  • "http-nio-8080-exec-7": 스레드 이름. 어느 스레드 풀 소속인지 여기서 가장 먼저 드러난다.
  • prio: 자바 스레드 우선순위.
  • tid: JVM 내부 스레드 구조체의 주소. 분석에서 쓸 일은 많지 않다.
  • nid: OS 네이티브 스레드 ID를 16진수로 쓴 값. CPU 분석에서 top 결과와 맞춰 볼 때 쓴다.
  • 그 뒤의 문구와 java.lang.Thread.State 줄: 스레드 상태.

JDK 버전에 따라 이 줄에 os_prio 같은 필드가 더 붙을 수 있다. 읽는 방법은 같다.

3.2 스레드 상태는 무엇을 뜻하나?

java.lang.Thread.State Javadoc 기준 정의다.

  • NEW: 아직 시작하지 않은 스레드.
  • RUNNABLE: JVM에서 실행 중인 상태. 단, 프로세서 같은 OS 자원을 기다리고 있을 수 있다.
  • BLOCKED: 모니터 락을 기다리는 상태. synchronized 블록이나 메서드에 들어가려 하거나, Object.wait() 후 다시 들어가려는 경우다.
  • WAITING: 다른 스레드의 특정 동작을 기한 없이 기다리는 상태. 타임아웃 없는 Object.wait(), Thread.join(), 그리고 LockSupport.park()가 여기에 해당한다.
  • TIMED_WAITING: 기한을 두고 기다리는 상태. Thread.sleep, Object.wait(long), Thread.join(long), LockSupport.parkNanos, parkUntil이 해당한다.
  • TERMINATED: 종료된 스레드.

가장 많이 오해하는 부분은 RUNNABLE이 CPU를 쓰고 있다는 뜻은 아니라는 점이다. 정의상 OS 자원을 기다리는 중일 수 있다. 네트워크 소켓에서 응답을 기다리는 스레드가 RUNNABLE로 찍혀 있는 모습도 흔히 보인다. "RUNNABLE이 많으니 CPU 문제"로 바로 연결하면 헛다리를 짚기 쉽다. CPU 문제인지는 4.4의 방법으로 따로 확인한다.

3.3 락 표기 읽기

스택 프레임 사이에 끼어 있는 락 줄이 분석의 핵심이다.

  • - waiting to lock <0x...>: 그 주소의 모니터 락을 얻으려고 BLOCKED로 기다리는 중.
  • - locked <0x...>: 그 모니터를 이미 쥐고 있음.
  • - parking to wait for <0x...>: LockSupport.park 계열로 대기 중. ReentrantLock, 블로킹 큐, 각종 풀 대기에서 보인다.

BLOCKED 스레드의 waiting to lock 주소를 복사해서, 같은 주소를 locked로 가진 스레드를 찾는다. 그 스레드가 다른 스레드를 막고 있는 당사자다. 그 스레드의 스택을 보면 락을 쥔 채 무엇을 하느라 놓지 않는지 보인다.

$ grep -n "c5a1b2c8" /tmp/tdump_12345_1.txt

java.util.concurrent 락은 모니터와 달리 locked 줄로 드러나지 않을 수 있다. 이때는 -l 옵션으로 뜬 덤프에서 각 스레드 아래의 Locked ownable synchronizers 항목을 확인한다. 2.1에서 -l을 항상 붙이라고 한 이유가 이것이다.

4. 패턴별 진단

덤프 모양에 따라 어떤 원인을 의심할지 정리한다. 같은 모양이라도 원인은 여러 가지일 수 있으니 출발점으로만 쓴다.

4.1 데드락

jcmd와 jstack은 덤프를 뜰 때 자동으로 데드락 탐지를 수행한다. 데드락이 있으면 스레드 목록 뒤에 Found one Java-level deadlock으로 시작하는 섹션이 붙는다. 아래는 형식을 설명하려고 구성한 예시이며 실측이 아니다. 주소는 임의값이다.

Found one Java-level deadlock:
=============================
"worker-1":
  waiting to lock monitor 0x00007f3a1c004e28 (object 0x00000000c5a10010, a com.example.Account),
  which is held by "worker-2"

"worker-2":
  waiting to lock monitor 0x00007f3a1c007a18 (object 0x00000000c5a10028, a com.example.Account),
  which is held by "worker-1"

Java stack information for the threads listed above:
===================================================
...

worker-1은 worker-2가 쥔 락을, worker-2는 worker-1이 쥔 락을 기다리는 순환이다. 아래쪽 스택 정보에서 두 스레드가 어느 코드 줄에서 락을 잡았는지 확인하고, 락을 잡는 순서를 통일하는 방향으로 고친다. 데드락이 없을 때는 데드락이 없다는 문구가 찍히는데, 정확한 문구와 위치는 JDK 버전에 따라 다를 수 있다.

덤프를 열면 grep -A 30 "deadlock" 덤프파일로 이 섹션부터 확인하는 습관을 들이면 데드락 여부를 가장 빨리 가릴 수 있다.

4.2 BLOCKED가 한 락에 몰려 있을 때

요청 처리 스레드 다수가 BLOCKED이고 waiting to lock 주소가 하나로 모인다면 락 경합을 의심한다. 데드락 섹션은 없는데 응답이 멈춘 경우다. 3.3의 방법으로 그 주소를 locked한 스레드를 찾고, 그 스레드가 락을 쥔 채 외부 API 호출이나 DB 쿼리 같은 느린 작업을 하고 있지 않은지 본다.

주소별로 대기 스레드가 몇 개씩 몰렸는지 세는 명령이다.

$ grep "waiting to lock" /tmp/tdump_12345_1.txt | sort | uniq -c | sort -rn | head

4.3 WAITING(parking)이 몰려 있을 때

WAITING 자체는 정상일 수 있다. 일이 없는 스레드 풀 워커는 작업 큐에서 park로 대기하므로, 한가한 서버의 덤프도 WAITING으로 가득할 수 있다. 그래서 상태보다 어디서 기다리는지를 본다.

요청 처리 스레드들이 커넥션 풀의 대여 메서드(getConnection 계열 프레임)나 HTTP 클라이언트의 커넥션 획득 지점에서 parking to wait for로 멈춰 있다면 풀 고갈을 의심한다. 다음에는 풀의 자원을 쥐고 있는 스레드들이 무엇을 하는지 따라간다. 느린 쿼리, 응답 없는 외부 호출, 커넥션 반납 누락이 후보다. 원인을 모른 채 풀 최대 크기만 키우면 같은 증상이 조금 늦게 다시 나타날 수 있다.

4.4 CPU가 높을 때는 top -H와 nid로 찾는다

CPU를 먹는 스레드는 덤프만으로는 고를 수 없다. OS에서 스레드별 CPU를 먼저 본 다음 덤프와 맞춘다.

top -H는 스레드 단위로 보여 주는 옵션이고, PID 칸에 나오는 값이 스레드 ID(10진수)다.

$ top -H -p 12345
$ top -H -b -n 1 -p 12345 | head -20     # 파일로 남길 때

CPU가 가장 높은 스레드 ID를 16진수로 바꾼다. 스레드 ID가 12400이라면 다음과 같다.

$ printf '%x\n' 12400
3070

덤프에서 nid=0x3070를 찾는다.

$ grep -A 20 "nid=0x3070 " /tmp/tdump_12345_1.txt

top과 덤프는 거의 동시에 떠야 의미가 있다. top 확인 직후 바로 덤프를 뜨고, 이 쌍을 몇 번 반복하기를 권한다. 찾은 스레드가 매번 같은 반복문이나 정규식, 직렬화 코드 안에 있다면 그곳이 유력한 후보다.

찾은 스레드가 GC 스레드나 C2 CompilerThread 같은 JVM 내부 스레드라면 애플리케이션 코드가 아니라 GC나 JIT 쪽을 봐야 한다. GC 스레드라면 GC 로그와 힙 사용량부터 확인한다. 네이티브 프레임까지 봐야 할 때는 jhsdb jstack --mixed --pid 12345로 자바 프레임과 C/C++ 프레임이 섞인 스택을 뽑을 수 있다.

4.5 상태별 대응 표

덤프에서 보이는 모양의심할 것다음 확인
Found one Java-level deadlock 섹션이 있음데드락순환에 걸린 스레드의 락 획득 순서
BLOCKED 다수, waiting to lock 주소가 하나로 모임모니터 락 경합같은 주소를 locked한 스레드의 스택
요청 스레드가 풀 대여 지점에서 WAITING(parking)커넥션 풀 등 자원 풀 고갈자원을 쥔 스레드가 하는 일, 반납 누락
WAITING이지만 작업 큐 대기 지점정상 유휴 상태일 수 있음다른 스레드부터 확인
RUNNABLE 다수, 네트워크 읽기 프레임외부 응답 대기(CPU 문제가 아닐 수 있음)대상 시스템 응답 시간, 타임아웃 설정
프로세스 CPU 높음특정 스레드의 연산 루프, GCtop -H TID를 16진수로 바꿔 nid 대조
TIMED_WAITING 다수, sleep 지점재시도, 폴링 로직재시도 간격과 횟수

5. 여러 장을 떠서 비교한다

덤프 한 장은 그 순간만 보여 준다. 락을 잠깐 기다리던 스레드가 우연히 찍혔을 수도 있다. 그래서 몇 초 간격으로 3회 안팎 연속으로 떠서 같은 스레드가 같은 스택에 계속 머물러 있는지 확인하기를 권한다. 세 장 모두 같은 줄에 서 있다면 실제로 멈춰 있을 가능성이 높다.

물론 수동으로 세 번 실행해도 되지만, 장애 중에는 실수하기 쉬우니 짧은 스크립트로 만들어 둔다.

#!/bin/bash
# 사용법: ./tdump.sh <pid> [횟수] [간격초]
PID=$1
COUNT=${2:-3}
INTERVAL=${3:-5}
OUT=/tmp/tdump_${PID}_$(date +%Y%m%d_%H%M%S)
mkdir -p "$OUT"

for i in $(seq 1 "$COUNT"); do
  top -H -b -n 1 -p "$PID" | head -30 > "$OUT/top_$i.txt"
  jcmd "$PID" Thread.print -l > "$OUT/dump_$i.txt"
  sleep "$INTERVAL"
done
echo "saved: $OUT"

2.4의 계정 조건은 스크립트에도 그대로 적용된다. 앱 계정으로 실행한다(sudo -u tomcat ./tdump.sh 12345).

뜬 다음에는 장마다 상태 분포부터 비교한다.

$ for f in /tmp/tdump_12345_*/dump_*.txt; do echo "== $f"; grep "java.lang.Thread.State" "$f" | sort | uniq -c; done

특정 스레드가 의심되면 장마다 그 스레드의 스택만 뽑아 나란히 본다.

$ grep -h -A 15 '"http-nio-8080-exec-7"' /tmp/tdump_12345_*/dump_*.txt

jcmd 문서는 Thread.print의 영향도를 Medium(스레드 수에 따라 다름)으로 적고 있다. 스레드가 아주 많은 JVM에서 짧은 간격으로 수십 번 뜨는 것은 피한다. 몇 초 간격으로 몇 회면 충분하다.

6. 가상 스레드를 쓴다면 덤프를 하나 더 뜬다

JDK 21의 가상 스레드는 JVM이 관리하는 가벼운 스레드다. 적은 수의 플랫폼 스레드(OS 스레드와 짝을 이루는 기존 방식의 스레드) 위에서 대량으로 돌아간다. 가상 스레드까지 포함한 덤프는 별도 명령으로 뜬다.

$ jcmd 12345 Thread.dump_to_file -format=text /tmp/vthreads_12345.txt
$ jcmd 12345 Thread.dump_to_file -format=json /tmp/vthreads_12345.json

이 덤프는 플랫폼 스레드와 가상 스레드를 모두 담는다. 대신 Oracle 문서에 따르면 객체 주소, 락, JNI 통계, 힙 통계 같은 전통적인 덤프 정보는 들어 있지 않다. 3.3의 락 주소 추적을 이 덤프로는 할 수 없다는 뜻이다.

그래서 가상 스레드를 쓰는 서비스라면 둘을 같이 뜬다. 락 경합과 데드락은 Thread.print -l로 보고, 가상 스레드가 어디에 몰려 있는지는 Thread.dump_to_file로 본다. 스레드 수가 많을 때는 JSON 형식이 스크립트로 집계하기 편하다.

7. 마무리

멈춘 서비스를 재시작하기 전에 jcmd <pid> Thread.print -l로 몇 초 간격 3회를 남기고, 데드락 섹션, BLOCKED가 몰린 락 주소, WAITING이 몰린 대기 지점 순으로 본다. CPU 문제는 top -H의 TID를 16진수로 바꿔 nid와 맞춘다. jcmd가 안 붙으면 실행 계정과 컨테이너 여부부터 확인하고, 그래도 안 되면 kill -3으로 뜬 뒤 stdout이 향하는 로그에서 꺼낸다.

5장의 스크립트를 서버에 미리 올려 두고 런북에 경로를 적어 두면 다음 장애 때 바로 쓸 수 있다. 계정명과 경로는 각자 환경에 맞게 바꿔서 쓴다.

자주 묻는 질문

jstack과 jcmd Thread.print는 결과가 다른가?

둘 다 전체 스레드의 스택과 상태를 출력하고 자동 데드락 탐지도 수행한다. 다만 JDK 21 man page는 jstack을 experimental and unsupported로 표기하고 이후 JDK에서 빠질 수 있다고 적고 있으며, Oracle 트러블슈팅 가이드는 jcmd를 권장 도구로 안내한다. 새로 스크립트나 런북을 만든다면 jcmd Thread.print -l 기준으로 작성하는 것을 권한다.

kill -3을 보내면 서비스가 죽지 않나?

기본 설정의 JVM은 SIGQUIT을 받으면 스레드 덤프를 출력하고 프로세스는 계속 실행된다. 다만 -Xrs 옵션으로 띄운 JVM은 리눅스에서 SIGQUIT 핸들러를 설치하지 않아 덤프가 나오지 않고, OS 기본 동작으로 프로세스가 종료될 수 있으니 실행 옵션부터 확인한다. 덤프는 명령을 친 터미널이 아니라 대상 프로세스의 표준 출력으로 나가므로, nohup.out이나 catalina.out, systemd 저널, docker logs처럼 stdout이 향하는 곳에서 찾는다.

가상 스레드를 쓰면 Thread.print만으로 충분한가?

JDK 21 문서는 가상 스레드까지 포함한 덤프로 jcmd Thread.dump_to_file(-format=text 또는 json)을 안내한다. 이 덤프에는 객체 주소와 락 정보가 없으므로, 락 경합과 데드락 분석은 Thread.print -l로 하고 가상 스레드 분포는 Thread.dump_to_file로 확인하는 식으로 둘을 함께 뜨는 것을 권한다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.