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 를 늘리기 전에 스레드 수부터 의심하세요.

최대 RSS 막대그래프. 플랫폼 스레드: 1,000 작업 124MB, 4,000 작업 261MB. 가상 스레드: 1,000 은 57MB, 4,000 은 71MB, 10,000 은 90MB, 100,000 은 210MB. 4,000 작업에서 가상 스레드는 약 4 분의 1 의 메모리를 썼고, 가상 스레드 100,000 개도 플랫폼 스레드 4,000 개보다 적었다.
1 초 sleep 하는 작업 N 개를 돌렸을 때 JVM 전체의 최대 상주 메모리(RSS, 3 회 중앙값). 플랫폼 스레드 10,000 개는 macOS 의 프로세스당 4,096 스레드 한도에 걸려 실행되지 않았다.

가상 스레드는 어떻게 코어를 막지 않고 블록되나

가상 스레드는 OS 가 아니라 JDK 가 스케줄하는 Java 객체입니다. JDK 는 이것을 기본값으로 코어 수만큼의 '캐리어 스레드'로 된 ForkJoinPool 위에서 돌립니다. 가상 스레드가 JDK 가 대응해 둔 블록 작업(sleep, 소켓 I/O, ReentrantLock, BlockingQueue, JDK 24 부터는 모니터도)에 들어가면 언마운트되어 스택 프레임이 힙으로 옮겨지고, 캐리어는 다른 가상 스레드를 실행합니다. 위 표에서 10만 작업일 때 힙 사용량이 약 122MB 까지 늘었는데도 RSS 가 작았던 이유가 이것입니다.

흐름도. 많은 가상 스레드가 ForkJoinPool 의 적은 수의 캐리어 스레드(기본값은 코어 수) 위에서 돈다. 가상 스레드가 sleep, I/O 대기, 락 대기(JDK 24 부터는 synchronized 안 포함)에 들어가면 언마운트되어 스택이 힙으로 옮겨지고 캐리어는 다른 가상 스레드를 실행한다. static 초기화 블록이나 네이티브 프레임 안에서 블록되면 피닝된 채로 남아 대기하는 동안 캐리어도 멈춘다. JFR 은 이를 jdk.VirtualThreadPinned 이벤트로 기록한다.
가상 스레드가 블록될 때의 흐름. 보통의 블록은 언마운트되어 캐리어가 비지만, 클래스 초기화 블록(또는 네이티브 프레임) 안의 블록은 JDK 26 에서도 캐리어를 붙잡는다(피닝).

가상 스레드가 소용없는 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 에서 그대로 컴파일된다는 보장은 없습니다.

판단표

판단 그림. 첫 질문은 '작업이 대부분 I/O·sleep·락을 기다리는가'. 그렇다면 작업마다 가상 스레드 하나를 쓰고, 상대 서비스에 동시 접속 한도가 있으면 Semaphore 를 더한다. 아니라면 CPU 바운드이므로 코어 수 정도의 고정 풀이나 병렬 스트림을 쓴다. 실측에서 가상 스레드로는 빨라지지 않았다.
작업이 무엇에 시간을 쓰는지로 실행 방식을 고른다. 가상 스레드가 효과를 내는 것은 작업이 대부분 기다리는 경우뿐이다.
상황 쓸 것 이유
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 에서 정식이 됐습니다.

  • #Java
  • #가상 스레드
  • #동시성
  • #구조적 동시성
  • #JDK 26
  • #고급

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