개념
- JVM에서 관리되는 경량(Lightweight) 스레드
- 기존의 플랫폼 스레드는 운영체제(OS)의 스레드를 직접 사용하기 때문에 생성과 컨텍스트 스위칭 비용이 큽니다.
- 가상 스레드는 JVM이 자체적으로 관리하고 스케줄링하여 OS 스레드를 더 효율적으로 활용합니다.
1
2
3
| [가상 스레드 1] \
[가상 스레드 2] - (스케줄러) -> [플랫폼 스레드 1] --> [CPU]
[가상 스레드 3] /
|
동작 방식
- 가상 스레드 스케줄링 (ForkJoinPool 기반)
- 기본적으로 ForkJoinPool.commonPool()을 활용해 관리됩니다.
- JVM이 가상 스레드를 자동으로 스케줄링하며, 특정 가상 스레드가 블로킹되면 JVM이 이를 즉시 감지하여 플랫폼 스레드에서 분리합니다.
- 블로킹 동작 처리
- 가상 스레드에서 I/O 작업 등 블로킹 작업이 발생하면 JVM은 해당 가상 스레드를 플랫폼 스레드에서 떼어냅니다(detach).
- 플랫폼 스레드는 즉시 다른 가상 스레드를 실행합니다.
- 블로킹 작업이 완료되면 다시 가상 스레드를 플랫폼 스레드에 연결하여 작업을 이어갑니다.
- 즉, 가상 스레드가 블로킹 상태일 때는 OS 스레드를 점유하지 않기 때문에, 블로킹 작업을 하는 동시성 애플리케이션(웹 서버, 데이터베이스 접근 등) 에서 특히 강력합니다.
주의 사항
- CPU 바운드 작업에서는 기존 스레드와 성능이 유사합니다. 오히려 많은 가상 스레드를 사용하면 컨텍스트 전환 오버헤드가 증가할 수 있습니다.
- 기존 스레드 기반 동기화 메커니즘 (synchronized)은 성능 저하를 초래할 수 있으므로, Java 21에서는 가상 스레드 사용 시 구조화된 동시성(structured concurrency) 패턴 사용을 권장합니다.
스케줄러 ?
- 가상 스레드는 스스로 실행될 수 없고, 실제 CPU에서 실행되려면 반드시 운영체제(OS)의 스레드(플랫폼 스레드) 에 할당되어야 합니다.
- 이렇게 가상 스레드를 플랫폼 스레드에 적절히 배치하고 실행 순서를 관리하는 역할을 수행하는 컴포넌트가 스케줄러입니다.
- 가상 스레드의 스케줄링은 주로 ForkJoinPool 기반으로 동작합니다.
- ForkJoinPool은 내부적으로 작업을 관리하는 큐(queue)를 가지고 있으며, 가상 스레드가 제출되면 작업 큐에 들어갑니다.
- 이 큐에서 가상 스레드가 플랫폼 스레드로 전달되어 CPU에서 실행됩니다.
- 가상 스레드가 블로킹 작업(I/O 등)을 하면, 해당 플랫폼 스레드는 바로 다른 가상 스레드를 실행하여 CPU의 활용률을 극대화합니다.
1
2
3
4
5
6
7
8
9
10
11
| [가상 스레드 큐 (Queue)]
│
▼
[스케줄러(ForkJoinPool)]
│
├──> 플랫폼 스레드 1 (OS Thread)
├──> 플랫폼 스레드 2 (OS Thread)
└──> 플랫폼 스레드 3 (OS Thread)
│
▼
[CPU 실행]
|
기존 스케줄러와의 차이 ?
- 기존의 자바(플랫폼 스레드)에서도 스케줄러가 있었습니다. 정확히 말하면, 플랫폼 스레드의 스케줄링은 자바(JVM)가 아니라 운영체제(OS)에서 관리했습니다.
- 기존 플랫폼 스레드는 JVM에서 만들지만, 실제 동작 관리는 운영체제(OS)의 커널 스케줄러가 맡고 있었습니다.
1
2
3
4
5
6
7
8
9
10
| [자바 애플리케이션]
│
▼
[JVM 스레드(Thread) 생성]
│
▼
[OS 스레드 할당 (native thread)]
│
▼
[OS의 스케줄러가 CPU 자원 할당 및 실행 순서 관리]
|
OS 스케줄러가 플랫폼 스레드를 관리할 때 문제점
- 스레드를 많이 생성할수록 OS의 리소스를 많이 소모하여 한계가 명확합니다.
- 블로킹(I/O 등) 작업 시에도 OS 스레드가 블로킹 상태에 머물기 때문에 자원을 낭비합니다.
- 컨텍스트 스위칭 비용이 높아 대규모 동시 처리에 비효율적입니다.
가상 스레드가 JVM 내 스케줄러를 사용함으로써 해결된 문제
- OS 리소스 소모가 줄어들어 수백만 개의 스레드를 손쉽게 생성하고 관리합니다.
- I/O 등 블로킹 작업 중에도 OS 스레드를 점유하지 않아 효율적인 자원 활용이 가능합니다.
- JVM에서 직접 관리하기 때문에 컨텍스트 전환 비용을 크게 줄였습니다.
컨텍스트 스위칭 비용 차이의 원인 ?
컨텍스트 스위칭
하나의 작업(스레드)을 중단하고 다른 작업을 실행할 때 상태를 저장했다가 복구하는 과정
1
2
3
4
| ① 현재 실행 중인 작업 중단
② 현재 상태 저장 (레지스터, 프로그램 카운터, 스택 등)
③ 새로운 작업 상태 복구 (레지스터, 프로그램 카운터, 스택 등)
④ 새로운 작업 실행
|
OS(운영체제)의 컨텍스트 스위칭 비용이 큰 이유
- OS는 하드웨어 수준에서 컨텍스트를 관리합니다.
- CPU 레지스터, 스택 포인터, 메모리 맵 등의 모든 하드웨어 상태를 저장하고 복구해야 합니다.
- OS가 관리하는 메모리 공간, CPU 자원까지 관리하므로 매우 상세하고 복잡한 작업입니다.
- 시스템 호출(System Call)과 인터럽트 처리
- 스레드 간의 스위칭뿐 아니라 시스템 호출이나 인터럽트 발생 시에도 컨텍스트를 스위칭해야 하므로 추가 비용이 발생합니다.
- 캐시(Cache) 무효화
- OS가 스레드를 바꿀 때마다 캐시된 데이터의 상당 부분이 무효화될 수 있어 CPU 캐시 효율성이 떨어집니다.
JVM(가상 스레드)의 컨텍스트 스위칭 비용이 작은 이유
- 순수 JVM 내부에서만 동작
- JVM은 하드웨어 상태를 직접 건드리지 않고, 스택 프레임(Stack Frame), 지역변수 등 JVM 내의 가벼운 논리적 상태만 저장하고 복구합니다.
- JVM 내에서만 이루어지므로 OS 커널과 하드웨어의 개입이 거의 없습니다.
- 최소한의 상태만 저장
- 가상 스레드는 OS가 관리하는 무거운 상태가 아니라, 간단한 JVM 내부 데이터 구조(스택 프레임 등) 만 보관합니다.
- 이 과정이 매우 가볍고 빠릅니다.
- 캐시 친화적(Cache-friendly)
- JVM 내부에서 짧은 시간 내에 빠르게 스위칭이 이루어지기 때문에 캐시의 데이터가 효율적으로 유지되어 성능이 더 우수합니다.
가상 스레드를 수백만개 생성해도 괜찮은 이유 ?
기존 플랫폼 스레드의 메모리 사용량 문제
- 기존 자바 플랫폼 스레드는 OS에서 관리되기 때문에, 각 스레드는 기본적으로 고정된 스택 크기를 가지며 메모리를 크게 사용합니다.
- 예시: 일반적인 자바 플랫폼 스레드는 기본적으로 1MB의 스택 메모리를 갖습니다.
- 만약 플랫폼 스레드를 1,000개만 생성해도: 1MB x 1,000개 = 약 1GB 메모리 사용
- 이처럼 플랫폼 스레드를 수천~수만 개 만드는 순간 메모리 소모가 급격히 늘어나 JVM이나 OS가 버티기 어려워집니다.
가상 스레드의 메모리 관리 방식
- 가상 스레드는 기본적으로 매우 작은 크기의 스택(일반적으로 수 KB)을 사용합니다.
- 플랫폼 스레드가 일반적으로 512KB~1MB의 스택을 사용하는 반면, 가상 스레드는 훨씬 적은 메모리를 사용합니다.
- 가상 스레드는 Stack Chunking 이라는 기술을 사용해 메모리 사용량을 크게 줄였습니다.
- 가상 스레드가 처음 생성될 때 매우 작은 스택만 할당합니다.
- 스택이 추가로 필요한 경우 JVM에서 자동으로 동적으로 늘어납니다.
- 스택의 크기가 고정되어 있지 않고 필요한 만큼만 증가하므로, 메모리 효율이 뛰어납니다.
- 예시
- 초기 스택: 작게 시작 (예: 2KB)
- 함수 호출 깊이가 깊어짐: 필요 시 조금씩 스택 크기 증가 (Chunk 단위로 할당)
- 작업 종료 후: 할당된 추가 스택을 다시 JVM이 관리하여 재사용