profile image

L o a d i n g . . .

article thumbnail image
Published 2022. 12. 17. 17:35
728x90

Java thread는 OS thread에 1:1로 매핑된다는 특성 덕에 다른 언어에서 사용하는 thread에 비해 무겁다는 명성을 가지고 있습니다. OS thread의 크기는 기본적으로 MB 단위이므로 대규모로 thread를 생성해서 사용 시 메모리에 큰 부하를 줄 수 있습니다. 단, 곧 등장할 java의 virtual thread는 OS thread와 N:1 구조로 매핑되는 user level thread이므로 기존의 java thread와 비교하면 훨씬 가볍습니다. 하지만 아직 virtual thread가 프로덕션에서 활용된 사례가 많지 않기 때문에 당분간은 여전히 java thread를 자주 사용할 것이고, 그렇기 때문에 java thread와 OS thread의 관계에 대해 잘 이해해야 합니다. 

 

이번 포스팅을 통해 java thread가 OS thread에 어떻게 매핑되는지, 그리고 virtual thread에 대해서 간략하게 살펴보겠습니다. 

Thread.java 

생성자 

Java에서 새로운 thread를 생성하는 방법은 Thread 생성자를 호출하고 인자로 Runnable을 넘겨주는 방식입니다. 소스코드는 다음과 같습니다. 

Java thread constructor

위 생성자는 내부 생성자들을 연쇄적으로 호출합니다. 최종적으로 호출하는 생성자의 코드는 다음과 같습니다. 

 private Thread(ThreadGroup g, Runnable target, String name,
                   long stackSize, AccessControlContext acc,
                   boolean inheritThreadLocals) {
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }

        this.name = name;

        Thread parent = currentThread();
        SecurityManager security = System.getSecurityManager();
        if (g == null) {
            /* Determine if it's an applet or not */

            /* If there is a security manager, ask the security manager
               what to do. */
            if (security != null) {
                g = security.getThreadGroup();
            }

            /* If the security manager doesn't have a strong opinion
               on the matter, use the parent thread group. */
            if (g == null) {
                g = parent.getThreadGroup();
            }
        }

        /* checkAccess regardless of whether or not threadgroup is
           explicitly passed in. */
        g.checkAccess();

        /*
         * Do we have the required permissions?
         */
        if (security != null) {
            if (isCCLOverridden(getClass())) {
                security.checkPermission(
                        SecurityConstants.SUBCLASS_IMPLEMENTATION_PERMISSION);
            }
        }

        g.addUnstarted();

        this.group = g;
        this.daemon = parent.isDaemon();
        this.priority = parent.getPriority();
        if (security == null || isCCLOverridden(parent.getClass()))
            this.contextClassLoader = parent.getContextClassLoader();
        else
            this.contextClassLoader = parent.contextClassLoader;
        this.inheritedAccessControlContext =
                acc != null ? acc : AccessController.getContext();
        this.target = target;
        setPriority(priority);
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        /* Stash the specified stack size in case the VM cares */
        this.stackSize = stackSize;

        /* Set thread ID */
        this.tid = nextThreadID();
    }

Start 

Thread의 생성자를 호출한 이후 저희는 thread.start() 메서드를 통해 해당 스레드를 실행시킬 수 있습니다. 해당 소스코드를 살펴보겠습니다.

Thread start method

Thread의 start 메서드는 start0이라는 native method를 실행시킵니다. Native method의 구현체는 다음과 같습니다. 

 

GitHub - openjdk/jdk: JDK main-line development https://openjdk.org/projects/jdk

JDK main-line development https://openjdk.org/projects/jdk - GitHub - openjdk/jdk: JDK main-line development https://openjdk.org/projects/jdk

github.com

start0 native method

Java native method의 start0 메서드는 JVM_StartThread 함수와 관련이 있으므로 해당 함수의 소스코드를 확인하면 java thread와 native OS thread가 어떻게 매핑되는지 확인할 수 있습니다. 

 

GitHub - openjdk/jdk: JDK main-line development https://openjdk.org/projects/jdk

JDK main-line development https://openjdk.org/projects/jdk - GitHub - openjdk/jdk: JDK main-line development https://openjdk.org/projects/jdk

github.com

