Skip to main content

Virtual Thread의 기본 개념 이해하기

About 7 minJavaSpringC++Java Native InterfaceArticle(s)blogd2.naver.comjavaspringcppc++jnivirtual-tread

Virtual Thread의 기본 개념 이해하기 관련

Spring > Article(s)

Article(s)

Virtual Thread의 기본 개념 이해하기 | NAVER D2
Virtual Thread의 기본 개념 이해하기

JDK에 정식 도입된 Virtual Thread는 기존의 KLT(kernel-level thread)와 ULT(user-level thread)를 1:1 매핑하여 사용하는 JVM의 스레드 모델을 개선한, 여러 개의 가상 스레드를 하나의 네이티브 스레드에 할당하여 사용하는 모델입니다. 이 글에서는 Virtual Thread가 기존 스레드 모델과 어떤 점이 다른지 알아보겠습니다.

JNI

Java Native Interface(이하 JNI)는 C, C++처럼 인터프리터 없이 OS가 바로 읽을 수 있는 형태의 네이티브 코드를 JVM이 호출할 수 있게 하는 인터페이스다. 쉽게 말해, JVM에서 다른 언어를 사용할 수 있게 한다. 이 JNI 덕분에 Java가 머신 플랫폼에 상관없이 동작할 수 있다. 이 호출은 Java에서 메서드 앞에 native 키워드를 붙여 해당 메서드가 JNI를 사용함을 나타낸다.

직접 사용하면서 이해해 보자(macOS 기준).

hoyoungjni라는 라이브러를 동적으로 읽어오게 하고, hyNativeMethod의 메서드는 JNI를 사용하도록 선언한다.

package org.example;

public class HoyoungJNI {  
  public HoyoungJNI() {
  }

  private native void hyNativeMethod();

  public static void main(String[] var0) throws Exception {
    HoyoungJNI var1 = new HoyoungJNI();
    var1.hyNativeMethod();
  }

  static {
    System.loadLibrary("hoyoungjni");
  }
}

hyNativeMethod를 구현해 보자. 헤더 파일을 만든다.

javac HoyoungJNI.java
javah -classpath ${경로} org.example.HoyoungJNI
#include <jni.h>

#ifndef _Included_org_example_HoyoungJNI
#define _Included_org_example_HoyoungJNI
#ifdef __cplusplus
extern "C" {  
#endif

JNIEXPORT void JNICALL Java_org_example_HoyoungJNI_hyNativeMethod  
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

이를 구현한다.

#include <jni.h>
#include "org_example_HoyoungJNI.h"

JNIEXPORT void JNICALL Java_org_example_HoyoungJNI_hyNativeMethod(JNIEnv *env, jobject obj) {  
       printf("JNI는 이렇게 동작해요");
}

컴파일한다.

gcc -I$JAVA_HOME/include -I$JAVA_HOME/include/darwin -I"${만든javah헤더파일경로}" -shared -m64  ${경로}/HoyoungJNI.c -o libhoyoungjni.dylib  
#
# The filename of a dynamic library normally contains the library’s name with the lib prefix and the .dylib extension

.Dynamic Library Design Guidelinesopen in new window의 다음 규약에 따라 앞의 lib와 뒤의 .dylib를 제외한 hoyoungjni가 라이브러리 이름으로 인식된다.

공유 라이브러리 파일을 만들었으니, 환경변수를 주입하여 실행해 보자.

# JNI는 이렇게 동작해요
java -Djava.library.path=${dylib파일경로}  -classpath ${classpath} org.example.HoyoungJNI

즉, 이렇게 JVM은 JNI를 사용하여 별도 인터프리터 없이 C로 작성된 코드를 실행한다.

Java 스레드

Java는 java.util.concurrent.ExecutorService를 두어 JVM 내부에서 스레드를 관리/실행한다. 여러 ExecutorServiceThreadPoolExecutor로 실제 스레드가 실행되는 부분만 간단히 살펴보자.

ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
  10, 10, 0L, TimeUnit.MILLISECONDS,
  new LinkedBlockingQueue<Runnable>()
);
threadPoolExecutor.submit(() -> {});

