Java/동시성

Java Virtual Thread

창욱씨 2025. 7. 12. 23:51
반응형

Java Virtual Threads

Java Virtual Thread는 Project Loom의 일환으로 Java 21에서 정식 도입된 경량 스레드입니다 (JEP 444).

기존 Platform Thread의 문제

기존 Java 스레드는 OS 커널 스레드와 1:1로 매핑됩니다.

  • 스레드 하나당 ~1MB 스택 메모리 소비
  • OS 스레드 수에 제한 → 수천 개 이상 생성 시 성능 저하
  • I/O 블로킹 시 OS 스레드가 통째로 블로킹됨
  • thread-per-request 모델의 확장성 한계

Virtual Thread란?

Virtual Thread는 JVM이 관리하는 경량 스레드로, 소수의 OS 스레드(carrier thread) 위에서 M:N 스케줄링됩니다.

┌─────────────────────────────────┐
│         JVM Scheduler           │
│  ┌───┐ ┌───┐ ┌───┐ ┌───┐ ...    │  ← 수백만 개의 Virtual Threads
│  │VT1│ │VT2│ │VT3│ │VT4│        │
│  └─┬─┘ └─┬─┘ └─┬─┘ └─┬─┘        │
│    └──┬───┘     └──┬───┘        │
│    ┌──┴──┐      ┌──┴──┐         │
│    │ CT1 │      │ CT2 │         │  ← 소수의 Carrier Threads (= OS Threads)
│    └─────┘      └─────┘         │
└─────────────────────────────────┘

핵심 특징

특성 Carrier Thread Virtual Thread
매핑 1:1 (OS 스레드) M:N (JVM 관리)
메모리 ~1MB 고정 스택 ~수 KB (동적 증가)
생성 비용 높음 매우 낮음
최대 개수 수천 개 수백만 개
블로킹 I/O OS 스레드 블로킹 carrier에서 unmount → 다른 VT 실행
풀링 필요 ✅ (ThreadPool) ❌ 불필요

사용법

1. 기본 생성

// 단일 Virtual Thread 실행
Thread.startVirtualThread(() -> {
    System.out.println("Hello from virtual thread!");
});

// Builder 패턴
Thread vt = Thread.ofVirtual()
    .name("my-vthread")
    .start(() -> doWork());

2. ExecutorService 활용

// Virtual Thread 전용 Executor — 태스크마다 새 VT 생성
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 100만 개의 동시 태스크도 가능
    IntStream.range(0, 1_000_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}

3. Spring Boot 적용 (3.2+)

# application.properties
spring.threads.virtual.enabled=true

이 한 줄로 Tomcat/Jetty의 요청 처리가 Virtual Thread 기반으로 전환됩니다.

동작 원리: Continuation 기반

Virtual Thread가 I/O 블로킹 호출을 만나면:

1. VT의 스택(continuation)을 힙에 저장 (unmount)
2. Carrier Thread가 해방되어 다른 VT 실행
3. I/O 완료 시 VT를 다시 Carrier Thread에 mount
4. 실행 재개

기존 코드를 수정 없이 블로킹 스타일로 작성해도, 내부적으로는 논블로킹처럼 동작합니다.

주의사항

이슈 설명
Pinning synchronized 블록 안에서 블로킹 시 carrier thread가 해방되지 않음 → ReentrantLock으로 대체 권장
ThreadLocal 남용 VT는 수백만 개 생성 가능하므로, ThreadLocal에 무거운 객체 캐싱 시 메모리 폭증 → ScopedValue (Preview) 사용 권장
CPU-bound 작업 VT는 I/O-bound 작업에 최적화. CPU-bound는 Platform Thread + ForkJoinPool이 더 적합
풀링 금지 VT는 생성 비용이 거의 없으므로, 풀링하면 오히려 성능 저하

언제 사용해야 하는가?

✅ 적합한 경우:

  • 대량의 동시 I/O 작업 (HTTP 호출, DB 쿼리, 파일 I/O)
  • Thread-per-request 서버 모델
  • 기존 블로킹 코드를 리팩터링 없이 확장하고 싶을 때

❌ 부적합한 경우:

  • CPU-intensive 연산 (행렬 곱, 암호화 등)
  • 이미 비동기/리액티브로 잘 동작하는 코드
  • synchronized를 대량 사용하는 레거시 코드 (pinning 이슈)
반응형