JVM_StartThread 함수의 전체 코드는 다음과 같습니다. 

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
#if INCLUDE_CDS
  if (DumpSharedSpaces) {
    // During java -Xshare:dump, if we allow multiple Java threads to
    // execute in parallel, symbols and classes may be loaded in
    // random orders which will make the resulting CDS archive
    // non-deterministic.
    //
    // Lucikly, during java -Xshare:dump, it's important to run only
    // the code in the main Java thread (which is NOT started here) that
    // creates the module graph, etc. It's safe to not start the other
    // threads which are launched by class static initializers
    // (ReferenceHandler, FinalizerThread and CleanerImpl).
    if (log_is_enabled(Info, cds)) {
      ResourceMark rm;
      oop t = JNIHandles::resolve_non_null(jthread);
      log_info(cds)("JVM_StartThread() ignored: %s", t->klass()->external_name());
    }
    return;
  }
#endif
  JavaThread *native_thread = NULL;

  // We cannot hold the Threads_lock when we throw an exception,
  // due to rank ordering issues. Example:  we might need to grab the
  // Heap_lock while we construct the exception.
  bool throw_illegal_thread_state = false;

  // We must release the Threads_lock before we can post a jvmti event
  // in Thread::start.
  {
    // Ensure that the C++ Thread and OSThread structures aren't freed before
    // we operate.
    MutexLocker mu(Threads_lock);

    // Since JDK 5 the java.lang.Thread threadStatus is used to prevent
    // re-starting an already started thread, so we should usually find
    // that the JavaThread is null. However for a JNI attached thread
    // there is a small window between the Thread object being created
    // (with its JavaThread set) and the update to its threadStatus, so we
    // have to check for this
    if (java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
      throw_illegal_thread_state = true;
    } else {
      jlong size =
             java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
      // Allocate the C++ Thread structure and create the native thread.  The
      // stack size retrieved from java is 64-bit signed, but the constructor takes
      // size_t (an unsigned type), which may be 32 or 64-bit depending on the platform.
      //  - Avoid truncating on 32-bit platforms if size is greater than UINT_MAX.
      //  - Avoid passing negative values which would result in really large stacks.
      NOT_LP64(if (size > SIZE_MAX) size = SIZE_MAX;)
      size_t sz = size > 0 ? (size_t) size : 0;
      native_thread = new JavaThread(&thread_entry, sz);

      // At this point it may be possible that no osthread was created for the
      // JavaThread due to lack of memory. Check for this situation and throw
      // an exception if necessary. Eventually we may want to change this so
      // that we only grab the lock if the thread was created successfully -
      // then we can also do this check and throw the exception in the
      // JavaThread constructor.
      if (native_thread->osthread() != NULL) {
        // Note: the current thread is not being used within "prepare".
        native_thread->prepare(jthread);
      }
    }
  }

  if (throw_illegal_thread_state) {
    THROW(vmSymbols::java_lang_IllegalThreadStateException());
  }

  assert(native_thread != NULL, "Starting null thread?");

  if (native_thread->osthread() == NULL) {
    ResourceMark rm(thread);
    log_warning(os, thread)("Failed to start the native thread for java.lang.Thread \"%s\"",
                            JavaThread::name_for(JNIHandles::resolve_non_null(jthread)));
    // No one should hold a reference to the 'native_thread'.
    native_thread->smr_delete();
    if (JvmtiExport::should_post_resource_exhausted()) {
      JvmtiExport::post_resource_exhausted(
        JVMTI_RESOURCE_EXHAUSTED_OOM_ERROR | JVMTI_RESOURCE_EXHAUSTED_THREADS,
        os::native_thread_creation_failed_msg());
    }
    THROW_MSG(vmSymbols::java_lang_OutOfMemoryError(),
              os::native_thread_creation_failed_msg());
  }

  JFR_ONLY(Jfr::on_java_thread_start(thread, native_thread);)

  Thread::start(native_thread);

JVM_END

해당 코드를 나눠서 분석해 보겠습니다. 

1. DumpSharedSpaces 

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
#if INCLUDE_CDS
  if (DumpSharedSpaces) {
    // During java -Xshare:dump, if we allow multiple Java threads to
    // execute in parallel, symbols and classes may be loaded in
    // random orders which will make the resulting CDS archive
    // non-deterministic.
    //
    // Lucikly, during java -Xshare:dump, it's important to run only
    // the code in the main Java thread (which is NOT started here) that
    // creates the module graph, etc. It's safe to not start the other
    // threads which are launched by class static initializers
    // (ReferenceHandler, FinalizerThread and CleanerImpl).
    if (log_is_enabled(Info, cds)) {
      ResourceMark rm;
      oop t = JNIHandles::resolve_non_null(jthread);
      log_info(cds)("JVM_StartThread() ignored: %s", t->klass()->external_name());
    }
    return;
  }

