스레드는 프로세스 수행의 병렬 처리를 가능케 하는 경량 작업 단위입니다. 하나의 프로세스의 커다란 작업을 여러 스레드에 나눠 처리하여 성능을 높일 수 있죠.
하나의 프로세스 내의 여러 스레드는 각자 독립적인 스택 공간을 가지지만, 나머지 힙, 코드, 데이터 영역은 포인터로 프로세스의 공간을 가르킵니다. 그래서 완전히 독립적인 공간을 갖는 프로세스의 생성과 컨텍스트 스위칭에 비해서는 낮은 비용 덕에 작업 처리의 효율성을 높였습니다.
스레드는 크게 커널에서 관리하는 커널 레벨 스레드(KLT, Kernel-Level Thread)와 사용자 프로그램에서 관리하는 유저 레벨 스레드(ULT, User-Level Thread)로 구분할 수 있습니다. 커널에서 관리되는 작업이므로, 커널 레벨 스레드는 상대적으로 더 큰 오버헤드를 수반합니다.
커널 레벨 스레드를 생성, 삭제 및 관리하기 위해서는 시스템 콜을 사용해야 하며, 이는 커널 공간으로의 모드 전환과 유저 공간으로의 복귀를 동반합니다. 이러한 모드 스위칭은 컨텍스트 스위칭의 오버헤드를 발생시키며, 생성과 삭제 과정에서도 시스템 콜이 필요해 번거롭습니다.
커널 수준의 컨텍스트 스위칭은 스레드의 컨텍스트뿐만 아니라 레지스터와 프로세서의 전체 컨텍스트도 전환하게 됩니다. 또한, 커널에서 운영되는 스레드이기 때문에 하드웨어 자원에 직접적으로 제한을 받으며, 운영체제에 대한 의존성이 큽니다.
Java의 전통적인 Java Thread는 커널 레벨 스레드입니다. 즉, JVM 내에서 스레드를 생성하면 Java Native Interface (JNI)를 통해 커널 스레드와 매핑됩니다.
JNI는 OS가 바로 읽을 수 있는 형태의 네이티브 코드 (C, C++ 등) 을 JVM이 호출할 수 있게끔 해주는 인터페이스입니다. JVM은 JNI를 사용하여 별도의 인터프리터 없이도 C/C++로 작성된 코드를 실행할 수 있습니다.
스레드 섹션에서 다룬 커널 스레드의 단점인 컨텍스트 스위칭 오버헤드, 운영체제 자원으로 제한되는 커널 스레드 생성 등이 바로 Java 스레드의 단점으로도 이어지죠.
Java 스레드가 개발되는 당시에는 운영체제의 효율성을 최대 끌어내고자 한 구현이였으나, 하드웨어와 운영체제의 성능이 많이 발전한 현재로서는 비효율적인 방식이죠.
Java는 java.util.concurrent.ExecutorService
로 JVM 내부에서 스레드를 관리하고 실행합니다. ThredPoolExecutor
라는 ExecutorService로 실제 스레드를 실행시키고는 하는데, 스레드를 추가할 여유가 있는지 확인한 후 실행하는 Thread.start
메서드에서 synchronized
하게 JNI 메서드 start0
을 호출하는 과정을 확인할 수 있습니다.
java.lang.Thread.java
private native void start0();
public void start() {
synchronized (this) {
// zero status corresponds to state "NEW".
if (holder.threadStatus != 0)
throw new IllegalThreadStateException();
start0();
}
}
JDK 21을 기준으로 start0
JNI 메서드는 다음과 같이 구현되어 있습니다.
static JNINativeMethod methods[] = {
{"start0", "()V", (void *)&JVM_StartThread},
{"setPriority0", "(I)V", (void *)&JVM_SetThreadPriority},
start0
에 해당하는 JVM_StartThread
는 Thread
를 상속하는 JavaThread
클래스 타입 객체를 생성합니다.
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
...
native_thread = new JavaThread(&thread_entry, sz);
...
JVM_END
...
class JavaThread: public Thread {
friend class VMStructs;
friend class JVMCIVMStructs;
friend class WhiteBox;
}
출처: 네이버 D2
그래서 스케쥴링은 Java에서 실행되고, 실제 생성/실행 등은 JNI를 통한 시스템 콜로 커널에서 진행되어 JVM의 Heap 공간에 존재하는 여러 유저 레벨 스레드 중 몇몇 스레드가 JVM의 스케쥴링에 의해 커널 스레드와 1:1 매핑됩니다.
커널 레벨 스레드와 1:1 매핑하여 사용한 Java의 전통적인 스레드도 프로세스보다는 효율적이였지만, 요청량이 급격하게 증가하는 서버 환경에서 thread per request를 구현하는 Java MVC 환경에는 충분하지 않았습니다.
각 스레드가 1MB만 차지한다고 가정해도 4GB 메모리 환경은 약 4,000 개의 스레드만을 가질 수 있어서 스레드의 수가 메모리에 의해 크게 제한됩니다. 더 많은 요청을 더 효율적으로 구현해야 하는 문제를 해결하기 위해 Java의 Virtual Thread(VT, 가상 스레드)가 탄생했습니다.
출처: 우아한기술블로그
기존의 스레드 모델은 유저 레벨 스레드가 스케쥴링되어 커널 레벨 스레드에 매핑되었다면, 이제는 가상 스레드가 스케쥴링되어 플랫폼 스레드에 매핑됩니다.
그래서 가상스레드, 플랫폼 스레드, 그리고 ForkJoinPool
의 구조를 확인해봅시다.
ForkJoinPool
은 분할 정복과 work stealing 방식으로 작업 처리 효율성을 높입니다. 분할 정복은 큰 규모의 작업이 작아질 때까지 재귀적으로 분할하여 동시 처리함을 의미하며, work stealing은 유휴 덱(작업큐)이 바쁜 덱의 끝에서 작업을 가져가서 처리함을 의미합니다.ForkJoinPool commonPool = ForkJoinPool.commonPool(); // ForkJoinPool에 public static 키워드 붙인 것과 같은 의미 ForkJoinPool forkJoinPool = PoolUtil.forkJoinPool;
런타임의 Virtual Thread를 확인하면 다음과 같은 요소를 확인할 수 있습니다.
carrierThread
: 자신의 작업 큐에서 작업을 가져와 실제 수행까지 처리하는 플랫폼 스레드를 의미합니다. 가상 스레드는 필요에 따라 서로 다른 carrierThread
에 마운트되거나 언마운트됩니다. scheduler
: ForkJoinPool
로, 플랫폼 스레드에 대한 풀 역할을 수행하고, 가상 스레드를 스케쥴링합니다.runContinuation
: 가상 스레드의 실제 작업 내용을 가집니다. 가상 스레드의 Continuation
객체는 가상 스레드의 현재 상태를 저장하여 콜 스택, 지역 변수 등을 포함합니다. 이 모든 요소들을 활용해 Java의 Virtual Thread가 동작합니다.
출처: 우아한기술블로그
runContinuation
을 플랫폼 스레드의 작업 큐에 추가(push)합니다.ForkJoinPool
이 각 작업 큐의 작업(runContinuation
)을 work stealing 방식으로 관리합니다. 이 과정 중 작업 큐에 작업이 추가되는 과정을 unpark
, 작업 큐에서 제거되는 과정을 park
라고도 합니다.
unpark
로 가상 스레드를 플랫폼 스레드에 마운트하여 실행합니다.
다음은 JDK21에서 구현된 가상 스레드에 대한 unpark()
메서드입니다.
@Override
@ChangesCurrentThread
void unpark() {
Thread currentThread = Thread.currentThread();
if (!getAndSetParkPermit(true) && currentThread != this) {
int s = state();
if (s == PARKED && compareAndSetState(PARKED, RUNNABLE)) {
if (currentThread instanceof VirtualThread vthread) {
vthread.switchToCarrierThread();
try {
submitRunContinuation();
} finally {
switchToVirtualThread(vthread);
}
} else {
submitRunContinuation();
}
} else if (s == PINNED) {
// unpark carrier thread when pinned.
synchronized (carrierThreadAccessLock()) {
Thread carrier = carrierThread;
if (carrier != null && state() == PINNED) {
U.unpark(carrier);
}
}
}
}
}
getAndSetParkPermit(boolean newValue)
로 park permit을 설정합니다.
park permit은 스레드가 작업 수행을 계속할 수 있는 권한을 부여하는 boolean 플래그입니다. true
면 스레드를 수행할 수 있고, false
면 다시 permit을 얻을 때까지 스레드는 중지합니다.
private boolean getAndSetParkPermit(boolean newValue) {
if (parkPermit != newValue) {
return U.getAndSetBoolean(this, PARK_PERMIT, newValue);
} else {
return newValue;
}
}
해당 메서드는 새롭게 받은 플래그가 기존 플래그와 다른 경우 스레드의 플래그를 업데이트합니다.
이 때 사용되는
Unsafe
클래스의getAndSetBoolean
메서드가 원자연산인getAndSetByte
을 활용하기 때문에 park permit을 사용하는 메서드 또한 동기화가 보장됩니다.@ForceInline public final boolean getAndSetBoolean(Object o, long offset, boolean newValue) { return byte2bool(getAndSetByte(o, offset, bool2byte(newValue))); }
이미 running 중인 스레드는 true
를 리턴하고 메서드 수행을 끝내지만, (1) 수행되고 있지 않은, (2) 현재 수행 중인 스레드와 다른 스레드면 컨텍스트 스위칭 작업을 수행합니다.
int s = state()
으로 스레드의 현재 상태를 확인합니다.
스레드가 PARKED
상태면 현재 정지된 스레드로, 재개되길 대기하고 있음을 의미합니다.
이외에도
RUNNABLE
,NEW
,WAITING
,TERMINATED
와 같은 상태를 가질 수 있습니다.
이후 compareAndSetState
메서드로 현재 스레드의 상태를 PARKED
에서 RUNNABLE
로 변경합니다. 이를 원자연산으로 수행하여 동시에 스레드들의 상태가 변경되어 경합상태가 발생할 가능성을 방지합니다.
성공적으로 스레드의 상태를 변경했다면 다음 단계로 넘어갑니다.
현재 스레드가 가상 스레드인지 확인하고 carrierThread
로 스위치합니다.
if (currentThread instanceof VirtualThread vthread) {
vthread.switchToCarrierThread();
}
가상 스레드는 CPU에 의해 직접적으로 수행되지는 않기 때문에
carrierThread
를 통해 작업을 해야 합니다. 가상 스레드와carrierThread
를 분리하는 구조가 작업의 실제 수행과 작업의 논리적인 상태와 흐름 또한 분리하고, 여러 가상 스레드를 필요에 따라 적은 수의carrierThread
에 마운트하고 언마운트해서 제한적인 운영체제 자원을 효율적으로 사용합니다.
submitRunContinuation
메서드를 수행하여 (주로) ForkJoinPool
타입의 Executor
인스턴스에 스레드의 runContinuation
작업을 제출합니다. 이 작업은 큐에 추가되어 수행될 것입니다.
이 작업이 완료되면 다시 가상 스레드로 스위치합니다.
물론 현재 스레드가 가상 스레드가 아니라면 이런 과정을 거칠 필요 없이 바로 스케쥴러에 작업을 제출할 수 있죠.
else if (s == PINNED) {
// unpark carrier thread when pinned.
synchronized (carrierThreadAccessLock()) {
Thread carrier = carrierThread;
if (carrier != null && state() == PINNED) {
U.unpark(carrier);
}
}
}
가상 스레드가 synchronized
블록 또는 메서드에 해당하는 코드를 수행하거나, JNI를 통해 네이티브 메서드를 사용하면 플랫폼 스레드(carrierThread
)에 PINNED
된 상태입니다.
PINNED
된 가상 스레드의 수행은 특정 플랫폼 스레드에 묶여있기 때문에 이 플랫폼 스레드부터 unpark
해줘야 가상 스레드도 수행할 수 있습니다.
thread-safe
하게 carrierThread
를 사용하기 위해 synchronized
키워드로 carrierThreadAccessLock()
메서드를 호출합니다.
carrierThread
에 NULL 체크를 수행하고, 가상 스레드의 현재 상태가 PINNED
임을 다시 확인한 다음 플랫폼 스레드에 대해 unpark
을 수행합니다.
unpark
을 통해 스레드 수행을 재개했다면, park
을 통해 스레드를 중지합니다.
@Override
void park() {
assert Thread.currentThread() == this;
// complete immediately if parking permit available or interrupted
if (getAndSetParkPermit(false) || interrupted)
return;
// park the thread
boolean yielded = false;
setState(PARKING);
try {
yielded = yieldContinuation(); // may throw
} finally {
assert (Thread.currentThread() == this) && (yielded == (state() == RUNNING));
if (!yielded) {
assert state() == PARKING;
setState(RUNNING);
}
}
// park on the carrier thread when pinned
if (!yielded) {
parkOnCarrierThread(false, 0);
}
}
park
메서드를 호출한 스레드가 현재 가상 스레드 인스턴스와 일치하는지 확인합니다.이 확인 절차를 통해 메서드의 잘못된 사용을 방지합니다.
원자연산 getAndSetParkPermit(false)
로 park permit
플래그를 확인합니다. 리턴 값이 true
면 park permit
플래그가 true
라는 뜻인데, 해당 스레드가 계속 수행될 권한을 가지고 있기 때문에 중지시키면 안된다고 판단하고 메서드를 퇴장합니다.
또한, 인터럽트된 스레드는 즉각적인 처리를 필요로 하기 때문에 park
되지 않습니다.
boolean yield
변수를 false
로 초기화합니다. park
과정이 성공적으로 수행되어 작업이 안전하게 중지되었으면 이 값이 true
로 설정될 것입니다.
PARKING
상태는 현재 스레드가 중지되는 과정에 있음을 의미합니다.
yieldContinuation()
메서드를 수행하여 경량 작업 정지를 시도합니다. 이는 곧 가상 스레드의 컨텍스트 스위칭을 의미하는데, 해당 가상 스레드의 Continuation
객체를 힙에 저장하여 다시 스케쥴링될 때까지 대기 상태로 만드는 과정입니다.
중지에 성공하면 가상 스레드는 플랫폼 스레드에서 언마운트되어 플랫폼 스레드는 다른 작업을 수행할 수 있습니다.
아래는 yieldContinuation
의 구현 코드입니다. unmount
메서드를 호출하여 가상 스레드를 플랫폼 스레드에서 언마운트한 후, 가상 스레드의 Continuation
객체를 yield
합니다. 이 과정은 일반적인 스레드의 컨텍스트 스위칭과 유사하게, 가상 스레드의 현 상태를 힙에 저장하고, 이전 상태를 복구합니다.
@Hidden
@ChangesCurrentThread
private boolean yieldContinuation() {
// unmount
notifyJvmtiUnmount(/*hide*/true);
unmount();
try {
return Continuation.yield(VTHREAD_SCOPE);
} finally {
// re-mount
mount();
notifyJvmtiMount(/*hide*/false);
}
}
unmount
메서드 내에서는 synchronized
키워드로 안전하게 가상스레드를 해제합니다.
@ChangesCurrentThread
@ReservedStackAccess
private void unmount() {
// set Thread.currentThread() to return the platform thread
Thread carrier = this.carrierThread;
carrier.setCurrentThread(carrier);
// break connection to carrier thread, synchronized with interrupt
synchronized (interruptLock) {
setCarrierThread(null);
}
carrier.clearInterrupt();
}
이 정지 과정의 성공 여부는 yielded
변수에 저장하고, 이 값이 가상 스레드의 상태를 정확하게 반영하는지 확인합니다.
정지에 실패했다면 (!yielded
) 스레드의 상태를 PARKING
에서 RUNNING
으로 되돌리고, 이는 스레드가 계속해서 수행됨을 의미합니다.
// park on the carrier thread when pinned
if (!yielded) {
parkOnCarrierThread(false, 0);
}
정지가 정상적으로 이루어지지 않았다면 가상 스레드가 플랫폼 스레드에 PINNED
되었을 가능성을 의미합니다.
그래서 가상스레드를 park
하려고 플랫폼 스레드(carrierThread
)를 park
합니다.
park(FileDescriptor fd, int event, long nanos)
는 해당 파일 디스크립터가 읽기 쓰기 등 특정 이벤트를 위해 준비될 때까지 (또는 특정 대기 시간 동안) 스레드가 스케쥴되지 않도록 합니다.
private void park(FileDescriptor fd, int event, long nanos) throws IOException {
Thread t = Thread.currentThread();
if (t.isVirtual()) {
Poller.poll(fdVal(fd), event, nanos, this::isOpen);
if (t.isInterrupted()) {
throw new InterruptedIOException();
}
} else {
long millis;
if (nanos == 0) {
millis = -1;
} else {
millis = NANOSECONDS.toMillis(nanos);
if (nanos > MILLISECONDS.toNanos(millis)) {
// Round up any excess nanos to the nearest millisecond to
// avoid parking for less than requested.
millis++;
}
}
Net.poll(fd, event, millis);
}
}
눈 여겨볼 점은, 스레드가 가상 스레드면 poll(int fd, int event, long nanos)
를 호출하고, 가상 스레드가 아니면 poll(FileDescriptor fd, int event, long nanos)
를 호출합니다.
첫번째는 일반 메서드로 처리하는 반면에 두번째는 네이티브 메서드로 처리합니다. 네이티브 메서드보다는 JVM 내에서 처리하는 과정이 더 효율적이고, 가상 스레드의 장점이기도 하죠.
JDK 17에서는 park
, unpark
개념을 LockSupport.class
를 통해 네이티브 메서드로 구현했습니다. JDK 21에서는 가상 스레드의 경우에는 가상 스레드 기반 컨텍스트 스위칭을 진행하는 로직을 추가하여 해당 경우에는 더 가벼운 가상 스레드 기반 컨텍스트 스위칭을 구현합니다.
슬립, I/O에 의한 park
/unpark
등도 JDK 17의 네이티브 기반 로직에 가상 스레드를 처리하는 로직을 추가했습니다.
(출처: 네이버 D2)
server: tomcat: threads: max: 1
위처럼 application.yml로 톰캣의 최대 스레드 수를 제한할 수 있습니다.
Spring MVC Tomcat 환경에서 각각 커널 스레드의 수를 1로 제한한 일반 스레드와 가상 스레드에 100개의 호출을 수행해 보면, 일반 스레드는 거의 동시성을 보장할 수 없습니다. 요청와 스레드 간 1:1, 스레드와 커널 스레드 간 1:1 관계이기 때문에 최대 1000ms * 100의 시간이 걸립니다.
하지만 가상 스레드를 사용한다면 거의 동시에 발생하는 100개의 호출을 논블록킹 방식으로 처리하여 커널 스레드 하나만으로도 최대 처리 시간이 약 1000ms입니다.
I/O와 슬립 등으로 인한 park
/unpark
메서드가 가상 스레드에 관해서는 가상 스레드 수준 스위칭을 제공하는 점을 확인하면 놀랍지 않은 성능 차이죠.
결국 가상 스레드가 수행되기 위해서는 플랫폼 스레드에 마운트되어야 하기 때문에 CPU-Bound 작업에서는 가상 스레드 스위칭이 아닌 플랫폼 스레드 스위칭이 발생합니다.
이런 상황에서 가상 스레드를 사용하면 플랫폼 스레드 사용 비용과 함께 가상 스레드이 생성 및 스케쥴링 비용까지 추가되어 성능 낭비가 발생합니다.
오라클에서 직접 가상 스레드 사용 시 유의사항을 발표했습니다.
Java 스레드는 매번 요청이 발생할 때마다 스레드를 생성하고 삭제하는 비용을 절감하기 위해 pool, 즉 미리 스레드를 만들어두고 필요에 따라 작업에 할당하여 처리합니다.
스레드는 운영체제에 의해 개수가 제한적인 자원이기 때문에 최대한 효율적으로 활용하는 것이 관건이지만, 가상 스레드는 단순히 애플리케이션 수준의 도메인 객체와도 같습니다. 그렇기 때문에 동시에 처리해야 하는 각 작업당 가상 스레드를 한 개 씩 할당해야 가상 스레드의 이점을 잘 활용할 수 있습니다. 즉, 작은 단위의 작업에도 가상 스레드를 별도로 만들어서 사용해야 합니다.
최소 10,000개 이상의 가상 스레드를 항상 사용해야 애플리케이션이 가상 스레드의 장점을 잘 활용한다고 볼 수 있습니다.
synchronized
블록 내의 가상 스레드의 블록킹(blocking) 연산은 곧 플랫폼 스레드, 이어서 커널 스레드의 블락을 유발합니다. 이 상태를 pinning이라 하는데, 소중한 커널 스레드가 블락된 시간이 길어지면 성능 저하로 이어지죠.
이는 가상 스레드 내에서 parallelStream
, 네이티브 메서드 등을 사용할 때도 해당됩니다.
이 문제에 대해 해결방안으로 ReentrantLock
가상 스레드도 일반 스레드처럼 thread-local 변수를 지원합니다. Thread-local 변수는 주로 현재 트랜잭션, 사용자 ID 등 컨텍스트에 엮인 정보를 저장합니다. 이런 사용은 가상 스레드의 목적과도 부합하지만, 문제는 thread-local 변수에 재사용 가능 객체를 캐쉬할 때입니다.
객체의 캐쉬는 여러 스레드가 해당 객체를 공유하고 재사용할 때 의미 있지만, 가상 스레드는 pool되지 않고, 재사용되지 않습니다.
대량 트랜잭션 처리를 위해 종종 Java보다 경량 스레드를 잘 지원하는 Go 언어를 선택하곤 했습니다. goroutine
은 가볍고 사용이 간편하다는 장점이 있지만, Java로 개발된 시스템과의 융합이 까다롭고, Go의 Stop the World GC 문제를 무시하기 어렵습니다. Java의 가상 스레드는 동기화 등 아직 발전이 필요한 부분이 있지만, 경량 스레드의 필요성을 공식적으로 인정하고 이를 제공하는 것은 언어의 발전 방향을 잘 제시하고 있다고 생각합니다.