[JAVA] Java21의 Virtual Thread에 대하여
- 프로그래밍 언어/[JAVA]
- 2024. 5. 23.
Virtual Thread의 탄생 배경
Java 개발자들은 오랜 기간 동안 병렬 처리 단위로 스레드를 사용했다.
스레드는 독립적으로 실행되며 각각의 스택(Stack)을 가지고 있고 프로세스보다 가벼워 컨텍스트 스위칭(Context Switching)이 빠른 장점이 있다.
그러나 자바 스레드는 운영체제(OS) 스레드의 Wrapper로 구현되어 있기 때문에 OS의 스레드보다 많은 수를 가질 수 없다. 게다가 OS 스레드는 비용이 비싸기 때문에 많은 수를 생성하기 힘들다.
문제는 많은 환경에서 Request-per-Thread 구조를 사용하고 있다는 것이다.
이는 요청 하나에 스레드 한개가 작업을 진행하는 구조를 뜻한다.
스레드 1개당 1MB 사이즈를 차지한다고 했을 때 8GB 메모리 환경에서 약 8000개의 스레드를 보유할 수 있다.
오늘날 어플리케이션들이 동시에 수천수만 개의 요청을 받는 것을 생각하면 스레드가 매우 부족함을 알 수 있다.
이러한 와중 자바 스레드에서 I/O 작업이나 네트워크 연결 작업 등을 만나 block 된다면 작업이 끝날 때까지 대기하며 비효율성이 발생하게 된다.
서버는 수많은 I/O 작업이 발생하기 때문에 이때마다 스레드가 일을 하지 않는다면 매우 비효율적일 것이다.
이러한 비효율을 개선하기 위해 Java 진영에서는 Virtual Thread를 탄생시켰다.
Virtual Thread란?
Virtual Thread는 처리량이 많은 동시 어플리케이션을 효율적으로 처리할 수 있는 경량 스레드이다.
운영체제에서 Virtual Memory 기술이 실제 메모리보다 더 큰 메모리를 가지고 있는 것처럼 동작하는 것처럼
Java 진영에서 Virtual Thread 기술은 실제 스레드보다 많은 스레드를 가지고 있는 것처럼 동작한다.
실제로 매우 많은 Virtual Thread를 생성해도 Out Of Memory(OOM) 문제가 발생하지 않는다.
Platform Thread는 java.lang.Thread의 인스턴스로 OS 스레드의 wrapper로 1대1 대응되며 Java 코드를 실행한다.
기존에 사용하던 Java 스레드라고 생각해도 무방하다.
반면 Virtual Thread는 특정 OS 스레드에 종속되지 않고 Platform Thread에 N:1로 종속된다.
따라서 OS 스레드보다 많은 수가 존재할 수 있다.
Platform Thread 위에서 Virtual Thread가 컨텍스트 스위칭을 하며 동작하는데, 이때 Virtual Thread는 스택 크기가 매우 작아 컨텍스트 스위칭 비용이 기존 Platform Thread보다 훨씬 저렴하다.
Platform Thread | Virtual Thread | |
Stack Size | ~2MB | ~10KB |
생성 시간 | ~1ms | ~1us |
컨텍스트 스위칭 | ~100us | ~10us |
게다가 기존 스레드가 생성시 System Call을 통해 커널(Kernel)과 통신하기 때문에 생성비용이 매우 큰 반면,
Virtual Thread는 JVM을 통해 생성되기 때문에 System Call이 적어 생성 비용이 훨씬 저렴하다.
이러한 특성 덕분에 Virtual Thread는 기존 스레드보다 좋은 성능을 발휘할 수 있다.
Virtual Thread의 동작 원리
우선 Virtual Thread는 아래와 같은 요소들을 가지고 있다.
- carrierThread : 실제로 작업을 수행하는 PlatformThread.
- scheduler : Virtual Thread의 스케줄링을 담당하는 스케쥴러. ForkJoinPool을 사용한다.
- runContinuation : Virtual Thread의 실제 작업 내용(Runnable)
Virtual Thread는 결국 carrierThread(PlatformThread)에 마운트(mount)되어 실행되는데 이는 unpark() 메서드를 통해 동작한다. 여기서 마운트는 Virtual Thread를 carrierThread에 할당한다고 생각하면 된다.
void unpark() {
...
if (s == PARKED && compareAndSetState(PARKED, RUNNABLE)) {
if (currentThread instanceof VirtualThread vthread) {
vthread.switchToCarrierThread();
try {
submitRunContinuation();
} finally {
switchToVirtualThread(vthread);
}
} else {
submitRunContinuation();
}
}
...
}
}
private void submitRunContinuation() {
try {
scheduler.execute(runContinuation);
} catch (RejectedExecutionException ree) {
submitFailed(ree);
throw ree;
}
}
실제 코드를 보면 unpark() 메서드를 통해 unpark 될 수 있는 Virtual Thread를 scheduler(ForkJoinPool)에게 넘겨 runContinuation을 실행(execute)하는 것을 확인할 수 있다.
이때 Virtual Thread는 ForkJoinPool의 Work Steal Queue에서 스케줄링되어 처리된다.
void park() {
...
// park on the carrier thread when pinned
if (!yielded) {
parkOnCarrierThread(false, 0);
}
}
private void parkOnCarrierThread(boolean timed, long nanos) {
assert state() == RUNNING;
...
setState(PINNED); RUNNING -> PINNED 로 전환
...
}
또한 Virtual Thread는 park() 메서드를 통해 언마운트(unmount)되어 힙(Heap) 메모리로 반환된다.
Virtual Thread가 강력한 이유는 이러한 일련의 과정들이 JVM에 의해 스케줄링되고 통제되기 때문이다.
실제로 Virtual Thread가 sleep 되는 상황의 코드를 살펴보면,
public static void sleep(long millis) throws InterruptedException {
if (millis < 0) {
throw new IllegalArgumentException("timeout value is negative");
}
long nanos = MILLISECONDS.toNanos(millis);
ThreadSleepEvent event = beforeSleep(nanos);
try {
if (currentThread() instanceof VirtualThread vthread) {
vthread.sleepNanos(nanos);
} else {
sleep0(nanos);
}
} finally {
afterSleep(event);
}
}
기존 스레드와 호환성을 위해 현재 실행중인 스레드가 Virtual Thread인지 검사하고 맞다면
sleepNanos를 통해 sleep 상태로 전환된다.
void sleepNanos(long nanos) throws InterruptedException {
...
parkNanos(remainingNanos);
...
}
@Override
void parkNanos(long nanos) {
...
boolean yielded = false;
Future<?> unparker = scheduleUnpark(this::unpark, nanos);
setState(PARKING);
try {
yielded = yieldContinuation(); // may throw
...
}
private boolean yieldContinuation() {
// unmount
notifyJvmtiUnmount(/*hide*/true);
unmount();
try {
return Continuation.yield(VTHREAD_SCOPE);
} finally {
// re-mount
mount();
notifyJvmtiMount(/*hide*/false);
}
}
코드를 살펴보면 명시적으로 OS 스레드(Kenel Thread)를 sleep 하지 않는 것을 확인할 수 있다.
다만 unmount와 mount를 통해 현재 Virtual Thread를 sleep 상태로 전환하고 다른 Virtual Thread가 다른 작업을 진행할 수 있는 것이다.
즉, non-blocking 방식으로 처리되기 때문에 I/O작업이 있을 때 상당한 퍼포먼스를 보여줄 수 있다.
Virtual Thread 주의사항
Virtual Thread는 매우 강력하지만 주의해야할 사항들이 있다.
No Pooling
기존에는 스레드를 사용할 때 스레드풀(Thread pool)을 많이 사용했지만 Virtual Thread는 그럴 필요가 없다.
생성비용이 매우 저렴하기 때문에 스레드풀을 생성하기 보다 새롭게 Virtual Thread를 생성하는 게 낫다.
또한 Virtual Thread는 생명주기동안 하나의 작업만 설계되었기 때문에 풀링하는 것은 좋지 않다.
No CPU bound Task
Virtual Thread는 Platform Thread보다 단일 작업 속도가 느린편이다. 따라서 I/O 작업등이 없이 CPU bound Task만을 실행하는 작업이라면 Virtual Thread보다 Platform Thread를 통해 실행하는 것이 좋다.
No Synchronized block, Native Method
Virtual Thread는 synchronized block에서 실행되거나 Native Method(JNI를 통한)를 실행하면 pinned 상태가 된다. 이 pinned 상태에서는 carrier Thread로 언마운트 될 수 없다. 따라서 Virtual Thread의 장점을 제대로 이용할 수 없다.
이러한 경우 ReentrantLock을 사용하면 해결할 수 있다고 한다.
실제로 Spring이나 MySQL등 많은 곳에서 synchronized가 사용되고 있는데, 이를 ReentrantLock으로 많이 전환하고 있어 Virtual Thread 사용에 대비하고 있다.
Thread Local
Virtual Thread는 Thread Local을 지원한다. 하지만 Thrad Local은 아래와 같은 단점들이 있다.
- unbound lifetime : 명확한 생명주기가 없다
- unconstrained mutability : 변경 가능성에 대한 제약이 없다
- unconstrained memory usage : 메모리 사용에 대한 제약이 없다
JVM 개발자들은 Virtual Thread를 기존 코드들과의 호환성을 위해 어쩔 수 없이 Thread Local을 지원하도록 설계했다. 하지만 Virtual Thread는 사용 시 엄청난 수가 존재할 수 있기 때문에 Thread Local 사용을 신중히 생각해야 한다.
실제로 Virtual Thread를 위해 JDK의 java에서 Thread Local의 많은 것을 제거했다고 한다.
대신 Scoped values라는 기술을 Thread Local의 대안으로 사용할 수 있도록 JEP429로 준비중이라고 한다.
정리
Java 21의 Virtual Thread는 I/O 작업 등 스레드가 blocking 되는 상황에서 기존 스레드보다 큰 성능을 발휘한다. 하지만 CPU bound 작업에서는 좋은 성능을 발휘할 수 없으니 주의해야 한다.
기존 비동기 방식의 코드를 작성하는 것보다 훨씬 사용이 간단하고 강력한 Virtual Thread를 활용하는 것은 Java 프로그래밍의 미래라고 보인다.
참고
[1] https://openjdk.org/jeps/444
[2] https://techblog.woowahan.com/15398/
[3] https://d2.naver.com/helloworld/1203723
[4] https://mangkyu.tistory.com/309
'프로그래밍 언어 > [JAVA]' 카테고리의 다른 글
[JAVA] 새로운 Java LTS버전, Java 21의 특징들에 대해 알아보자 (0) | 2024.05.15 |
---|---|
[JAVA] POJO(Plain Old Java Object)란? (0) | 2023.04.27 |
[JAVA] 얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy)에 대하여 (2) | 2023.02.14 |
[Java] 직렬화(Serialization)와 역직렬화(Deserialization)란? transient 변수란? (0) | 2023.01.26 |
[JAVA] 추상클래스와 인터페이스의 차이 (6) | 2023.01.12 |