개요
Java19에서 Virtual Thread가 얼리억세스로 포함되었습니다.
Java21에서는 Virtual Thread를 정식 기능으로 포함시켰습니다.
Virtual Thread는 경량 스레드 모델입니다.
비슷한 모델로는 Go의 goroutine, Kotlin의 coroutine 등이 있습니다.
Kotlin의 coroutine이 2017년 추가된 기능임을 감안하면, 6년이나 걸렸다는 것을 알 수 있습니다.
하지만 Virtual Thread가 Java21에 추가되며, 선택지가 하나 더 생겼습니다.
새로 서비스를 개발하는 입장에서는 Java + coroutine보다는 Only Java로 개발할 수 있을 것입니다.
SpringBoot 3.2버전부터 Virtual Thread를 정식으로 지원하기 시작했습니다.
때문에 많은 곳에서 Virtual Thread로 마이그레이션을 진행했고, 잘만 사용한다면 효율을 극대화시킬 수 있을 물건입니다.
물론 모든 상황에서 만능인 것은 아니기에, 제대로 사용하기 위해 주의해야 할 부분이 많습니다.
이번 글에서는 Virtual Thread가 어떻게 동작하는지, 성능이 얼마나 좋아졌는지에 대해 간단히 알아보겠습니다.
Thread
Java에서 Thread는 User Thread와 Kernel Thread가 1:1로 매칭됩니다.
이전 글에서 native method인 start0()를 사용해 Thread를 만들어야 한다 했었는데, 이것이 바로 JNI를 통해 System call을 발동하고 OS Thread를 만든 뒤 User Thread와 잇는 것입니다.
Kernel단의 PCB에 Thread를 적재하고, User단의 객체와 연결하고, 추가 작업을 진행하는 것입니다.
Thread가 Process의 대안으로 등장했을 때, 자원소모가 극적으로 낮아졌습니다.
그러나 사용자의 Request가 극적으로 많아지면서, 어떻게든 최적화를 해야 하는 문제점이 발생했습니다.
Oracle 32bit JVM의 경우, Thread의 최대 크기는 320KB이고, Oracle 64bit JVM은 1MB입니다.
64bit 서버를 운영할 때 4GB메모리를 가진 서버는 4096개의 스레드를 가질 수 있을 것입니다.
물론, 운영체제와 기타 비용을 제외한다면 그것보다 적어집니다.
또, 해당 스레드에서 실행되는 작업이 또 자원소모를 발생시킨다면 실질적으로 사용 가능한 자원이 더 줄어들 것입니다.
따라서 요청 처리량, Context Switching등에 사용되는 비용을 줄일 필요성이 지속적으로 제기되었습니다.
이를 위해 Java에서 준비한 선물이 바로 Virtual Thread라 할 수 있습니다.
Virtual Thread
JVM내 구조가 기존 스레드 모델과 완전히 달라진 모습을 볼 수 있습니다.
ForkJoin Pool의 경우 일종의 ThreadPool이라 보시면 됩니다.
ThreadPool이란? -> (https://nangmandeveloper.tistory.com/7)
ForkJoinPool에서 관리하는 Platform Thread들이 OS Thread와 1:1로 매칭이 되고, Platform Thread와 Virtual Thread가 1:N방식으로 매칭되는 것을 볼 수 있습니다.
기존에 하나의 스레드를 추가하기 위해서 PCB에 Thread를 올리고, user단에서 Thread객체를 올리고, 둘을 연결하고...
이러한 작업을 진행했다면
ForkJoin Pool에서 일정량의 Platform Thread를 유지함으로 Thread추가/제거시의 오버헤드를 없애고, Virtual Thread를 1:N으로 매칭하면서 작업 효율성을 높인 것입니다.
구조만 보면, 마치 TomCat의 ThreadPool이 생각납니다.
스레드를 사용하기 위한 단계가 달라졌으니, Context Switching 비용이 절감되었습니다.
System call이 없어도 되고(물론 ThreadPool에서 새 Platform Thread를 만들 때는 System call이 있을 겁니다.), Virtual Thread가 Thread보다 경량인 구조이고...
아래 두 링크에서 해당 내용을 더 자세히 볼 수 있습니다.
https://blog.ycrash.io/2023/01/20/is-java-virtual-threads-lightweight/
https://techblog.woowahan.com/15398/
Virtual Thread 사용해보기
public class VirtualThread {
public static void main(String[] args) {
Thread th1 = Thread.ofVirtual().name("hello").unstarted(new examVThread());
Thread th2 = Thread.ofVirtual().name("hello2").unstarted(new examVThread());
th1.start();
th2.start();
try {
th1.join();
th2.join();
} catch (Exception e){
e.printStackTrace();
}
}
}
class examVThread implements Runnable{
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " " + Thread.currentThread().getThreadGroup());
}
}
위의 코드를 실행하면 Virtual Thread를 사용할 수 있습니다.
기존 스레드를 사용하는 것과 큰 차이가 있지는 않습니다.
일반 스레드처럼 클래스를 만들고, Thread.ofVirtual()...으로 새 가상 스레드 객체를 만들고, start메서드로 실행합니다.
이전 글에서 배웠던 ThreadGroup이 VirtualThreads로 되어 있는 것을 볼 수 있습니다.
public class ThreadVersus {
public static void main(String[] args) {
long normalStart = System.currentTimeMillis();
for(int i = 0; i < 100000; i++){
Thread th = new Thread(new NormalThread());
th.start();
try {
th.join();
} catch (Exception e){
e.printStackTrace();
}
}
long normalEnd = System.currentTimeMillis();
long vStart = System.currentTimeMillis();
for(int i = 0; i < 100000; i++){
Thread th = Thread.ofVirtual().unstarted(new VirThread());
th.start();
try {
th.join();
} catch (Exception e){
e.printStackTrace();
}
}
long vEnd = System.currentTimeMillis();
System.out.println("Normal Thread Time : " + (normalEnd - normalStart) / 1000);
System.out.println("Virtual Thread Time : " + (vEnd - vStart) / 1000);
}
}
class NormalThread extends Thread {
@Override
public void run() {
int res = 0;
for(int i = 0; i < 100; i++){
res += i;
}
}
}
class VirThread extends Thread {
@Override
public void run() {
int res = 0;
for(int i = 0; i < 100; i++){
res += i;
}
}
}
이 코드를 실행해 보면, Virtual Thread를 사용할 때 오버헤드가 얼마나 줄어들었는지 확인할 수 있습니다.
***
- 위 코드는 소수점 이하 시간을 버린 상태입니다.
- 위 코드는 멀티스레드 실행을 테스트하는 것이 아닙니다.
- Thread와 Virtual Thread로 동일한 작업을 진행할 때, Thread를 생성, 삭제하는 데 있어서 얼마나 차이가 나는지 테스트하는 코드입니다.
- Thread.join()을 사용하면, 해당 스레드 종료 후 다음 스레드로 넘어가기 때문에 동기상태로 동작합니다.
***
17년식 구형 노트북으로 테스트해본 결과, 10배 정도 차이가 발생했음을 알 수 있습니다.
스레드 생성과 제거 등에 필요한 오버헤드가 줄어든 것입니다.
디버거를 통해 아래의 사실을 확인할 수 있습니다.
1. Virtual Thread는 스케줄러로 ForkJoinPool을 사용한다.
2. runContinuation : runnable(lambda)
3. carrierThread : workQueue를 갖는 Platform Thread
Virtual Thread의 동작과정은 다음과 같습니다.
1. Heap에 Virtual Thread runContinuation이 있다.
2. Virtual Thread 실행 시, runContinuation을 park()메서드로 올린다.
3. 해당 runContinuation을 ForkJoinPool의 carrierThread가 가진 Work Steal Queue에 넣는다.
4. 이렇게 진행되는 runContinuation은 sleep inturrpt, complete되면 heap에 돌아간다.
ForkJoinPool의 CarrierThread(Platform Thread)는 항시 대기중이고
Virtual Thread가 올려질 때마다 Work Steal Queue에 작업이 들어가고, 분배됩니다.
일반 스레드는 TCB Context Switching시 PCB내의 TCB가 커널 레벨에서 교체되는 반면
가상 스레드는 runnable과 같은 요소만 유저 레벨에서 교체되는 것입니다.
고려사항
아쉽게도, Virtual Thread는 만능이 아닙니다.
여러 가지의 고려사항이 있겠지만 몇 가지만 살펴보도록 하겠습니다.
1. ThreadPool
Virtual Thread를 사용할 때, ForkJoinPool이 사용됩니다. 이러한 ThreadPool은 생성 비용이 매우 큰 구조를 가지고 있기 때문에, 경량 작업을 수행하는 시스템이라면 이 비용이 더 클 수 있습니다.
2. CPU Bound작업시 비효율적이다.
CPU Bound 프로그램이라면, 스레드가 적거나 가상 스레드를 사용하지 않는 것이 더 효과적일 수 있습니다.
IO Bound 프로그램이라면 스레드의 갯수가 어느 정도 있는 것이 효과적이기 때문에, 가상 스레드가 효과적입니다.
3. Pinned Issue
가상 스레드 내에서 syncronized, parallelStream, native method를 사용하면 virtual thread가 carrier thread에 할당될 수 없는 상태가 됩니다. 이 때 문제가 발생할 수 있기 때문에 ReentrantLock을 사용하는 등의 조치가 필요합니다.
4. ThreadLocal
ThreadPool사용시 언제나 주의해야 하는 부분으로, ThreadLocal을 최대한 작게 사용하거나 사용을 자제해야 합니다.
Platform thread가 재사용되기 때문에, 사용 이후 remove()메서드를 통해 ThreadLocal을 비워줘야 합니다.
'JAVA > Thread' 카테고리의 다른 글
[Thread] 5. Virtual Thread 알아보기 - 3 (0) | 2024.02.04 |
---|---|
[Thread] 4. Virtual Thread 알아보기 - 2 (0) | 2024.01.28 |
[Thread] 2. JAVA에서 Thread사용하기 (1) | 2023.12.19 |
[Thread] 1. Process와 Thread의 차이 (0) | 2023.12.18 |