새소식

Welcome to the tech blog of Junior Backend Developer Seulbin Kang!

Study

(CS Study) Java Virtual Thread (feat. 모던 자바 동시성 프로그래밍)

  • -

👀 들어가기 전에

JDK에 정식 도입된지 좀 시간이 지난 Virtual Thread에 대해 알아보려 합니다.

요 근래 OS Level에서의 공부를 하며 현재 제가 실제로 만드는 프로그램과 어떻게 연동되게 되는지를 공부해가며 Thread쪽에 많은 관심이 가기 시작했습니다. I/O 작업이 많은 어플리케이션에서 상당한 장점을 보이는 Virtual Thread를 좀 더 깊이있게 파보기 위해 이 주제를 선정하게 되었습니다. 

 

기존에는 Kernel Level Thread와 User Level Thread를 1:1 매핑하여 사용하는 JVM 스레드 모델이었다면 현재는 여러 개의 가상 스레드를 하나의 네이티브 스레드에 할당하여 사용하는 모델입니다. 이 글에서는 Virtual Thread가 기존 스레드 모델과의 다른점, ~~ 에 대해 알아보도록 하겠습니다. 여러 기업들의 기술 블로그 문서와 모던 자바 동시성 프로그래밍이라는 책을 보며 공부해봤던 내용을 참고하여 정리해보았습니다.


👀 본론

1. Virtual Thread의 등장 (기존 Thread 모델의 한계)

JVM은 이전 Apache Tomcat의 내부 구조를 살펴보며 확인했듯, 인터프리터와 JIT 컴파일러를 통해 Java 바이트코드를 실행하며, 필요할 경우 JNI를 통해 C/C++로 작성된 네이티브 코드를 호출합니다. JNI는 JVM이 C,C++ 로 작성된 네이티브 코드를 호출할 수 있도록 하는 인터페이스로 이를 통해 Java는 플랫폼 독립적인 실행 환경을 유지하면서도 실제 스레드 생성, 파일 I/O, 네트워크 처리와 같은 저수준 작업은 OS에 위임합니다.


기존의 Platform Thread를 통한 연결을 이루어낸 Java의 Thread는 아래와 같이 구성되어있으며 모델의 구조는 다음과 같은 특징을 가집니다.

 

Platform Thread

 

Java Thread는 Heap에 존재하는 객체로 스레드의 상태와 메타 정보를 담고 있습니다. 이 Thread의 실제 실행은 JVM이 생성한 Platform Thread를 통해 이루어지게 됩니다. 이 Platform Thread는 OS Thread와 1:1 매핑이 됩니다. 따라서 JVM은 생명주기를 관리하지만, 실제 스케줄링과 실행은 OS 스케줄러에 의해 수행되도록 구성되어있습니다.

이때 대부분의 웹 어플리케이션은 요청당 하나의 스레드를 할당하는 모델을 사용합니다. 예를 들어 Tomcat과 같은 서블릿 컨테이너에서는 클라이언트의 요청이 들어오면, 스레드 풀에서 하나의 스레드를 할당하고 해당 스레드가 요청의 전체 생애주기를 담당하게 됩니다.

즉, 하나의 요청이 하나의 Platform Thread를 사용하게 되는 것입니다. 


그럼 Thread를 많이 만들면 성능이 좋아질 것 처럼 보입니다. 하지만, 이전 스레드 주제로 발표를 진행했을 때와 같이 실제로는 그렇지 않습니다. Platform Thread는 Heap메모리 이외에도 약 1~2MB 수준의 Stack 메모리를 사용하게 됩니다.

 

이는 스레드 하나만 생각하면 별 문제가 없어보이지만, 수천 개의 동시 요청이 발생하는 대규모 어플리케이션에서는 메모리 사용량이 급증할 수 있습니다. 스레드가 사용할 수 있는 전체 메모리 사용량에 따라 실제 사용 가능한 동시 연결 개수가 크게 달라질 수 있게 되는 것입니다. 어플리케이션이 물리 메모리 전부를 다 사용한다고 가정했을 때, 메모리가 부족해서 디스크 I/O를 유발하는 페이징이 발생하며 처리 속도를 급격하게 떨어트리게 될 것입니다. 읽기 속도는 디스크가 RAM에 비해 1,000배나 느리며 이것만으로도 어플리케이션 성능에 심대한 영향을 끼칠 수 밖에 없는 것입니다.(출처 : 모던 자바 동시성 프로그래밍 도서)

 