위 코드는 DumpSharedSpaces 변수를 확인하고 만약 해당 flag가 참인 경우 로그만 남기고 새로운 스레드는 생성하지 않습니다. 

2. OS 스레드 생성 

#endif
  JavaThread *native_thread = NULL;

  // We cannot hold the Threads_lock when we throw an exception,
  // due to rank ordering issues. Example:  we might need to grab the
  // Heap_lock while we construct the exception.
  bool throw_illegal_thread_state = false;

  // We must release the Threads_lock before we can post a jvmti event
  // in Thread::start.
  {
    // Ensure that the C++ Thread and OSThread structures aren't freed before
    // we operate.
    MutexLocker mu(Threads_lock);

    // Since JDK 5 the java.lang.Thread threadStatus is used to prevent
    // re-starting an already started thread, so we should usually find
    // that the JavaThread is null. However for a JNI attached thread
    // there is a small window between the Thread object being created
    // (with its JavaThread set) and the update to its threadStatus, so we
    // have to check for this
    if (java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
      throw_illegal_thread_state = true;
    } else {
      jlong size =
             java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
      // Allocate the C++ Thread structure and create the native thread.  The
      // stack size retrieved from java is 64-bit signed, but the constructor takes
      // size_t (an unsigned type), which may be 32 or 64-bit depending on the platform.
      //  - Avoid truncating on 32-bit platforms if size is greater than UINT_MAX.
      //  - Avoid passing negative values which would result in really large stacks.
      NOT_LP64(if (size > SIZE_MAX) size = SIZE_MAX;)
      size_t sz = size > 0 ? (size_t) size : 0;
      native_thread = new JavaThread(&thread_entry, sz);

      // At this point it may be possible that no osthread was created for the
      // JavaThread due to lack of memory. Check for this situation and throw
      // an exception if necessary. Eventually we may want to change this so
      // that we only grab the lock if the thread was created successfully -
      // then we can also do this check and throw the exception in the
      // JavaThread constructor.
      if (native_thread->osthread() != NULL) {
        // Note: the current thread is not being used within "prepare".
        native_thread->prepare(jthread);
      }
    }
  }

  if (throw_illegal_thread_state) {
    THROW(vmSymbols::java_lang_IllegalThreadStateException());
  }

  assert(native_thread != NULL, "Starting null thread?");

  if (native_thread->osthread() == NULL) {
    ResourceMark rm(thread);
    log_warning(os, thread)("Failed to start the native thread for java.lang.Thread \"%s\"",
                            JavaThread::name_for(JNIHandles::resolve_non_null(jthread)));
    // No one should hold a reference to the 'native_thread'.
    native_thread->smr_delete();
    if (JvmtiExport::should_post_resource_exhausted()) {
      JvmtiExport::post_resource_exhausted(
        JVMTI_RESOURCE_EXHAUSTED_OOM_ERROR | JVMTI_RESOURCE_EXHAUSTED_THREADS,
        os::native_thread_creation_failed_msg());
    }
    THROW_MSG(vmSymbols::java_lang_OutOfMemoryError(),
              os::native_thread_creation_failed_msg());
  }

  JFR_ONLY(Jfr::on_java_thread_start(thread, native_thread);)

  Thread::start(native_thread);

JVM_END

위 코드는 다음과 같은 순서로 동작합니다. 

  1. native_thread, thread_illegal_thread_state 변수를  초기화 
  2. Thread_lock mutex를 획득 
  3. Thread의 threadStatus를 확인 -> 해당 스레드가 이미 시작됐는지 확인하는 과정. 만약 스레드가 이미 시작됐다면 throw_illegal_thread_state에 true값 할당 
  4. 스레드가 이미 시작됐지 않았다면 다음과 같이 동작 
    • 스레드에 할당할 stack size를 계산 
    • JavaThread 객체를 생성 
    • JavaThread 객체가 성공적으로 생성됐다면 prepare 함수를 호출 
    • prepare 함수는 JavaThread를 초기화하고 native_thread가 JavaThread 객체를 가리키도록 설정 
  5. throw_illegal_thread_state가 true인 경우 exception 발생 
  6. native_thread -> osthread 가 NULL 인 경우 native thread가 생성되지 않았음을 의미 -> 에러 메시지 생성 
  7. native_thread가 성공적으로 생성된 경우 Thread::start 함수를 통해 스레드 실행 
    • Thread::start는 OS thread를 생성하고 실행하는 함수입니다. 

