
JVM에서 제공하는 스레드를 플랫폼 스레드라고 말하며, JNI를 통해 시스템 콜을 호출하고 커널 스레드를 생성함으로써 플랫폼 스레드는 커널 스레드와 1:1로 매핑된다.
컨텍스트 스위칭을 OS 수준에 위임하기 때문에 구현이 간단하고, 멀티 코어를 활용할 수 있다는 장점이 있지만, 다음과 같은 문제점이 있다.
이러한 문제를 해결하기 위해서 가상 스레드가 생겼다.

가상 스레드는 JDK 21에서 도입된 경량 스레드로, 기존 플랫폼 스레드와 달리 커널 스레드에 종속되지 않는다.
동작 방식
가상 스레드는 커널 스레드와 1:1로 매핑한 캐리어스레드 위에서 동작하고, 가상 스레드와 캐리어 스레드는 M:N으로 매핑된다.
가상 스레드에서 블로킹 I/O가 발생한다면 해당 가상 스레드는 일시 중단 상태가 되고, 캐리어 스레드는 다른 가상 스레드를 실행한다.
이렇게 캐리어 스레드가 어떤 가상 스레드를 사용할지는 JVM 내부의 스케줄러에서 하며, 스케줄러는 ForkJoinPool을 사용한다.
이러한 동작 방식은 다음과 같은 장점이 있다.
이러한 장점으로 인해 기존 방식을 그대로 유지하면서 높은 처리량을 제공할 수 있다.
💡 ForkJoinPool이란?
특정 작업을 작은 단위로 여러 개 나눠서 각 스레드에서 작업한 뒤, 결과물을 합치는 방식으로 동작하는 스레드 풀이다.
작업 훔치기 기능이 있기 때문에 먼저 작업을 다 끝낸 스레드는 아직 작업이 남은 스레드의 작업 큐의 꼬리에서부터 작업을 가져와서 수행한다.
이 기능을 통해 놀고 있는 스레드 없이 효율적으로 작업을 진행할 수 있다.
자바에서 작업을 진행할 때, 실행 할 메서드를 스택 영역에 쌓아두고 순차적으로 실행한다.
가상 스레드의 컨텍스트 스위칭은 이러한 특성을 이용한다.
작업을 진행하다가 블로킹 I/O를 만나서 중지된 시점부터 앞으로 실행될 메서드들을 잘라내서 힙 영역에 보관한다.
이후 I/O 작업이 완료되면서 작업을 재개할 때, 힙에 저장된 메서드들을 스택 영역에 복구한 뒤 중지된 시점부터 다시 순차적으로 메서드를 실행한다.
즉,"스택 프레임을 자르고 붙이는" 과정을 통해서 가상 스레드 간의 전환이 이루어진다.
이러한 과정은 park, unpark 메서드를 통해 진행된다.
가상 스레드에서 블로킹 I/O가 발생해 작업을 중지할 때 실행되는 메서드로 다음과 같이 진행된다.
가상 스레드의 상태를 PARKING 상태로 전환한다.
unmount() 함수를 실행해 현재 캐리어 스레드와의 연결을 해제한다.
yield 함수를 실행해 현재 캐리어 스레드를 다른 가상 스레드에게 양보한다.
3.1. 현재 실행 중인 스택 프레임을 힙 영역에 저장한다.
3.2. 다른 가상 스레드에게 양보한다.
가상 스레드의 상태를 PARKED 상태로 전환한다.
블로킹 I/O 작업이 완료된 후 중지된 작업을 다시 실행되는 메서드로 다음과 같이 진행한다.
스케줄러에게 runContinuation 메서드 실행을 요청한다.
Continuation.run으로 진입해서 enterSpecial 네이티브 메서드를 실행한다.
enterSpecial 메서드 : 힙에 저장된 작업을 스택에 복원한다.
중지된 시점부터 작업을 재개한다. (mount() 실행)
캐싱은 생성 비용이 부담될 때 사용한다. 스레드 로컬에 캐싱하게 된다면
모든 캐리어 스레드의 스레드 로컬에 캐싱이 필요하고, 이로 인해 캐리어 스레드의 크기가 커지게 된다.
스레드 로컬을 사용하기 보단 final static과 같은 불변 변수를 공유하는 것을 권장한다.
풀을 만들어서 미리 생성해두는 것은 생성 비용에 부담이 있어서 미리 만들어두고, 이를 공유하기 위함이다. 하지만 가상 스레드는 생성 비용에 부담이 있지 않고 가볍기 때문에 스레드 풀을 만들 필요가 없다.
물론, 스레드 풀을 만들어 스레드 개수를 제한함으로써 동시성을 제어할 수 있다.
(ex. 스레드를 10개로 만들어서 특정 서비스의 접근을 동시에 10개만 할 수 있도록 만들 수 있다.)
하지만 위 동작은 스레드 풀의 부수 효과이며, 본질적인 목표가 아니다.
따라서 동시성을 제어하고 싶을 땐 세마포어를 사용하는 것을 권장한다.
가상 스레드에서 CPU Bound 작업을 진행하면 캐리어 스레드를 오랫동안 점유하기 때문에 가상 스레드의 처리량이 제한된다.
이는 처리량을 높이는 목적으로 설계된 가상 스레드의 취지에 부합하지 않기 때문에 CPU Bound 작업을 진행할 땐, 별도의 스레드 풀을 사용해 플랫폼 스레드에서 작업하는 것을 권장한다.
가상 스레드에서 synchronized를 사용하면 synchronized 내부의 모니터가 캐리어 스레드를 점유하기 때문에 가상 스레드가 고정된다. (Pinned 현상)
물론 synchronized의 작업이 짧다면 크게 상관없지만, 긴 작업을 수행하면 캐리어 스레드가 처리할 수 있는 처리량이 줄어들기 때문에 지양해야 한다.
synchronized 대신 모니터를 사용하지 않는 ReentrantLock을 사용할 것을 권장한다.
가상 스레드가 블로킹 I/O를 만나 캐리어 스레드의 연결을 해제하는 과정이 필요한데, 이 때 연결이 해제가 되지 않고 캐리어 스레드와 계속 연결된 상태가 되는 현상을 말한다.
연결이 해제되지 않는 이유는 대표적으로 3가지가 있다.
JVM은 네이티브 코드의 실행을 제어할 수 없기 때문에 힙 영역에 데이터를 백업할 수 없다.
JVM에서는 객체의 모니터는 캐리어 스레드가 점유하기 때문에 Pinned 이슈가 발생한다.
JVM에서는 Critical Section 내에서 실행되는 작업을 보호하기 위해서 Pinned 이슈가 발생한다.
Critical Section 내에서 실행되는 작업 종류
Java 가상 스레드, 깊이 있는 소스 코드 분석과 작동 원리 1편 - 생성과 시작
LY Corporation Java 가상 스레드, 깊이 있는 소스 코드 분석과 작동 원리 2편 - 컨텍스트 스위칭
LY Corporation Java 가상 스레드, 깊이 있는 소스 코드 분석과 작동 원리 3편 - 고정 이슈와 한계