submit을 하면 무슨 일이 일어나는 걸까?

다음은 ThreadPoolExecutorexecute 함수의 일부이다.

/**
 * java.util.concurrent.ThreadPoolExecutor.java
 */
public void execute(Runnable command) {
  /* ... 생략 ... */
  int c = ctl.get();                           // 현재 RUNNUNG 상태인 스레드 수를 가져오고
  if (workerCountOf(c) < corePoolSize) {
    if (addWorker(command, true))            // 풀 수보다 작으면 워커에 추가한다.
      return;
    c = ctl.get();
  }
  /* ... 생략 ... */
}

private boolean addWorker(Runnable firstTask, boolean core) {
  // ThreadPoolExecutor가 실행해도 된다고 판단하면
  if (workerAdded) {
    container.start(t);      // 실행한다.
    workerStarted = true;
  }
}

워커에 추가된 스레드는 결국 Thread.start를 실행한다.

/**
 * java.lang.Thread.java
 */
private native void start0();        // 실제 실행은 결국 JNI를 통한다.

public void start() {
    synchronized (this) {
        // zero status corresponds to state "NEW".
        if (holder.threadStatus != 0)
          throw new IllegalThreadStateException();
        start0();
    }
}

즉, ExecutorService의 스케줄링 정책에 따라 JNI로 스레드를 실행하는 방식이다.

JDK 21을 기준으로 살펴보자.

(openjdk/jdk21 - /src/java.base/share/native/libjava/Thread.copen in new window)

static JNINativeMethod methods[] = {  
  {"start0",           "()V",        (void *)&JVM_StartThread},
  {"setPriority0",     "(I)V",       (void *)&JVM_SetThreadPriority},
  /* ... 생략 ... */
}

start0JVM_StartThread 메서드이고 JavaThread를 생성한다.

(openjdk/jdk21 - /src/hotspot/share/prims/jvm.cppopen in new window)

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))  
  /* ... 생략 ... */
  native_thread = new JavaThread(&thread_entry, sz);
  /* ... 생략 ... */
JVM_END

JavaThreadThread의 하위 클래스이다.

(openjdk/jdk21 - src/hotspot/share/runtime/javaThread.hppopen in new window)