대부분의 운영체제는 생성 가능한 Thread 수에 제한이 있습니다. 즉, 어플리케이션의 확장성 자체가 OS Thread 개수에 의해 제한되는 것입니다.

 

그렇다면 주어진 환경에서 만들 수 있는 스레드의 최대 개수는 얼마인지 직접 확인해보도록 하겠습니다.

package com.chapter01;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.LockSupport;

public class ThreadLimitTest {
    public static void main(String[] args) {
        var threadCount = new AtomicInteger(0);
        try{
            while(true){
                var thread = new Thread(() -> {
                    threadCount.incrementAndGet();
                    LockSupport.park();
                });
                thread.start();
            }
        }catch(OutOfMemoryError error){
            System.out.println("Reached thread limit: "+threadCount.get());
            error.printStackTrace();
        }
    }
}

 

위 코드 실행 결과

 

환경 : 18GB RAM / Apple M3 Pro / IntelliJ
결과 : 약 4,074개에서 실패

 

 

 

왜 이렇게 되었을까요?

 

Thread.start()를 호출할 때 마다 JVM은 Platform Thread를 생성하고 OS Thread에 요청을 넣게 됩니다. (pthread_create)

하지만 일정 개수를 초과하게 되면 메모리가 부족하고, OS Thread 제한에 걸리게 되며 위와 같은 에러가 발생하게 되는 것입니다.

즉, 기존 Java Thread 모델은 한계가 생기게 되는 것입니다.

Platform Thread 하나는 OS Thread 하나를 계속 점유하게 되며 Thread가 실행 중이면, OS Thread가 사용되고 Thread가 I/O로 block 당하게 되면 OS Thread도 같이 block당하게 되는 것입니다. Thread가 대기 상태여서 놀고 있게 되더라도 OS Thread는 반환되지 않습니다. 이 문제를 해결하기 위해 등장한 것이 바로 Virtual Thread입니다.


😲 Virtual Thread의 등장

Virtual Thread는 기존에 사용하던 방식과는 많이 다른 양상을 확인할 수 있습니다.

출처 : https://jenkov.com/tutorials/java-concurrency/java-virtual-threads.html

 

Virtual Thread (수만 ~ 수십만개)가 Platform Thread (소수, Carrier Thread)와 연결되며 이게 OS Thread와 연결되는 방식입니다.

즉, 많은 Virtual Thread를 적은 수의 Platform Thread 위에서 실행하는 M:N 모델인 것입니다.

 

위 그림을 통해 파악하면 좀 더 쉽게 파악할 수 있습니다.

Virtual Thread는 보시는 것처럼 OS Thread 위에서 직접 실행되지 않습니다. 대신 Carrier Thread (Platform Thread) 위에 올라가서 실행됩니다. 눈에 띄는 것이 Unmounted Ready Virtual Thread와 Unmounted Blocked Virtual Thread, Mounted Virtual Thread입니다. Virtual Thread의 특징 중하나로, 필요할 때만 Platform Thread 위에 올려 실행하는 것입니다.

자세한 내용은 아래 내부 동작에서 이어 다루어 보도록 하겠습니다.


2. Virtual Thread의 내부 동작

1) 스택 프레임이 Heap에 존재합니다.

기존 Platform Thread는 다음과 같은 구조를 가지게 됩니다.

  • Thread마다 고정된 Stack 메모리 할당 (수 MB) 
  • OS가 직접 관리합니다.

하지만 Virtual Thread는 스택 프레임을 가비지 컬렉션 대상이 되는 Heap 영역에 저장합니다.

가상 스레드1번 mounted
가상 스레드 1번 사용 종료 후 2번 mounted

 

덕분에 스레드에 필요한 스택 크기를 예측할 필요가 없는 것입니다.

가상 스레드가 사용할 수 있는 메모리 사용 공간은 수백 바이트에서 시작하며 호출 스택이 커지고 줄어듬에 따라 자동 조정되며

GC의 대상이 되어 메모리 관리가 가능합니다. 즉, Thread의 스택조차 객체처럼 관리될 수 있는 것입니다.


2) Carrier Thread와 Mount 구조