Other threading models 

위에서 확인한 것처럼, Java 스레드는 OS 스레드와 1:1로 매핑됩니다. 그리고 스레드를 생성하는 과정에서 시스템 콜이 호출되어 비용이 증가합니다. 그러나 Go 언어에서는 go 스레드(goroutine)를 OS 스레드와 N:1로 매핑함으로써 java와 비교해서 더욱 가벼운 스레딩 방식을 제공합니다. Go는 자체적인 스케쥴링을 통해 goroutine을 OS thread에서 실행시킵니다. 

https://medium.com/@genchilu/javas-thread-model-and-golang-goroutine-f1325ca2df0c

Java 21부터 정식 기능으로 사용할 수 있는 virtual thread도 위와 유사한 방식으로 동작합니다. 그럼 virtual thread가 어떻게 동작하는지 살펴보겠습니다. 

 

Java Virtual Thread 

Java virtual thread는 JDK 19부터 preview feature로 추가됐습니다. Project Loom에서 개발된 기능으로 기존의 java thread의 한계(OS thread를 wrapping한 무거운 thread, 비싼 context switching)를 개선할 수 있습니다. Virtual thread의 동작 방식을 도식화하면 아래와 같습니다. 

Virtual thread

Virtual thread가 도입되면서 기존 OS와 1:1로 대응하던 java thread는 carrier thread로 명칭이 변경됐습니다. 각 virtual thread는 carrier thread의 queue를 활용하여 스케쥴링됩니다. ForkJoinPool에 생성되는 carrier thread의 수는 기본적으로 CPU 코어의 수만큼 생성됩니다. 

 

VirtualThread.java  

VirtualThread는 기존 java thread와 다르게 OS에 의해 스케쥴링되는게 아닌, JVM에 의해 스케쥴링됩니다. 

VirtualThread javadoc

VirtualThread State 

VirtualThread는 일련의 state machine 처럼 동작합니다. Javadoc에서는 다음과 같이 virtualThread의 state를 설명합니다. 

/*
 * Virtual thread state and transitions:
 *
 *      NEW -> STARTED         // Thread.start
 *  STARTED -> TERMINATED      // failed to start
 *  STARTED -> RUNNING         // first run
 *
 *  RUNNING -> PARKING         // Thread attempts to park
 *  PARKING -> PARKED          // cont.yield successful, thread is parked
 *  PARKING -> PINNED          // cont.yield failed, thread is pinned
 *
 *   PARKED -> RUNNABLE        // unpark or interrupted
 *   PINNED -> RUNNABLE        // unpark or interrupted
 *
 * RUNNABLE -> RUNNING         // continue execution
 *
 *  RUNNING -> YIELDING        // Thread.yield
 * YIELDING -> RUNNABLE        // yield successful
 * YIELDING -> RUNNING         // yield failed
 *
 *  RUNNING -> TERMINATED      // done
 */

이를 도식화하면 다음과 같습니다. 

마무리 

이번 포스팅을 통해 Java thread를 사용하는게 왜 비용이 높은지, 그리고 이러한 제약을 극복할 수 있는 Java virtual thread의 동작 방식을 알아보았습니다. Java virtual thread 등장 이전에는 Java threading model의 한계를 극복하기 위해 여러 시도가 있었습니다. 대표적인 예로 Reactive 프로그래밍이 있습니다. Netty와 Spring Webflux는 Reactive 프로그래밍을 통해 스레드 생성과 전환 비용을 최소화하여 Java threading model의 한계를 극복했습니다. 그러나 Reactive 프로그래밍은 프로그램 작성의 난이도가 높아지고 스택 트레이스 추적 및 디버깅의 어렵다는 한계가 있습니다.

Java virtual thread의 등장으로 기존 java threading model의 한계와 reactive 프로그래밍으로 인한 복잡성을 어느 정도 극복할 수 있을 것으로 기대됩니다. 이를 통해 java 개발자들은 비즈니스 로직에 더 많이 집중할 수 있고 virtual thread를 통해 더 다양한 시도를 할 수 있을것으로 예상됩니다. 

728x90
복사했습니다!