Java : 가상 스레드

     

     

     

     

    쓰레드 모델

    아래같은 쓰레드 모델이 존재한다. 여기서 자바는 (b) Pure kernel-level 쓰레드 모델을 사용하고 있다. JDK의 구현마다 다르겠지만, 내가 테스트 해본 OpenJDK 17, 21에서 쓰레드풀 사이즈가 증가하는만큼 OS 쓰레드 개수가 증가하기 때문에 유절 레벨 쓰레드 - 커널 레벨 쓰레드는 One To One으로 Binding 된다고 이해했다.

    Thread Model

    쓰레드는 스케쥴링을 관리하는 주체가 유저 / 커널이냐에 따라 유저 레벨 쓰레드와 커널 레벨 쓰레드가 존재한다. 위의 쓰레드 모델을 살펴보면 다음과 같다.

    • (a) Pure user-level : 커널은 프로세스가 CPU를 사용할 수 있도록 스케쥴링 해줌. 프로세스 내부의 유저 레벨 쓰레드의 스케쥴링은 프로세스 라이브러리에서 처리함. 프로세스의 유저레벨 쓰레드 하나가 블록되면, 프로세스 전체가 블록됨.
    • (b) Pure kernel-level : 유저 레벨 쓰레드 - 커널 레벨 쓰레드가 Binding 됨. OS는 커널 레벨 쓰레드을 스케쥴링 함. 스케쥴링의 주체가 OS임. 
    • (c) Combined : 두 방법을 조합해서 사용함. 

    결론부터 이야기하면 현재 대부분의 JDK는 Pure kernel-level 스레드 모델을 사용하고 있으며, 유저 레벨 쓰레드만큼의 커널 레벨 쓰레드를 만들어서 One to One으로 바인딩해서 사용한다. 

     

    One to One 바인딩 확인해보기

    Spring Tomcat의 쓰레드 풀 최소 사이즈를 수정하면서 Java 프로세스에 할당된 OS 쓰레드의 갯수를 확인하는 방식으로 One to One인지 확인해봤다. 실험해 본 JDK 환경은 openjdk:17, openjdk:21이며 모두 동일하게 유저 레벨 스레드 / OS 레벨 스레드가 One to One으로 바인딩 되는 것을 확인할 수 있었다. 

    특정 프로세스에 할당된 OS 레벨 스레드의 갯수를 확인하는 명령어는 다음과 같다. 

    # 프로세스에 할당된 OS 레벨 쓰레드 목록 → 여기서 Java만 보면 됨. 
    $ pstree -p  
    
    # 전체 프로세스에 할당된 OS 레벨 쓰레드 목록
    $ pstree -p | wc -l

    결과는 아래 그래프에서 확인할 수 있다. openjdk17, openjdk21은 모두 "x = y" 그래프에 맞춰서 Tomcat Thread Pool Size가 증가할 때 OS Level Thread도 1:1 수준으로 증가하는 것을 확인할 수 있었다.

     

     

    기존 자바 쓰레드의 이해

    여기서부터는 이 블로그에 작성하신 분을 내가 이해한 것을 바탕으로 재구성했다. 원본 글에서 작성해주신 깊은 내용은 이곳에 있다.


    Step1. LWP(Light Weight Process)

    오라클 레퍼런스를 참조해서 작성된 글이다. 먼저 그림에 있는 용어를 정리해보자.

    • JLTs : Java Application Level Thread. 자바에서 사용되는 프로세스.
    • KLTs : Kernel Level Thread. OS가 관리하는 쓰레드.
    • LWP : Light Weight Process. JLTs - KLTs의 가교 역할을 함. 

    대략적인 용어는 다음과 같이 정리할 수 있다. 중요한 것은 LWP인데, 어떤 역할을 하는지 정리하면 다음과 같다. 

    • LWP는 OS에게 KLTs 관리를 부탁함.
    • LWP는 Java에서 요청한 Java Thread를 Kernel Thread에 맵핑함. 
    • LWP는 커널 - 유저 레벨 스레드 사이에서 일종의 인터페이스 역할도 함. 
      • Java에서 thread.start()를 하는 것이 OS에 전달되도록.

    LWP는 어떻게 구현되고, 어떻게 동작할까? LWP는 c++의 Solaris Thread Library로 구현되어있다. Java 소스 코드를 실행하면 JNI(Java Native Interace)를 통해 Solaris Thread Library가 실행되어 LWP가 인터페이스 하도록 되어있다. 

     


    Step2. LWP 매핑 시뮬레이션

    Java Thread를 사용할 때, start() / run() / join() 같은 메서드가 존재한다. 이 메서드는 OS에 종속적인데, OS에 system call을 보내서 OS에 해당 기능을 요청해야 한다. 자바 쓰레드가 start()라는 자바 코드를 실행하면 JNI를 실행시키고, JNI가 JVM과 C++ Library를 통해서 OS에게 시스템 콜을 요청하는 형태가 된다.

    자바 쓰레드가 커널 스레드로 바인딩 되기까지를 간단하게 구현한 프로젝트가 있다. 이 프로젝트의 을 읽고, 실행해보면 아래 결과가 나온다. 아래 실행 결과를 살펴보면 각각 다음과 같다.

    • Starting thread_entry_point : Java가 C++을 통해서 OS에게 스레드 생성 요청 시도.
    • Started a linux thread : OS 스레드가 생성 완료됨. 
    • Running Thread : 자바 쓰레드 객체가 run() (자바 코드)를 실행했을 때 발생하는 출력. 
    더보기
    [info] Loading settings from build.sbt ...
    [info] Set current project to threading (in build file:/threading/)
    [info] Executing in batch mode. For better performance use sbt's shell
    [success] Total time: 1 s, completed Jan 1, 2018 3:39:01 AM
    [success] Total time: 1 s, completed Jan 1, 2018 3:39:02 AM
    [info] Running (fork) com.threading.ThreadingApp 
    [info] Started a linux thread Starting thread_entry_point 140531437143808!
    [info] Started a linux thread 140531428751104!
    [info] Starting  thread_entry_point Started a linux thread 140531420358400!
    [info] Starting  thread_entry_point Started a linux thread 140531411965696!
    [info] Starting  thread_entry_point Started a linux thread 140530323289856!
    [info] Running Thread 1
    [info] Running Thread 3
    [info] Starting  thread_entry_point Started a linux thread 140530314897152!
    [info] Starting  thread_entry_point Started a linux thread 140531437143808!
    [info] Running Thread 2
    [info] Running Thread 4
    [info] Starting  thread_entry_point Started a linux thread 140530306504448!
    [info] Started a linux thread 140531428751104!
    [info] Starting  thread_entry_point Starting  thread_entry_point Started a linux thread 140530298111744!
    [info] Running Thread 5
    [info] Started a linux thread 140530314897152!
    [info] Started a linux thread 140531411965696!
    [info] Started a linux thread 140530289719040!
    [info] Started a linux thread 140530281326336!
    [info] Started a linux thread 140530272933632!
    [info] Started a linux thread 140529987745536!
    [info] Started a linux thread 140529979352832!
    [info] Started a linux thread 140529970960128!
    [info] Started a linux thread 140529962567424!
    [info] Running Thread 9
    [info] Running Thread 8
    [info] Running Thread 7
    [info] Running Thread 6
    [info] Running Thread 10
    [info] Running Thread 15
    [info] Running Thread 16
    [info] Running Thread 13
    [info] Running Thread 14
    [info] Running Thread 12
    [info] Running Thread 11
    [info] Running Thread 17
    [info] Running Thread 19
    [info] Running Thread 18

    위에서는 OS 스레드는 15개, Java 스레드는 19개가 생성된다. 솔라리스 OS 기반에서는 Many to Many로 유저 레벨 스레드 - OS 레벨 스레드가 맵핑되기 때문에 이렇게 동작한다. 반면 Window / Linux에서는 One to One으로 맵핑된다고 한다.


    Step3. OpenJDK 커널 코드 분석

    위 그림에서 Native Thread가 있다. Native Thread는 C++의 Thread 객체가 OS 레벨 쓰레드를 소유하게 된 상태를 의미한다. 

     

    C++ 스레드 상속 구조

    C++의 스레드 상속 구조는 아래와 같다. JavaThread / NonJavaThread가 존재하는 것을 볼 수 있다.

    더보기
    // thread.hpp:96
    // Class hierarchy
    // - Thread
    //   - JavaThread
    //     - various subclasses eg CompilerThread, ServiceThread
    //   - NonJavaThread
    //     - NamedThread
    //       - VMThread
    //       - ConcurrentGCThread
    //       - WorkerThread
    //     - WatcherThread
    //     - JfrThreadSampler
    //     - LogAsyncWriter
    //
    // All Thread subclasses must be either JavaThread or NonJavaThread.
    // This means !t->is_Java_thread() iff t is a NonJavaThread, or t is
    // a partially constructed/destroyed Thread.

     

    스레드 실행 흐름 

    최초로 thread_native_entry() 메서드가 호출된 후 초기화 후, 적절히 바인딩 되는 것을 볼 수 있다. (사실 잘 이해 못함)

    더보기
    // thread.hpp:113
    // Thread execution sequence and actions:
    // All threads:
    //  - thread_native_entry  // per-OS native entry point
    //    - stack initialization
    //    - other OS-level initialization (signal masks etc)
    //    - handshake with creating thread (if not started suspended)
    //    - this->call_run()  // common shared entry point
    //      - shared common initialization
    //      - this->pre_run()  // virtual per-thread-type initialization
    //      - this->run()      // virtual per-thread-type "main" logic
    //      - shared common tear-down
    //      - this->post_run()  // virtual per-thread-type tear-down
    //      - // 'this' no longer referenceable
    //    - OS-level tear-down (minimal)
    //    - final logging
    //
    // For JavaThread:
    //   - this->run()  // virtual but not normally overridden
    //     - this->thread_main_inner()  // extra call level to ensure correct stack calculations
    //       - this->entry_point()  // set differently for each kind of JavaThread

     

    C++ 스레드 종류 및 우선 순위

    다음과 같은 쓰레드가 지원된다. 

    더보기
    // os.hpp:70
    enum ThreadPriority {        // JLS 20.20.1-3
      NoPriority       = -1,     // Initial non-priority value
      MinPriority      =  1,     // Minimum priority
      NormPriority     =  5,     // Normal (non-daemon) priority
      NearMaxPriority  =  9,     // High priority, used for VMThread
      MaxPriority      = 10,     // Highest priority, used for WatcherThread
                                 // ensures that VMThread doesn't starve profiler
      CriticalPriority = 11      // Critical thread priority
    };
    
    // os.hpp:455
    enum ThreadType {
        vm_thread,
        gc_thread,         // GC thread
        java_thread,       // Java, CodeCacheSweeper, JVMTIAgent and Service threads.
        compiler_thread,
        watcher_thread,
        asynclog_thread,   // dedicated to flushing logs
        os_thread
      };

     

    C++의 Java Thread 클래스

    JavaThread 클래스는 Thread 클래스에게 상속받은 os_thread_list가 하나 있고, _threadObj라는 Java 레벨의 스레드가 있다. JavaThread 클래스는 실제 자바의 쓰레드와 커널 쓰레드를 연결하는 인스턴스다.

    더보기
    // thread.hpp:677
    class JavaThread: public Thread {
      friend class VMStructs;
      friend class JVMCIVMStructs;
      friend class WhiteBox;
      friend class ThreadsSMRSupport; // to access _threadObj for exiting_threads_oops_do
      friend class HandshakeState;
     private:
      bool           _on_thread_list;                // Is set when this JavaThread is added to the Threads list
      OopHandle      _threadObj;                     // The Java level thread object
      
      /* ...생략... */
    }

     

     

    Thread.start()  in Java

    Java 코드에서 thread.start()가 호출되면 아래 코드에서 처럼 JNINativeMathod를 통해 JVM_startThread 메서드가 호출된다.

    private native void start0();

    가장 중요한 흐름은 다음과 같다. 

    1. Solaris Library Thread 생성(솔라리스 OS 경우, c++ Javathread 클래스 인스턴스)
    2. Kernel Thread 생성
    3. Solaris Library Thread와 Kenerl Thread를 연결해서 Native Thread 완성
    4. Native Thread와 JavaApplication Level의 Thread를 연결 (binding) 
    5. 완성된 스레드를 쓰레드큐에 추가
    6. Java Application Level Thread가 원하는 코드를 native Thread 에게 실행시킴 (Thread.run() in java cod)

    아래에는 Native Code의 실행 과정을 살펴볼 수 있다. 

     

    JNI Native Methods

    더보기

     

    // java.base/share/native/libjava/Thread.c
    
    static JNINativeMethod methods[] = {
        {"start0",           "()V",        (void *)&JVM_StartThread},
        {"stop0",            "(" OBJ ")V", (void *)&JVM_StopThread},
        {"isAlive",          "()Z",        (void *)&JVM_IsThreadAlive},
        {"suspend0",         "()V",        (void *)&JVM_SuspendThread},
        {"resume0",          "()V",        (void *)&JVM_ResumeThread},
        {"setPriority0",     "(I)V",       (void *)&JVM_SetThreadPriority},
        {"yield",            "()V",        (void *)&JVM_Yield},
        {"sleep",            "(J)V",       (void *)&JVM_Sleep},
        {"currentThread",    "()" THD,     (void *)&JVM_CurrentThread},
        {"interrupt0",       "()V",        (void *)&JVM_Interrupt},
        {"holdsLock",        "(" OBJ ")Z", (void *)&JVM_HoldsLock},
        {"getThreads",        "()[" THD,   (void *)&JVM_GetAllThreads},
        {"dumpThreads",      "([" THD ")[[" STE, (void *)&JVM_DumpThreads},
        {"setNativeName",    "(" STR ")V", (void *)&JVM_SetNativeThreadName},
    };
    
    #undef THD
    #undef OBJ
    #undef STE
    #undef STR
    
    JNIEXPORT void JNICALL
    Java_java_lang_Thread_registerNatives(JNIEnv *env, jclass cls)
    {
        (*env)->RegisterNatives(env, cls, methods, ARRAY_LENGTH(methods));
    }

     

    Native Thread 생성

    자식 스레드는 waiting 후 부모에게 허락 singal 받으면 run() 수행

    더보기
    //prims/jvm.cpp:2850
    JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
      
      JavaThread *native_thread = NULL;
    
      {
        MutexLocker mu(Threads_lock);
    
        // javaThread가 이전에 start상태였으면 생성 불가!
        if (java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
          throw_illegal_thread_state = true;
        } else {
         
          // c++ thread와 os thread를 연결해서 native_thread 생성
          // 부모는 자식스레드(Java와 연결될 os 커널스레드)를 생성하고 나옴. (INITIALIZED 상태가 됨)
          // 자식스레드(os 스레드)는 부모 스레드가 run() 실행을 허락하기 전까지 대기.
          native_thread = new JavaThread(&thread_entry, sz);  // <-- 중요!
          
    
          if (native_thread->osthread() != NULL) {
            // 자식 스레드를 실행시키기 전에 꼭 처리되어야 하는 전처리 작업 -> 조건동기화가 필요!
            // native thread를 java thread와 연결, 스레드 리스트에 추가
            native_thread->prepare(jthread);
          }
        }
      }
    
        /* ...생략... */
    
      Thread::start(native_thread);  // 자식 스레드가 run()을 수행하도록 허락
    
    JVM_END

     

    new JavaThread()

    부모 스레드는 대기하고 있는 자식 스레드 (OS Thread, JLT와 Binding 될 스레드)를 깨워서 run을 수행하게끔 한다.

    더보기
    // new JavaThread -> create_thread -> pthread_create(..., thread_native_entry, ...)
    
    // os_linux.cpp:660
    // Thread start routine for all newly created threads
    static void *thread_native_entry(Thread *thread) {
      
      /* ...생략... */
      
      // handshaking with parent thread
      {
        MutexLocker ml(sync, Mutex::_no_safepoint_check_flag);
    
        // notify parent thread
        osthread->set_state(INITIALIZED);
        sync->notify_all();
    
        // wait until os::start_thread()
        // 여기서 멈춰있음, 부모가 바인딩과 같은 전처리 작업을 수행 후 RUNNABLE 상태로 변경시켜주면 진행
        while (osthread->get_state() == INITIALIZED) {  
          sync->wait_without_safepoint_check();
        }
      }
      
      // call one more level start routine
      thread->run()
    
      /* ...생략... */
    }

     

    Thread::start(Thread* thread)

    여기서 자식 쓰레드(OS Thread)의 상태를 변경함. 

    더보기
    //thread.cpp:518
    void Thread::start(Thread* thread) {
      if (thread->is_Java_thread()) {
        java_lang_Thread::set_thread_status(JavaThread::cast(thread)->threadObj(),
                                            JavaThreadStatus::RUNNABLE);
      }
      os::start_thread(thread);
    }
    
    //os.cpp:873
    void os::start_thread(Thread* thread) {
      // guard suspend/resume
      MutexLocker ml(thread->SR_lock(), Mutex::_no_safepoint_check_flag);
      OSThread* osthread = thread->osthread();
      osthread->set_state(RUNNABLE); // 여기서 자식의 상태를 변경하므로 자식스레드가 대기상태에서 벗어남
      pd_start_thread(thread); 
    }

     

    자바 스레드 공부 요약

    다시 한번 이 블로그에서 요약해주신 내용이 많이 도움되었다고 작성하고 싶다. 요약하면 다음과 같다.

    • Java의 초창기에는 Green Thread라고 해서 User Level Thread 모드로만 동작하던 시절이 있었다.
    • 현재는 OS마다 다르긴 하지만, 자바에서 User Level 쓰레드가 생성되면 동일한 수의 커널 레벨 쓰레드가 생성되어 One to One Bind가 됨. 
    • JNI는 자바의 유저 레벨 쓰레드 ↔ 커널 레벨 쓰레드를 바인딩 해주는 역할을 한다.
    • 현재 자바의 쓰레드는 OS 레벨에서 스케쥴링 된다. 

     

     

    Java의 쓰레드

    자바의 쓰레드는 

     

     

     

    대부분 유저 레벨 쓰레드 / 커널 레벨 쓰레드가 1대1 맵핑됨.

    그러나 JDK 구현에 따라서 달라질 수 있음. JNI의 라이브러리가 어떻게 맵핑을 하느냐에 따라 다른 듯 

     

     

     

    현재 자바에서는 실행될 때는 JVM이 쓰레드 스케쥴링을 하지 않고 OS에게 스케쥴링을 맡겨 OS의 스케쥴링 정책을 따른다.

    그래서 자바의 쓰레드는 커널의 쓰레드의 1:1 매핑하여 동작해서 실제로 병렬성을 만족한다.

     

     

     

     

     

     

     

     

    참고

    댓글

    Designed by JB FACTORY