Virtual Thread는 혼자 실행되지 않습니다. 실제로 CPU 위에서 실행되기 위해서는 어찌되었든 Platform Thread가 필요합니다.

이때 사용되는 것이 바로 Carrier Thread입니다.

JVM은 Virtual Thread를 실행할 때 크게 정리해보면 아래와 같은 과정을 거칩니다.

  1. Virtual Thread를 Carrier Thread에 mount
  2. Carrier Thread가 Virtual Thread의 코드를 실행
  3. 실행이 끝나면 unmount

그렇다면, Carrier Thread의 관리는 누가할까요?

 

캐리어 스레드는 특화된 ForkJoinPool의 일부입니다.

마운트 과정에는 힙에 있는 스택 프레임을 캐리어 스레드의 스택으로 임시 복사하는 것도 포함됩니다.

본질적으로 캐리어 스레드는 가상 스레드의 코드를 실행하기 위해 빌려온 것입니다.


3) 가상 스레드가 블로킹 연산을 처리하는 방식

가상 스레드가 I/O 대기처럼 일반적으로 스레드를 블로킹하는 연산을 만나면, 캐리어 스레드로부터 언마운트 될 수 있습니다.

캐리어 스레드의 스택에 복사된 후 코드가 실행되면서 변경이 발생한 스택 프레임 내용이 힙으로 다시 복사되고,

캐리어 스레드는 자유로운 몸이 되어 다른 작업을 수행할 수 있게 합니다.

 

가상 스레드는 바로 이런 블로킹 연산 처리 방식 덕분에 자원 효율성을 극도로 높일 수 있습니다.

이 과정은 JDK내의 거의 모든 블로킹 지점에 소급 적용되어 있습니다.

ex) Socket I/O, File I/O, Thread.sleep 등등

4) Stack 이동 (Mount / Unmount의 과정)

조금 더 내부적으로 살펴보면 아래와 같습니다. 실행 중 → 일부 스택이 Carrier Thread에 올라갑니다. Blocking이 발생되면 변경된 스택 상태를 Heap에 저장합니다. 다시 실행 시 → Heap → Carrier Thread로 복원합니다. 이 과정을 통해 Virtual Thraed는 필요할 때만 실행 상태를 가지게 됩니다.


5) ThreadLocal의 격리

Virtual Thread는 Carrier Thread와 완전히 분리되어 있습니다.

ThreadLocal의 격리 상태

 

Carrier Thread의 ThreadLocal 값은 보이지 않으며 Virtual Thread 자체의 ThreadLocal만 사용하도록 되어있습니다. 

이런 강력한 추상화가 저희가 여태껏 불편하게 여겨왔던 1:1구조의 문제점을 해결시켜준 것입니다.


6) Virtual Memory와의 유사성

제가 왜 이 타이밍에 이 주제를 갖고 왔는지 눈치채신 분들도 있을 것이라 생각합니다.

Virtual Thread의 구성 자체(?), 논리 자체가 바로 이전 주 주제인 Virtual Memory와 굉장히 유사합니다.

 

가상스레드는 희소하고 비용이 많이 드는 플랫폼 스레드를 공유함으로써 사실상 무제한적인 멀티스레드의 환상을 만들어냅니다.

가상메모리 시스템에서 사용되지 않는 메모리 페이지가 디스크로 페이지 아웃 되는 것처럼 사용되지 않는 가상 스레드의 스택은 힙으로 페이지 아웃됩니다.


3. Virtual Thread의 함정 (feat. Netflix)

지금까지 살펴본 Virtual Thread는 마치 모든 문제를 해결해주는 완벽한 기술처럼 보입니다.

하지만 모든 기술적 선택, 기술에는 Trade Off가 있듯이 이 가상스레드 또한 문제점이 존재합니다.

 

대표적인 사례를 Netflix 기술문서에서 가져와 보았습니다.

