가상 스레드가 캐리어 스레드에서 떨어져 나오지 못하고 붙어 있는 상태. 붙잡힌 캐리어는 그동안 다른 가상 스레드를 받지 못한다.
왜 문제인가 — 개수가 코어 수뿐이다
캐리어 스레드는 기본적으로 CPU 코어 수만큼만 존재한다. 8코어라면 8개다. 가상 스레드 5,000개가 떠 있어도, 그 중 8개가 pinning된 채 각각 100ms짜리 DB 호출을 기다리면 나머지 4,992개는 실행될 자리 자체가 없다. 스레드는 넉넉한데 애플리케이션이 멈추는, 원인 찾기 어려운 장애가 된다.
Java 21~23의 원인 — synchronized
synchronized (lock) {
jdbcTemplate.query(...); // ← 여기서 블로킹하면 캐리어에 고정됐다
}
이유는 모니터의 소유권 기록 방식에 있었다. synchronized는 객체에 딸린 모니터(한 번에 하나만 들어가게 하는 잠금 장치)를 잡는데, JVM이 "이 모니터의 주인은 누구인가"를 캐리어(OS 스레드)의 정체성으로 기록했다. 가상 스레드를 다른 캐리어로 옮기면 주인이 바뀐 것처럼 보이므로, 안전하게 옮길 방법이 없어 고정할 수밖에 없었다.
진짜 문제는 이게 내 코드의 문제가 아니었다는 점이다. JDBC 드라이버·커넥션 풀·로깅 프레임워크 내부에 synchronized가 널려 있어서, 내가 한 번도 안 썼는데도 부하가 오르면 캐리어가 말라붙었다. 당시 처방이 ReentrantLock으로 바꾸는 것이었다 — 모니터가 아니라 평범한 객체의 필드 상태로 잠금을 표현하므로 캐리어와 무관하고, 따라서 언마운트가 가능하다.
Java 24 이후 — 남은 셋
JEP 491이 모니터 소유권을 가상 스레드 자신의 정체성으로 추적하도록 고쳐서 이 원인은 사라졌다. 지금 남은 pinning은 셋뿐이다.
| 남은 원인 | 왜 |
|---|---|
| 네이티브 프레임(JNI·FFM) | 자바 밖의 C/C++ 코드가 스택 주소를 직접 들고 있을 수 있어, 스택을 힙으로 옮기면 그 주소가 무효가 된다 |
| 클래스 로딩·정적 초기화 | 클래스 초기화 도중에는 언마운트 불가. 우회법 없음 |
| 리눅스 로컬 파일 I/O | 파일 읽기·쓰기를 논블로킹으로 처리할 io_uring 통합이 JDK에 아직 없다 |
실무 기준으로 "웹 요청 → DB·HTTP 호출"이 대부분인 서버에서는 사실상 사라졌다고 봐도 된다.
진단
-Djdk.tracePinnedThreads=full # pinning 발생 시 스택 트레이스 출력 (Java 21~23)
이후 버전에서는 JFR(Java Flight Recorder — JVM 내장 이벤트 기록기)의 관련 이벤트로 본다.
면접 함정
- ❌ "가상 스레드에서
synchronized는 쓰면 안 된다" → Java 23 이하 기준의 옛 조언이다. 버전을 함께 말해야 맞는 답이 된다. - ❌ "
ReentrantLock으로 다 바꿔야 한다" → 이제 pinning 목적으로는 불필요하다.tryLock·Condition같은 기능이 필요할 때만 쓴다.
진단 출력 읽기
java -Djdk.tracePinnedThreads=full -jar app.jar
Thread[#28,ForkJoinPool-1-worker-2,5,CarrierThreads]
- java.base/java.lang.VirtualThread$VThreadContinuation.onPinned
- app//com.zaxxer.hikari.pool.HikariPool.getConnection <== monitors:1
- ^^^^^^^^^^
- 여기서 모니터를 쥔 채 블로킹했다
monitors:1이 모니터(synchronized) 때문에 고정됐다는 표시다. 이 자리에 남의 라이브러리 이름이 찍히면 당시에는 손쓸 방법이 없었다.
지금은 어떻게 보나
Java 24부터는 모니터 pinning이 없으므로 이 플래그의 쓸모가 줄었다. 남은 세 원인은 JFR(Java Flight Recorder — JVM에 내장된 이벤트 기록기)의 관련 이벤트로 관측하고, 실무에서는 보통 캐리어 스레드 수 근처에서 처리량이 정체되는지로 먼저 의심한다.