class JavaThread: public Thread {  
  friend class VMStructs;
  friend class JVMCIVMStructs;
  friend class WhiteBox;

결국, Java 단의 ExecutorService를 통해 스케줄링되는 여러 java.lang.Thread 객체는 JVM에 존재하는 start0 함수를 JNI를 통해 호출하고, 각 머신 OS에 맞게 설치된 JVM은 커널 스레드를 만들어 실행한다. 이러한 네이티브 메서드 호출은 JVM 내에서 스택과 분리되어 있는 네이티브 메서드 스택을 사용한다.

출처: https://usemynotes.com/what-is-jvm-jit/
출처: https://usemynotes.com/what-is-jvm-jit/

즉, 스케줄링은 Java에서, 실제 실행은 JNI를 통해 커널에서 실행된다. Java의 스레드 모델을 도식화하면 다음과 같다.

Heap에 존재하는 많은 ULT 중 하나가 JVM의 스케줄링에 따라 KLT에 매핑되어 실행하는 형태가 기존의 Java 스레드 모델이다.

virtualthread">Virtual Thread

기존의 Java 스레드를 알아보았으니 이제 JDK 21에 새로 도입된 Virtual Thread를 알아보자.

Virtual Thread concepts

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

Virtual Thread는 기존 KLT(1) : ULT(1)의 구조가 아닌 KLT(1) : ULT(1) : Virtual Thread(N)의 구조로 사용된다. KLT와 Virtual Thread 사이의 ULT는 플랫폼 스레드라고 한다.

위 그림과 같이 Heap에 수많은 Virtual Thread를 할당해놓고, 플랫폼 스레드에 대상 Virtual Thread를 마운트/언마운트하여 컨텍스트 스위칭을 수행한다. 따라서 컨텍스트 스위칭 비용이 작아질 수 밖에 없다.

스레드의 크기와 컨텍스트 스위칭 비용이 많이 감소한 모델이기 때문에 Spring MVC/Tomcat 등의 모델이 Netty/WebFlux에 비해 가진 단점이 많이 희석되었다.

Virtual Thread states

Virtual Thread에는 9개의 상태가 있다.

(openjdk/jdk21 - /src/java.base/share/classes/java/lang/VirtualThread.javaopen in new window)

/**
 * 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
 */
private static final int NEW      = 0;
private static final int STARTED  = 1;
private static final int RUNNABLE = 2;     // runnable-unmounted
private static final int RUNNING  = 3;     // runnable-mounted
private static final int PARKING  = 4;
private static final int PARKED   = 5;     // unmounted
private static final int PINNED   = 6;     // mounted
private static final int YIELDING = 7;     // Thread.yield
private static final int TERMINATED = 99;  // final state

다음과 같이 Virtual Thread의 상태에 따라 플랫폼 스레드에 마운트/언마운트해 실행을 관리한다.

플랫폼 스레드에 언마운트/마운트할 때에는 park/unpark 메서드를 사용한다.

(openjdk/jdk21 - /src/java.base/share/classes/java/lang/BaseVirtualThread.javaopen in new window)

sealed abstract class BaseVirtualThread extends Thread  
        permits VirtualThread, ThreadBuilders.BoundVirtualThread {

  /**
   * Initializes a virtual Thread.
   *
   * <span class="hljs-doctag">@param name thread name, can be null
   * <span class="hljs-doctag">@param characteristics thread characteristics
   * <span class="hljs-doctag">@param bound true when bound to an OS thread
   */
  BaseVirtualThread(String name, int characteristics, boolean bound) {
    super(name, characteristics, bound);
  }

  /**
   * Parks the current virtual thread until the parking permit is available or
   * the thread is interrupted.
   *
   * The behavior of this method when the current thread is not this thread
   * is not defined.
   */
  abstract void park();

  /**
   * Parks current virtual thread up to the given waiting time until the parking
   * permit is available or the thread is interrupted.
   *
   * The behavior of this method when the current thread is not this thread
   * is not defined.
   */
  abstract void parkNanos(long nanos);

  /**
   * Makes available the parking permit to the given this virtual thread.
   */
  abstract void unpark();
}

위 상태 그림처럼 Virtual Thread의 state를 변경시켜가며 상태를 관리한다.

(openjdk/jdk21 - /src/java.base/share/classes/java/lang/VirtualThread.javaopen in new window)

@Override
void park() {
  /* ... 생략 ... */
  // park on the carrier thread when pinned
  if (!yielded) {
    parkOnCarrierThread(false, 0);
  }
}

private void parkOnCarrierThread(boolean timed, long nanos) {
  assert state() == RUNNING;

  /* ... 생략 ... */
  setState(PINNED);  // RUNNING -> PINNED 로 전환
  /* ... 생략 ... */
}

플랫폼 스레드에 마운트하여 실행하는 unpark 메서드를 보자.

(openjdk/jdk21 - /src/java.base/share/classes/java/lang/VirtualThread.javaopen in new window)

void unpark() {
  /* ... 생략 ... */
  if (s == PARKED & compareAndSetState(PARKED, RUNNABLE)) {
    if (currentThread instanceof VirtualThread vthread) {
      vthread.switchToCarrierThread();
      try {
          submitRunContinuation();
      } finally {
          switchToVirtualThread(vthread);
      }
    } else {
      submitRunContinuation();
    }
  }
  /* ... 생략 ... */
}

private void submitRunContinuation() {
  try {
    scheduler.execute(runContinuation);
  } catch (RejectedExecutionException ree) {
    submitFailed(ree);
    throw ree;
  }
}

보다시피 scheduler로 실제 실행을 넘기며, schedulerForkJoinPool이다.

(openjdk/jdk21 - /src/java.base/share/classes/java/lang/VirtualThread.javaopen in new window)

private static ForkJoinPool createDefaultScheduler() {
  ForkJoinWorkerThreadFactory factory = pool -> {
    PrivilegedAction<ForkJoinWorkerThread> pa = () -> new CarrierThread(pool);
    return AccessController.doPrivileged(pa);
  };
  /* ... 생략 ... */
}

Virtual Thread는 플랫폼 스레드를 참조하고 있으며 이는 carrierThread라고 한다.

(openjdk/jdk21open in new window)

// carrier thread when mounted, accessed by VM
private volatile Thread carrierThread;

즉, JVM이 직접 접근하는 스레드는 플랫폼 스레드이며, 플랫폼 스레드에 마운트하여 실행하는 과정은 carrierThread에 실행 대상 Virtual Thread를 할당하는 방식이다.

(openjdk/jdk21open in new window)

private void mount() {
  /* ... 생략 ... */
  carrier.setCurrentThread(this);     // -> 플랫폼 스레드에 실행할 Virtual Thread 할당
  /* ... 생략 ... */
}

private void unmount() {
  Thread carrier = this.carrierThread;
  carrier.setCurrentThread(carrier);

  synchronized (interruptLock) {
    setCarrierThread(null);        // -> Virtual Thread에서 Virtual Thread 제거
  }
  carrier.clearInterrupt();
}

Virtual Thread는 플랫폼 스레드를 참조하고 있으며 실제 실행 시에는 플랫폼 스레드에 마운트되어 ForkJoinPool의 큐에 들어가 스케줄링된다.

private <T> ForkJoinTask<T> poolSubmit(boolean signalIfEmpty,  
                                       ForkJoinTask<T> task) {
  WorkQueue q; Thread t; ForkJoinWorkerThread wt;
  U.storeStoreFence();  // ensure safely publishable
  if (task == null) throw new NullPointerException();
  if (((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) &
      (wt = (ForkJoinWorkerThread)t).pool == this)
      q = wt.workQueue;
  else {
      task.markPoolSubmission();
      q = submissionQueue(true);
  }
  q.push(task, this, signalIfEmpty);
  return task;
}

Virtual Thread pinning

Virtual Thread의 장점은, JVM이 자체적으로 Virtual Thread를 스케줄링하고 컨텍스트 스위칭 비용이 줄어들어 효율적으로 운영할 수 있다는 것이다. 하지만 Virtual Thread가 플랫폼 스레드에 고정되어 장점을 활용할 수 없는 경우가 있다. Virtual Thread 내에서 synchronized block을 사용하거나, JNI를 통해 네이티브 메서드를 사용하는 경우다.

출처: https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD
출처: https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-704A716D-0662-4BC7-8C7F-66EE74B1EDAD

Virtual Thread는 Spring Boot 3.2.x에서 공식적으로 지원open in new window하지만 2.x에서도 별도로 설정해서 사용open in new window할 수 있다

@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
  return protocolHandler -> {
      protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
  };
}

다만, 공식 블로그에 따르면 Spring 로직 내에 많은 synchronized가 있어 효율이 좋지 않다.

출처: https://spring.io/blog/2022/10/11/embracing-virtual-threads#mitigating-limitations
출처: https://spring.io/blog/2022/10/11/embracing-virtual-threads#mitigating-limitations

실제로 Spring Boot 2.7.17에서 Virtual Thread를 사용하도록 설정하고 -Djdk.tracePinnedThreads=short 옵션과 함께 구동한 후 synchronized를 사용하는 컨트롤러를 호출하면 다음과 같은 로그를 많이 볼 수 있다.

@GetMapping("/test")
@Operation(summary = "테스트", description = "테스트")
public String test() throws Exception {
  synchronized (this){
    Thread.sleep(1000l);
    log.info("HELLO");
  }
  return "OK";
}
Thread[#185,ForkJoinPool-1-worker-1,5,CarrierThreads]  
    com.example.test.TestController.test(TestController.java:22) <== monitors:1

또한 Spring 구동 시 다음과 같은 로그도 볼 수 있다.

Thread[#184,ForkJoinPool-1-worker-2,5,CarrierThreads]  
    com.mysql.cj.protocol.ReadAheadInputStream.read(ReadAheadInputStream.java:180) <== monitors:1
    com.mysql.cj.jdbc.ConnectionImpl.commit(ConnectionImpl.java:791) <== monitors:1

MySQL 패키지에 사용된 synchronized가 pinning을 유발하고 있는 것이다.

따라서 Spring은 synchronizedReentrantLock으로 마이그레이션하는 방향으로 가고 있다.

출처: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2.0-M2-Release-Notes#support-for-virtual-threads
출처: https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.2.0-M2-Release-Notes#support-for-virtual-threads

그 밖에도 많은 진영에서 Virtual Thread를 지원하기 위해 synchronized에서 ReentrantLock으로 마이그레이션이 진행되고 있다.

synchronized가 많이 남아있는 Spring Boot 2.x에서는 Virtual Thread를 잘 사용하기 위해서는 여러 의존 모듈의 마이그레이션이 선행되어야 할 것 같다. 앞으로 미래 Java 버전에서는 synchronized는 점점 사라질 것으로 예상한다.

Virtual Thread blocking

기존 Java 스레드는 sleep 실행 시 blocking 상태가 되며 다른 스레드와 컨텍스트 스위칭을 한다. Virtual Thread의 sleep을 살펴보자.

(openjdk/jdk21open in new window)

public static void sleep(long millis) throws InterruptedException {
  if (millis < 0) {
    throw new IllegalArgumentException("timeout value is negative");
  }

  long nanos = MILLISECONDS.toNanos(millis);
  ThreadSleepEvent event = beforeSleep(nanos);
  try {
    if (currentThread() instanceof VirtualThread vthread) {
        vthread.sleepNanos(nanos);
    } else {
        sleep0(nanos);
    }
  } finally {
    afterSleep(event);
  }
}

기존 스레드의 경우 sleep0 JNI 호출로 KLT와 함께 block 상태로 변경되고 Virtual Thread의 경우 다른 동작을 하는 것을 볼 수 있다.

(openjdk/jdk21open in new window)

void sleepNanos(long nanos) throws InterruptedException {
  /* ... 생략 ... */
  parkNanos(remainingNanos);
  /* ... 생략 ... */
}

(openjdk/jdk21open in new window)

@Override
void parkNanos(long nanos) {  
  /* ... 생략 ... */
  boolean yielded = false;
  Future<?> unparker = scheduleUnpark(this::unpark, nanos);
  setState(PARKING);
  try {
    yielded = yieldContinuation();  // may throw
    /* ... 생략 ... */
  } catch(/* ... 생략 ... */) {

  }
}

private boolean yieldContinuation() {
  // unmount
  notifyJvmtiUnmount(/*hide*/true);
  unmount();
  try {
    return Continuation.yield(VTHREAD_SCOPE);
  } finally {
    // re-mount
    mount();
    notifyJvmtiMount(/*hide*/false);
  }
}

스레드를 언마운트/park하고 다시 마운트/unpark하는 것은 Future로 돌리는 것을 알 수 있다. 즉, 명시적인 KLT의 sleep/block을 수행하지 않는다.

Spring MVC Tomcat 하에서 테스트를 해보자. Virtual Thread를 사용하지 않는 Tomcat의 threads를 1로 설정하여 커널 스레드를 하나만 사용하게 하고, Virtual Thread에서도 커널 스레드를 하나만 사용하게 하여 처리량을 비교해 보겠다. 또한 호출은 100개의 요청을 동시에 보내 보겠다.

다음 컨트롤러를 호출한다.

@GetMapping("/test")
@Operation(summary = "테스트", description = "테스트")
public String test() throws Exception {
  Thread.sleep(1000l);
  log.info("{}", Thread.currentThread());
  return "OK";
}

Tomcat은 다음 설정으로 스레드를 제한한다.

server:  
  tomcat:
    threads:
      max: 1

Virtual Thread는 가이드에 따라 다음 환경변수를 통해 스레드를 제한한다.

출처: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html
출처: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html

Virtual Thread를 사용하지 않은 환경에서는 100개의 호출이 동시에 발생했으나, Tomcat 스레드가 1이므로 호출 처리에 최대 1000ms×1001000ms\times{100} 의 처리 시간이 걸리고 1TPS의 처리량을 넘지 못한다. 즉, 동시성이 거의 없는 것을 볼 수 있다.

Name# reqs# failsAvgMinMaxMedianreq/sfailures/s
GET /test230(0.00%)11986102122943120000.990.00

Virtual Thread를 사용한 환경에서는 높은 TPS 처리량을 보인다. 100개의 호출이 동시에 발생했으나, non-blocking 방식으로 처리되어 최대 처리 시간 또한 1000l 정도다. 또한 로그에서 커널 스레드는 하나만 사용하는 것을 알 수 있다.

Name# reqs# failsAvgMinMaxMedianreq/sfailures/s
GET /test9280(0.00%)100510011031100189.190.00
2024-02-05 13:17:26.329  INFO 70581 --- [               ] VirtualThread[#312]/runnable@ForkJoinPool-1-worker-1  
2024-02-05 13:17:26.336  INFO 70581 --- [               ] VirtualThread[#313]/runnable@ForkJoinPool-1-worker-1  
2024-02-05 13:17:26.339  INFO 70581 --- [               ] VirtualThread[#314]/runnable@ForkJoinPool-1-worker-1  
2024-02-05 13:17:26.349  INFO 70581 --- [               ] VirtualThread[#315]/runnable@ForkJoinPool-1-worker-1  

따라서 Tomcat, Spring MVC 하에서도 Netty/WebFlux와 처리 방식과 효율이 같으며, 네트워크 I/O처럼 CPU를 사용하지 않는 스레드 blocking 환경에서 사용하면 좋은 효율을 보여줄 수 있다고 판단할 수 있다.


마치며

CPU intensive 환경이 아닌, 네트워크 I/O가 다수 발생하는 웹서버 환경에서는 하나의 호출에 하나의 스레드를 점유하는 기존 Spring MVC/Tomcat 모델은 큰 부담으로 작용했고, non-blocking single thread 모델인 Netty/WebFlux 모델이 그 단점을 해결하며 부상했다. 하지만 학습이 어렵고, 숙련도가 부족해 block을 한 번이라도 잘못 사용하는 순간 전체 서비스가 망가지기 때문에 쉽게 도입하긴 쉽지 않다고 생각한다. 레거시 서비스의 경우 webflux로 마이그레이션하기도 어려울 것이다.

Virtual Thread의 등장은 non-blocking single thread 모델을 사용하지 않아도 된다고 말하고 있다. 실제로 CPU intensive 환경이 아니라면 non-blocking single thread 모델만큼이나 효율을 잘 내고 있다.

추후 많은 Java 진영에서 synchronized를 제거하는 등, Virtual Thread를 사용하기 위한 준비가 된다면 Java 진영의 non-blocking single thread 모델의 자리에 Virtual Thread가 들어갈지도 모르겠다.


참고 자료

openjdk/jdk21

https://openjdk.org/projects/jdk/21 released 2023-09-19
JDK 21 Documentation - Home

The documentation for JDK 21 includes developer guides, API documentation, and release notes.
Spring | Blog

Level up your Java code and explore what Spring can do for you.
What is JVM (Java Virtual Machine)? - UseMyNotes

In this tutorial, we will unfold two of the most important questions of the Java programming environment i.e What is JVM (Java Virtual Machine)? and What is
Java Virtual Threads

From Java 19 virtual threads were added to Java. This tutorial explains how to create Java virtual threads, and how they differ from system threads in Java

이찬희 (MarkiiimarK)
Never Stop Learning.