Java 가상 스레드 JDK 26 실측: 한계·피닝·경쟁 상태
sleep 작업 10만 개를 가상 스레드와 플랫폼 스레드로 실측. macOS 에서 죽는 지점, CPU 작업에 효과 없는 이유, JEP 491 이후 피닝, 경쟁 상태와 구조적 동시성까지 다룬다.
시리즈: Java 강좌 5/6 (고급). ExecutorService 는 쓸 줄 알지만 "가상 스레드로 바꾸면 다 확장된다"는 말을 직접 확인해 본 적은 없는 분을 위한 글입니다. 다 읽으면 가상 스레드가 효과 있는 경우와 없는 경우를 숫자로 설명하고, 처음에 반드시 만나는 컴파일 오류 2 개와 '조용히 사라지는 예외'를 피하고, 경쟁 상태를 세 가지 방법으로 고칠 수 있습니다.
외부 API 나 DB 응답을 기다리는 시간이 대부분인 Spring 백엔드라면 가상 스레드는 Java 동시성에서 20 년 만의 가장 큰 변화입니다. 반대로 계산만 하는 배치에는 아무 효과가 없습니다. 이 글은 두 경우를 모두 실측합니다. 백엔드 면접에 단골로 나오는 "synchronized 는 가상 스레드를 피닝하니 ReentrantLock 으로 바꿔야 한다"는 답이 JDK 26 에서도 맞는지도 확인했습니다. 결론부터 말하면 그 답은 대부분 낡았습니다.
실행 환경: OpenJDK 26 (Temurin 26+35), Apple Silicon Mac (M1 Pro, 8 코어, 메모리 16GB), macOS. 메모리는 /usr/bin/time -l 이 보고한 최대 상주 메모리(RSS)이고, 따로 적지 않으면 3 회 중앙값입니다. 시간에는 JVM 시작과 JIT 워밍업이 포함되지만, 여기서 보는 차이는 10 배 이상이라 결론에는 영향이 없습니다.
sleep 작업 1만 개·10만 개: 플랫폼 스레드가 멈추는 지점
고전적인 실험입니다. 1 초씩 sleep 하는 작업을 N 개 띄우고 모두 끝나기를 기다립니다. sleep 은 느린 네트워크 호출 대신입니다. 파일 하나로 세 가지 실행 방식을 바꿔 가며 돌립니다.
import java.time.Duration;
import java.util.concurrent.*;
/** Start N tasks that each sleep 1 s. Usage: java SleepTasks.java <virtual|platform|pool200> <N> */
public class SleepTasks {
public static void main(String[] args) throws Exception {
String mode = args[0];
int n = Integer.parseInt(args[1]);
ExecutorService ex = switch (mode) {
case "virtual" -> Executors.newVirtualThreadPerTaskExecutor();
case "platform" -> Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory());
case "pool200" -> Executors.newFixedThreadPool(200);
default -> throw new IllegalArgumentException(mode);
};
long t0 = System.nanoTime();
try (ex) { // close() waits for all tasks
for (int i = 0; i < n; i++) {
ex.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return null; });
}
}
long ms = (System.nanoTime() - t0) / 1_000_000;
Runtime rt = Runtime.getRuntime();
long usedMb = (rt.totalMemory() - rt.freeMemory()) >> 20;
System.out.printf("%-8s n=%,d wall=%,d ms heapUsed=%d MB%n", mode, n, ms, usedMb);
}
}
| 실행 방식 | 작업 수 | 실제 시간 | 최대 RSS |
|---|---|---|---|
| 작업마다 플랫폼 스레드 | 1,000 | 1,078ms | 124MB |
| 작업마다 플랫폼 스레드 | 4,000 | 1,304ms | 261MB |
| 작업마다 플랫폼 스레드 | 10,000 | 비정상 종료 | – |
| 작업마다 가상 스레드 | 1,000 | 1,024ms | 57MB |
| 작업마다 가상 스레드 | 4,000 | 1,040ms | 71MB |
| 작업마다 가상 스레드 | 10,000 | 1,068ms | 90MB |
| 작업마다 가상 스레드 | 100,000 | 1,791ms | 210MB |
| 스레드 200 개 고정 풀 | 10,000 | 50,375ms (1 회) | 73MB |
가상 스레드 10만 개가 플랫폼 스레드 4,000 개보다 메모리를 덜 썼습니다. 스레드 200 개 고정 풀은 메모리를 아끼는 예전 방식인데, 대신 시간을 냅니다. 1 초짜리 작업 1만 개를 200 개씩 처리하면 50 바퀴이고, 50,375ms 는 정확히 그 계산과 맞습니다. 10만 개를 풀로 돌리는 실험은 하지 않았습니다(같은 계산으로 약 500 초).
그리고 플랫폼 스레드는 1만 개에서 느려진 게 아니라 죽었습니다.
[0.453s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 2048k, guardsize: 16k, detached.
[0.453s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-4067"
Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1486)
...
at SleepTasks.main(SleepTasks.java:18)
OutOfMemoryError 라고 나오지만 힙은 넉넉했습니다. 단서는 'Thread-4067' 입니다. 이 Mac 에서 sysctl kern.num_taskthreads 를 실행하면 4096 이 나옵니다. macOS 의 프로세스당 스레드 한도이고, GC·JIT 같은 JVM 자체 스레드를 더하면 사용자 스레드 4,000 개 남짓에서 한도에 닿습니다. 리눅스 서버에서는 ulimit -u 나 컨테이너 설정이 한도를 정하므로 숫자는 달라지지만 메시지는 같습니다. 이 오류를 보면 -Xmx 를 늘리기 전에 스레드 수부터 의심하세요.
가상 스레드는 어떻게 코어를 막지 않고 블록되나
가상 스레드는 OS 가 아니라 JDK 가 스케줄하는 Java 객체입니다. JDK 는 이것을 기본값으로 코어 수만큼의 '캐리어 스레드'로 된 ForkJoinPool 위에서 돌립니다. 가상 스레드가 JDK 가 대응해 둔 블록 작업(sleep, 소켓 I/O, ReentrantLock, BlockingQueue, JDK 24 부터는 모니터도)에 들어가면 언마운트되어 스택 프레임이 힙으로 옮겨지고, 캐리어는 다른 가상 스레드를 실행합니다. 위 표에서 10만 작업일 때 힙 사용량이 약 122MB 까지 늘었는데도 RSS 가 작았던 이유가 이것입니다.
가상 스레드가 소용없는 CPU 바운드 예
블록되지 않는 작업에는 언마운트할 기회가 없습니다. xorshift 를 2억 번 돌리는 작업 64 개로 비교했습니다.
static long work(long seed) { // pure CPU: xorshift loop
long x = seed;
for (int i = 0; i < 200_000_000; i++) { x ^= x << 13; x ^= x >>> 7; x ^= x << 17; }
return x;
}
// run(...) 은 작업 64 개를 넣고 결과를 합친다.
// 먼저 work(1) 을 한 번 호출해 JIT 컴파일을 끝내 둔다
| 실행 방식 (64 작업) | 1 회 | 2 회 | 3 회 | 중앙값 |
|---|---|---|---|---|
| 단일 스레드 | 25,362ms | 25,622ms | 25,024ms | 25,362ms |
| 스레드 8 개 고정 풀 | 4,385ms | 4,530ms | 4,332ms | 4,385ms |
| 작업마다 가상 스레드 | 4,741ms | 4,191ms | 4,695ms | 4,695ms |
| 작업마다 플랫폼 스레드 | 3,992ms | 4,161ms | 3,920ms | 3,992ms |
병렬로 돌린 세 방식은 모두 단일 스레드의 약 5.8 배로, M1 Pro 의 8 코어(성능 6 + 효율 2)가 낼 수 있는 상한입니다. 가상 스레드는 빨라지지 않았고, 회차별 흔들림(4,191~4,741ms)이 방식 간 차이보다 오히려 큽니다. 계산 작업은 코어 수에 맞춘 고정 풀이나 병렬 스트림에 맡기세요. 그래야 I/O 를 기다리는 가상 스레드가 쓸 캐리어를 계산 작업이 차지하지도 않습니다.
JDK 26 의 피닝: JEP 491 이 고친 것과 남은 것
JDK 21 에서는 synchronized 안에서 블록된 가상 스레드가 '피닝'되어, 기다리는 내내 캐리어를 붙잡았습니다. JDK 24 의 JEP 491 이 모니터에 대해서는 이를 없앴습니다. JDK 26 의 동작을 캐리어를 1 개만(-Djdk.virtualThreadScheduler.parallelism=1) 두고 확인했습니다. 피닝이 일어나면 모든 작업이 직렬이 됩니다.
// 경우 1: 작업 100 개가 각자 자기 모니터를 잡은 채 100ms 잔다
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
Object lock = new Object();
ex.submit(() -> { synchronized (lock) { sleep(100); } });
}
}
// 경우 2: 작업 1 개가 static 초기화 블록 안에서 1 초 자고, 다른 100 개는 10ms 만 잔다
static class Slow { static { sleep(1000); } static void touch() {} }
$ java -Djdk.virtualThreadScheduler.parallelism=1 -cp out Pinning
synchronized + sleep : 115 ms
bystanders while <clinit> blocks: 960 ms
경우 1 은 115ms 였습니다. 피닝됐다면 100 × 100ms = 약 10 초가 걸렸을 테니, 모니터는 이제 피닝하지 않습니다. 경우 2 가 아직 남은 피닝입니다. 가상 스레드 하나가 클래스 초기화 블록 안에서 자는 동안, 10ms 면 끝날 작업 100 개가 960ms 를 기다렸습니다. 캐리어를 기본값 8 개로 돌리면 같은 100 개가 11ms 에 끝납니다. 캐리어가 꽉 차기 전에는 드러나지 않으니 운영 환경에서야 발견하는 종류의 문제입니다.
-Djdk.tracePinnedThreads=full 로 추적하려 했지만 JDK 26 에서 이 속성은 아무 일도 하지 않습니다. 같은 두 줄만 나오고 스택 트레이스는 없었으며, JDK 26 클래스 라이브러리(lib/modules)를 검색해도 tracePinnedThreads 라는 문자열이 없었습니다. JEP 491 과 함께 삭제됐습니다. 대신 JFR 이벤트 jdk.VirtualThreadPinned 를 씁니다. 이번 JDK 에는 jfr 명령이 없어서 jdk.jfr.consumer API 로 10 줄 정도의 프로그램을 만들어 기록을 읽었습니다.
$ java -Djdk.virtualThreadScheduler.parallelism=1 -XX:StartFlightRecording=filename=pin.jfr,settings=profile -cp out Pinning
$ java Dump.java pin.jfr
jdk.VirtualThreadPinned {
duration = 1.01 s
blockingOperation = "LockSupport.park"
pinnedReason = "VM call to Pinning$Slow.<clinit> on stack"
carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 33)
eventThread = "" (javaThreadId = 134, virtual)
...
기록된 피닝은 클래스 초기화 블록 1 건뿐이었고, synchronized 안의 sleep 100 번은 한 건도 나오지 않았습니다. pinnedReason 에 원인이 그대로 적히니 예전의 스택 트레이스 출력보다 추적하기 쉽습니다.
흔한 실수: 컴파일 오류 2 개와 조용히 사라지는 예외
처음 쓰는 가상 스레드 코드는 대개 이렇습니다.
int count = 0;
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
ex.submit(() -> { Thread.sleep(10); }); // mistake 1
ex.submit(() -> count++); // mistake 2
}
}
Mistakes.java:8: error: unreported exception InterruptedException; must be caught or declared to be thrown
ex.submit(() -> { Thread.sleep(10); }); // mistake 1
^
Mistakes.java:9: error: local variables referenced from a lambda expression must be final or effectively final
ex.submit(() -> count++); // mistake 2
^
2 errors
실수 1 → 무슨 일이 생기나 → 고치는 법. 값을 돌려주지 않는 블록 람다는 submit(Runnable) 에 맞춰지는데, Runnable.run() 은 체크 예외를 던질 수 없습니다. return null; 을 붙이면 람다가 Callable 로 취급되어 예외를 던질 수 있게 됩니다. 위 SleepTasks 의 람다가 return null 로 끝나는 이유입니다.
실수 2 → 무슨 일이 생기나 → 고치는 법. 람다는 변수가 아니라 값을 캡처합니다. 컴파일러가 막아 주는 게 오히려 친절한 편인데, 흔한 우회법(static int 필드로 바꾸기)은 컴파일은 되지만 결과가 틀립니다. 다음 절에서 실측합니다.
조용히 사라지는 예외. submit() 은 작업의 예외를 Future 안에 넣어 둡니다. get() 을 부르지 않으면 예외는 어디에도 나오지 않습니다.
main finished, no stack trace above?
Exception in thread "" java.lang.IllegalStateException: boom2
at Swallow.lambda$main$1(Swallow.java:9)
submit() 으로 넣은 작업의 boom 은 아무것도 출력되지 않았고, Thread.ofVirtual().start(...) 로 띄운 쪽만 기본 핸들러까지 도달했습니다. 스레드 이름이 빈 문자열("")인 것도 보세요. 가상 스레드는 기본적으로 이름이 없으니, 로그로 추적하려면 Thread.ofVirtual().name("fetch-", 0) 처럼 이름을 붙이세요.
경쟁 상태를 실측하고 세 가지로 고치기
가상 스레드 1,000 개가 공유 카운터를 각자 1만 번 올립니다. 정답은 10,000,000 입니다.
static int plain;
static volatile int vol;
static final AtomicInteger atomic = new AtomicInteger();
static final LongAdder adder = new LongAdder();
static final ReentrantLock lock = new ReentrantLock();
static int locked;
run("int ++", () -> plain++, () -> plain);
run("volatile ++", () -> vol++, () -> vol);
run("AtomicInteger", atomic::incrementAndGet, atomic::get);
run("LongAdder", adder::increment, adder::sum);
run("ReentrantLock", () -> { lock.lock(); try { locked++; } finally { lock.unlock(); } }, () -> locked);
int ++ total= 7,627,061 16 ms
volatile ++ total= 1,282,858 342 ms
AtomicInteger total= 10,000,000 667 ms
LongAdder total= 10,000,000 25 ms
ReentrantLock total= 10,000,000 341 ms
세 번 실행에서 그냥 int 는 210만~290만 번의 갱신을 잃었습니다. volatile 은 더 나빠서 결과가 128만~260만이었습니다. volatile 은 읽기·쓰기를 다른 스레드에 보이게 할 뿐이고 vol++ 이 '읽기·더하기·쓰기' 세 단계라는 사실은 그대로이며, 매번 메모리까지 쓰러 가는 만큼 충돌이 더 잦아집니다. "volatile 은 원자성을 보장하지 않는다"는 면접 답변을 숫자로 확인한 셈입니다.
세 가지 해결책은 매번 정확했습니다. 시간에는 람다별 JIT 워밍업이 섞여 있으니 순위로만 읽으세요. AtomicInteger 는 458~667ms, ReentrantLock 은 341~371ms, LongAdder 는 21~26ms 였습니다. LongAdder 는 값을 여러 셀에 나눠 세고 sum() 할 때 합치기 때문에 빠릅니다. 자주 올리고 가끔 읽는 카운터는 LongAdder, 필드 두 개를 함께 바꿔야 하면 락을 쓰세요.
JDK 26 의 구조적 동시성과 스코프 값
StructuredTaskScope 는 하위 작업 묶음을 한 단위로 다룹니다. 하나가 실패하면 나머지는 취소되고, 자식이 도는 동안에는 스코프를 빠져나갈 수 없습니다. JDK 26 에서도 아직 프리뷰 API 입니다.
$ javac Structured.java
Structured.java:2: error: StructuredTaskScope is a preview API and is disabled by default.
import java.util.concurrent.StructuredTaskScope;
^
(use --enable-preview to enable preview APIs)
try (var scope = StructuredTaskScope.open()) { // default: all must succeed
var user = scope.fork(() -> fetch("user", 100, failUser));
var order = scope.fork(() -> fetch("order", 2_000, false));
scope.join(); // throws if any subtask failed
System.out.println(" " + user.get() + " " + order.get());
}
$ javac --release 26 --enable-preview -Xlint:preview Structured.java
Structured.java:20: warning: [preview] StructuredTaskScope is a preview API and may be removed in a future release.
$ java --enable-preview Structured
user@req-42 order@req-42
failUser=false -> 2,013 ms
order cancelled after being interrupted
caught FailedException: java.lang.IllegalStateException: user failed
failUser=true -> 108 ms
user 가 100ms 에 실패하자 2 초짜리 order 는 인터럽트로 취소되었고, 요청 전체가 108ms 에 돌아왔습니다. 아무도 쓰지 않을 결과를 2 초 기다리지 않습니다. req-42 는 호출 바깥에서 묶은 ScopedValue 값이고, fork 한 하위 작업에도 이어졌습니다. ScopedValue 자체는 JDK 26 에서 플래그 없이 컴파일됐고, javac --release 24 에서는 프리뷰 API 로 거부, --release 25 에서는 통과했습니다. 즉 JDK 25 에서 정식이 됐습니다. 스코프 쪽은 프리뷰라 릴리스마다 API 가 바뀌어 왔습니다(JDK 21 에서는 new StructuredTaskScope.ShutdownOnFailure()). JDK 26 기준 코드가 27 에서 그대로 컴파일된다는 보장은 없습니다.
판단표
| 상황 | 쓸 것 | 이유 |
|---|---|---|
| HTTP·DB 를 기다리는 작업 수천 개 | newVirtualThreadPerTaskExecutor() |
sleep 10만 개가 1.8 초·210MB |
| 상대 서버가 동시 N 개까지만 받음 | 가상 스레드 + Semaphore(N) |
풀을 유량 제한용으로 쓰지 않는다 |
| CPU 바운드 계산 | 코어 수 정도의 고정 풀이나 병렬 스트림 | 가상 스레드로 빨라지지 않았다 |
| 가끔 읽는 공유 카운터 | LongAdder |
정확하고 21~26ms (락은 341ms~) |
| 필드 여러 개를 함께 갱신 | ReentrantLock 또는 synchronized |
JDK 24 부터 모니터는 피닝하지 않음 |
| 하나라도 실패하면 모두 멈출 병렬 호출 | StructuredTaskScope (26 에서 프리뷰) |
실패 시 2 초 대신 108ms |
| 요청 단위 컨텍스트 | ScopedValue (25 에서 정식) |
fork 한 하위 작업에 전달된다 |
무거운 객체를 ThreadLocal 에 보관 |
가상 스레드에서는 설계를 다시 본다 | 풀 스레드 수가 아니라 작업 수만큼 생긴다 |
솔직한 한계와 버전 메모
- 가상 스레드는 Java 21 에서 정식이 됐습니다.
javac --release 20은newVirtualThreadPerTaskExecutor()를 프리뷰 API 로 거부하고--release 21은 통과시킵니다. 그 밖의 것은 모두 JDK 26 에서만 실행했습니다. - JDK 24 의 피닝 변경은 26 에서 확인했을 뿐입니다. 'JDK 21 이라면 10 초'는 계산값이고 실행하지 않았습니다.
- 스레드 4,096 개 한도는 macOS 설정입니다. 리눅스 서버나 컨테이너에서는 다른 숫자에서 죽습니다.
- RSS 는 JVM 전체 값입니다.
Runtime의 힙 값은 작업이 끝난 뒤의 스냅숏이지 최댓값이 아닙니다. - JMH 는 쓰지 않았습니다. CPU 테스트는
work()를 한 번 워밍업했지만 경쟁 테스트는 하지 않았으니 순위만 의미가 있습니다.
다음 강좌에서는 JVM 안으로 들어가 JIT 워밍업, GC, 메모리 누수를 같은 방식으로 잽니다.
이전 강좌: 4강: 스트림·람다·Optional · 다음 강좌: 6강: JIT·GC·메모리
관련: Python 강좌 5강: 스레드·프로세스·asyncio 와 GIL · VB.NET 강좌 5강: Async/Await
자주 묻는 질문
가상 스레드를 쓰면 CPU 를 쓰는 작업도 빨라지나요?
아니요. CPU 만 쓰는 작업 64 개가 스레드 8 개 고정 풀에서 4,385ms, 작업마다 가상 스레드로 4,695ms 걸렸습니다(3 회 중앙값, 8 코어 M1 Pro). 가상 스레드는 기다리는 시간이 긴 작업에 효과가 있고, 계산만 하는 작업은 코어 수가 상한입니다.
JDK 26 에서도 synchronized 안의 가상 스레드는 피닝되나요?
보통의 블록은 아닙니다. JDK 24(JEP 491)부터 synchronized 안에서 sleep 해도 언마운트됩니다. 캐리어 1 개로, 각자 모니터를 잡은 채 100ms 자는 작업 100 개가 약 115ms 에 끝났습니다. 다만 클래스 초기화 블록 안의 블록은 여전히 피닝되며 JFR 에 jdk.VirtualThreadPinned 로 기록됐습니다. -Djdk.tracePinnedThreads 는 JDK 26 에 더 이상 없습니다.
Java 26 에서 구조적 동시성(StructuredTaskScope)은 정식 기능인가요?
아닙니다. JDK 26 에서도 프리뷰 API 라서 --enable-preview 없이는 javac 가 오류를 냅니다. 반면 ScopedValue 는 플래그 없이 컴파일되고, --release 24 에서는 프리뷰로 거부되며 --release 25 에서는 통과하므로 JDK 25 에서 정식이 됐습니다.