(출처 : https://netflixtechblog.com/java-21-virtual-threads-dude-wheres-my-lock-3052540e231d)

 

글을 읽어보시면 결국 여기서 이야기하는 바는 Pinning과 관련한 이야기입니다.

Pinning이란 Virtual Thread가 Carrier Thread에서 Unmount되지 못하는 상태를 의미합니다.

 

Netflix에서는 이 Pinning이라는 문제가 어떻게 발생했고, 왜 문제가 되었을까요?

 

Netflix에서는 Virtual Thread를 적용하는 과정에서 아래와 같은 문제를 겪습니다.

하나의 Lock과 제한된 Carrier Thread가 결합되면서, Lock을 기다리는 Virtual Thread들이 Carrier Thread를 점유하고,

Lock을 획득해야 할 Thread는 실행 기회조차 얻지 못하는 상태가 발생하게 되는 것입니다.

 

출처 : Netflix TechBlog

 

그래프를 보시면, RPS는 초반에는 정상적으로 처리되다가 후반으로 가면 갈수록 서버가 요청을 처리 못하는 것을 확인할 수 있습니다.

반면 closeWait 소켓은 계속 증가하여 계속 상승하고 있습니다. 요청은 계속 들어오고 있지만 응답처리가 안되어 쌓이고 있는 것을 의미합니다.

 

이때, Tech Blog를 보게되면 Unparking 신호를 받았지만 Fork-Join Pool이 동일한 락을 기다리는 다른 4개의 가상 스레드로 점유되어 있어서 실행이 안되었던 것입니다.

 

쉽게 풀어 설명을 하면,

Pinning 발생

 

Virtual Thread는 unpark 신호를 받아 실행 준비가 되었지만, Fork-Join pool의 모든 carrier thread가 동일한 lock을 기다리는 다른 Pinned virtual thread들에 의해 점유되어 있어 실행될 수 없는 것입니다.

Carrier 고갈

 

실행 불가 상태

 

이로 인해 lock을 획득해야 하는 thread가 실행되지 못하고, 다른 pinned virtual thread들 역시 lock을 획득하지 못한 채 계속 대기하게 되면서 전체 시스템이 멈추는 상태가 발생된 것입니다.

 

아마 위 Tech Blog에 코드가 나와있지는 않지만, 예상컨데, Synchronized를 사용하며 파생된 문제가 아닐까 생각됩니다.

Synchronized는 Carrier Thread를 Blocking하기에 리소스를 독점 사용할 때는 Synchronized 대신 ReentrantLock을 사용하여야 하는데, Synchronized를 사용하며 Carrier Thread를 Blocking하고, 이로 인해 Lock 획득 자체가 실패하면서 문제가 발생된 것으로 해석되어집니다.


👀 결론

지금까지 살펴본 내용을 정리해보면 Virtual Thread는 단순히 스레드를 많이 만들어서 사용자를 편리하게 해주는 기술이 아닙니다.

스레드라는 추상화의 비용 구조 자체를 변경한 것입니다.

 

기존의 Platform Thread 모델에서는 스레드 하나가 곧 OS Thread 하나였고, 이는 곧 메모리 비용 + 커널 리소스 + 스케줄링 비용을 의미했습니다. 그 결과, 스레드 자체가 귀한 자원이었고 이 안에서 잘 사용하기 위해 Thread Pool을 만들고, 비동기 프로그래밍을 하는 등 구조적으로 변화시키는 선택을 해왔습니다. 하지만 Virtual Thread는 이 흐름을 바꿔놓았습니다.

Heap에 상태를 저장하고 필요할 때만 Carrier Thread를 빌려 쓰는 구조를 통해 스레드를 싸고 가볍게 만들어버린 것입니다.

그러면 이를 통해 어떤 것을 얻을 수 있었을까요?

 

기존에는 성능을 위해 어쩔 수 없이 비동기 코드를 작성해야만 했다면, 이제는 I/O Bound Application에서는 동기적 코드 스타일을 유지하면서도 높은 동시성을 얻을 수 있게 되었습니다. I/O Bound 작업이라든가, 요청 1개가 Thread 1개씩 점유하던 모델, 높은 동시성을 처리해야하는 서버들에 있어서 기존에 고민하던 부분들은 이 Virtual Thread로 대부분 해결할 수 있는 것입니다.

다만 CPU Bound 작업에서는 생각만큼 큰 효과를 볼 수 없습니다. (CPU는 결국 물리적으로 제한된 자원이기 때문입니다. 따로 대기가 없기에 오히려 스케줄링 비용만 증가시키게 될 수도 있는 것입니다. (Virtual Thread가 많으면 JVM 스케줄러가 계속 관리해야하기에...))

이 동시성 프로그래밍의 패러다임을 바꾸는 기술을 잘 활용하기 위해서는 앞으로 더 공부해봐야할 것 같습니다...!

Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.