Java의 쓰레드 모델은 Native 쓰레드로, Java의 유저 쓰레드를 생성하면 JNI(Java Native Inerface) 를 통하여 커널 영역에서 시스템 콜을 호출하며 OS 가 커널 쓰레드를 생성하고 매핑하여 작업을 수행하는 형태입니다.
Platform Thread 의 기본 스케줄러는 ForkJoinPool 방식을 사용합니다. 스케줄러는 Thread Pool 을 관리하고, Virtual Thread 의 작업 분배 역할을 합니다. 여기서 Platform은 실제 운영 체제의 Thread 와 JVM 내부의 Virtual Thread 를 중계하는 플랫폼으로 해석할 수 있습니다.
가장 주요한 사항으로는 Virtual Thread 가 JVM 영역 내부에 존재하며, JVM에 의해 스케줄링 된다는 점입니다. 이로 인해 얻을 수 있는 장점은 다음과 같습니다.
Platform Thread 와 연결될 Virtual Thread 에서, Blocking 이 발생하면 대기중인 Virtual 쓰레드를 연결하여 작업을 처리합니다.
Thread Pool 을 병렬 혹은 동시 프로그래밍에서 빠질 수 없는 도구입니다. 여러 코어 혹은 CPU 를 활용하는 측면에서나 과도하게 Thread 가 생성되는 비효율성을 줄이는 측면에서나 Thread Pool 을 좋은 해답이 됩니다.
그러나 일반적인 Thread Pool 의 구현에서도 병목이 되는 부분이 생기는데, 바로 Thread Pool 에 맡겨질 Work 를 담고 있는 Queue 부분입니다.
위의 문제는 Queue 를 하나만 사용해서 생기는 문제이기 때문에 Queue 를 여러 개 사용해서 문제를 해결할 수 있습니다. 즉, Thread 마다 Queue 를 사용하는 것입니다.
하지만, 이는 부족한 해결 방법입니다. 예를 들어 하나의 Work 를 처리하면 새로운 Work 가 여러개 생기는 형태의 작업인 경우에는 Thread Pool 에 있는 Thread 중 하나의 Thread 에만 집중적으로 Work 가 몰리게 되는 현상이 발생할 수 있습니다.
-> 즉, 한 Thread 에는 작업이 넘치는데 다른 Thread 는 놀고 있을 수 있습니다.
Work Stealing 이란 위에서 살펴본 Thread 가 전용 Queue 를 사용함으로써 생기는 Thread 간 불균형을 해결하기 위한 방법입니다. 여기서의 개념은 한 Thread 가 자신의 Queue에 더이상 처리할 Work 가 없다면 다른 Thread Queue 에 있는 Work 를 가져가 (Stealing) 자신이 처리하는 것입니다.
여기서 Work Queue 는 Deque(Double Ended Queue) 자료구조로 구현되어 Queue 와는 달리 Front 와 End 모두에서 push 와 pop 이 가능한 자료구조 입니다.
IO가 발생한 작업을 중단하고 즉시 다른 요청 메인스트림을 처리하므로, 예열이 필요한 경우나 순간적으로 트래픽이 몰릴 경우 DB 커넥션과 가은 인프라 자원을 순간적으로 고갈시킬 수 있습니다.
따라서 내부적으로 적은 인프라 자원을 소비하도록 캐싱 및 커넥션 풀 개수를 미리 어느정도 제한한느 튜닝작업이 필요할 수 있습니다.
Virtual Thread 는 값싼 일회용품이라고 보면 됩니다. 생성비용이 작기 때문에 쓰레드 풀을 만드는 행위 자체가 낭비가 될 수 있습니다. 필요할 때마다 생성하고 GC 에 의해 소멸되도록 방치하는 것이 좋습니다.
IO 작업이 없이, CPU 작업만 수행하는 것은 기존의 방식보다 성능이 떨어집니다. 컨텍스트 스위칭이 빈번하지 않는환경이라면 기존의 쓰레드 모델을 사용하는 것이 더 나을 수 있습니다.
Virtual Thread 내에서 synchronized
나 parallelStream
혹은 네이티브 메서드를 쓰면 virtual thread 가 플랫폼 쓰레드에 적용될 수 없는 상태가 되어버립니다. 이를 Pinned(고정된) 상태라고 하는데요. 이는 Virtual Thread 의 성능 저하를 유발할 수 있습니다.
서드파티 앱이나, synchronized 과정 중에 다소 시간이 걸리는 연산이 존재하는 경우엔 ReenterantLock 으로 대체할 수 있을지 고려해야 합니다.
public static void initialize(List<Company> publisherList) {
Map<String, CompanyDto> newStore = new ConcurrentHashMap<>();
for (Company publisher : publisherList) {
...
}
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
STORE = newStore;
} finally {
lock.unlock();
}
}
Virtual Thread 는 수시로 생성되고 소멸되며 스위칭됩니다. 백 만개의 쓰레드를 운용할 수 있도록 설계가 되었기 때문에, 항상 크기를 작게 유지하는 것이 좋습니다.
푸른선: waiting-thread -> 약 2000 가량의 waiting 가 감소.
Thread 전체로 보면 기존대비 필요한 Threads 양이 약 40% 감소.
메모리 할당 빈도역시 줄어든것 확인 가능
(위에서 언급한 Virtual Thread가 일반 Thread 보다 가볍다고 한 부분 확인)
GC시 걸리는 Stop The World 평균 시간이 34% 정도 감소
미적용(GC STW)
적용(GC STW)
프로세스 사용량 기준 평균 약 3.2%의 CPU 사용량 감소, 기존 대비 약 20% 정도의 CPU 사용량 감소.
Load Avergage 는 1.45 감소, 기존 대비 약 12% 감소
Thread 는 일단 생성 비용이 비싸고 작업을 전화하는 비용이 비쌉니다. 또한 한 Thread가 다른 Thread 로부터 작업을 기다려야할 때 Blocking 되게 되면 해당 Thread 는 하는 작업 없이 다른 작업이 끝날때 까지 기다려야하기 때문에 자원이 낭비됩니다. 아래의 그림은 작업의 단위가 Thread 일 경우 생기는 고질적인 문제점 입니다.
코틀린에서도 Thread 라는 작업의 단위를 사용하지만, Thread 내부에서 작은 Thread 처럼 동작하는 코루틴이 존재합니다. Thread 하나를 일시 중단 가능한 Thread 처럼 사용하는 것이 바로 Coroutine 입니다.
코루틴의 동작 방식은 다음과 같습니다.