Virtual Thread Pinning 심화 분석과 해결 전략
Virtual Thread가 Carrier 스레드에 고정(Pinned)되는 조건, JFR로 Pinning을 탐지하는 방법, ReentrantLock 교체 및 Java 23 JEP 491 업그레이드 전략을 상세히 정리합니다.
지난 글에서 Structured Concurrency로 여러 Virtual Thread를 안전하게 묶어 관리하는 방법을 살펴봤습니다. Virtual Thread의 가장 큰 장점은 I/O 블로킹 시 Carrier 스레드를 해방해 다른 작업을 처리하는 것인데, Pinning이 발생하면 이 메커니즘이 깨져 처리량이 급격히 떨어집니다. 이번 글에서는 Pinning이 발생하는 정확한 조건과 이를 탐지하고 해결하는 전략을 깊이 있게 다룹니다.
Pinning이란
Virtual Thread는 실행 중 I/O 블로킹이 발생하면 Carrier Thread(OS 스레드)에서 분리(unmount) 되고, 블로킹이 끝나면 다시 올라탑니다(mount). 이 덕분에 소수의 Carrier 스레드로 수백만 개의 Virtual Thread를 처리할 수 있습니다.
Pinning은 Virtual Thread가 Carrier에서 분리되지 못하고 고정된 채로 블로킹되는 상태입니다. Carrier 스레드 전체가 함께 묶여 버려, 해당 스레드가 다른 Virtual Thread를 처리하지 못하게 됩니다.
Pinning 발생 조건
Pinning은 두 가지 상황에서 발생합니다.
1. synchronized 블록/메서드 내 블로킹 (Java 21~22)
// ❌ Carrier Thread가 함께 블로킹됨 (Java 21~22)
synchronized (lock) {
byte[] buf = new byte[4096];
socket.getInputStream().read(buf); // I/O → Carrier 고정
}
synchronized는 JVM 내부적으로 monitor 잠금을 사용하는데, Java 21~22까지는 monitor 보유 중에 Virtual Thread를 unmount할 수 없었습니다.
2. Native Frame (JNI 코드)의 존재
// ❌ native 메서드 호출 스택에서 블로킹 — 버전 무관
nativeMethod(); // native frame 포함 스택 → 항상 Pinned
JNI native frame이 콜 스택에 존재하면 스택 포인터를 안전하게 교체할 수 없어 unmount가 불가능합니다. Java 버전에 관계없이 이 제약은 유지됩니다.
Pinning 탐지 방법
JVM 플래그로 스택 추적
개발 환경에서는 다음 플래그로 Pinning 발생 즉시 스택 트레이스를 출력할 수 있습니다.
# full: 전체 스택, short: 핵심 프레임만
java -Djdk.tracePinnedThreads=full -jar app.jar
출력 예시:
Thread[#22,ForkJoinPool-1-worker-1,5,main]
java.base/java.lang.VirtualThread$PinnedScope.block(VirtualThread.java)
com.example.UserService.loadUser(UserService.java:42)
...
<== monitors:1
<== monitors:1이 표시되면 synchronized 잠금이 Pinning 원인입니다.
JFR(Java Flight Recorder) 이벤트 수집
프로덕션 환경에서는 JFR의 jdk.VirtualThreadPinned 이벤트를 활용합니다.
# JFR 수집 시작
jcmd <pid> JFR.start name=pin duration=60s \
settings=profile filename=pinning.jfr
# 덤프 후 분석
jcmd <pid> JFR.dump name=pin filename=pinning.jfr
JFR 파일 분석 코드:
Path jfrPath = Path.of("pinning.jfr");
try (RecordingFile rf = new RecordingFile(jfrPath)) {
rf.readAllEvents().stream()
.filter(e -> "jdk.VirtualThreadPinned"
.equals(e.getEventType().getName()))
.forEach(e -> {
System.out.println("Duration: " + e.getDuration());
System.out.println("Stack: " + e.getStackTrace());
});
}
이벤트에는 스레드 ID, 지속 시간, 스택 트레이스가 포함되어 있어 어느 코드 경로에서 Pinning이 발생하는지 정확히 파악할 수 있습니다.
해결 전략
전략 1 — synchronized → ReentrantLock 교체 (Java 21~22)
Java 21~22를 사용 중이라면 synchronized 대신 java.util.concurrent.locks.ReentrantLock을 사용합니다.
import java.util.concurrent.locks.ReentrantLock;
public class UserService {
private final ReentrantLock lock = new ReentrantLock();
public User loadUser(long id) {
lock.lock();
try {
// I/O 블로킹이 발생해도 Carrier Thread 해방 가능
return repository.findById(id);
} finally {
lock.unlock();
}
}
}
ReentrantLock은 LockSupport.park()를 사용하므로 Virtual Thread가 정상적으로 unmount됩니다.
전략 2 — Java 23 이상으로 업그레이드 (JEP 491)
Java 23에서 JEP 491이 구현되어 synchronized 블록/메서드 내 블로킹 I/O에서도 Pinning이 발생하지 않게 됐습니다. synchronized의 monitor 잠금을 Virtual Thread가 unmount되는 동안에도 보유할 수 있도록 JVM 내부를 재설계했습니다.
// Java 23+에서는 아무 문제 없음
synchronized (lock) {
socket.getInputStream().read(buf); // Pinning 없음
}
신규 프로젝트라면 Java 23+(또는 LTS 관점에서 Java 25) 사용을 권장합니다.
전략 3 — native/JNI 코드 격리
native frame 기반 Pinning은 어느 버전에서도 해결되지 않습니다. native 메서드를 호출해야 할 때는 Virtual Thread 대신 Platform Thread 전용 풀을 사용합니다.
ExecutorService nativePool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
CompletableFuture.supplyAsync(() -> {
// native 코드 실행 — platform thread에서
return nativeLibrary.process(data);
}, nativePool).thenAccept(result -> {
// Virtual Thread에서 후속 처리
virtualPool.execute(() -> handleResult(result));
});
전략 4 — Carrier Pool 크기 임시 조정
근본 해결이 아닌 임시방편이지만, Pinning이 발생하더라도 다른 Virtual Thread가 실행될 Carrier를 확보할 수 있습니다.
-Djdk.virtualThreadScheduler.maxPoolSize=256
기본값은 CPU 코어 수이며, Pinning이 많은 환경에서는 이 값을 늘려 처리량 저하를 완화할 수 있습니다. 단, 이는 OS 스레드 수를 늘리는 것이므로 메모리 부담이 증가합니다.
Pinning 위험도 판단 기준
모든 Pinning이 위험한 것은 아닙니다. 짧은 CPU-bound 연산 중 Pinning은 실질적 영향이 미미합니다. 문제가 되는 경우는 다음과 같습니다.
- Pinning 지속 시간이 수십 ms 이상인 경우
- 동시 Pinning 수가 Carrier Pool 크기에 근접하는 경우
- Pinning 중 추가 Virtual Thread 생성이 큐에서 대기하는 경우
JFR의 jdk.VirtualThreadPinned 이벤트 duration 필드를 기준으로 임계값(>20ms)을 초과하는 이벤트에 집중하는 것이 효과적입니다.
지난 글: Structured Concurrency로 안전한 비동기 구조 만들기
다음 글: Virtual Thread 운영 모범 사례
읽어주셔서 감사합니다. 😊