Java虚线程 简介

虚线程是JDK19中新引入的一个功能,是用户级别的线程。旨在帮助开发者以更简单、清晰的方式开发出高性能,吞吐量更大的应用程序。

背景

以服务端应用为例,我们看一下Java中使用Thread的方式

thread-per-request(同步编程)

thread-per-request的服务端应用中,一个request从始至终都运行在同一个线程上。这样开发出来的程序有几个特点

  • 方便开发,方便理解
  • 上下文保持一致,便于调试
  • 同步阻塞,吞吐量不高

由于JDK的线程是在OS的线程上封装了一层,一个JDK线程对应了一个OS线程。如果线程过多会导致CPU频繁切换也会影响到系统的性能,所以我们在实际应用中通常会使用线程池来控制我们的线程数,同时减少创建、销毁线程所带来的开销。

asynchronous style (异步编程)

在同步编程的方式中,同时能够处理的请求数量依赖于线程池的数量。在异步编程中采用了另一种thread-sharing的方式,当一个请求遇到I/O操作时,它会将当前线程返还给线程池,这样该线程就可以为其他请求服务。通过异步编程的方式我们可以在资源有限的方式下开发出高性能的services,但是这也带来了不小的复杂度:

  • 开发复杂,它需要所谓的异步编程风格,采用一组单独的 I/O 方法,这些方法不等待 I/O 操作完成,而是稍后将其完成通知给回调
  • 将完整的业务逻辑拆分为多个小阶段,然后使用lambda表达式和流式API组合起来(CompletableFuture, eg)
  • 难以调试,线程栈无法提供完整的上下文信息

虚线程

在前面的介绍中我们可以看到应用程序的开发便利性和高性能似乎不可兼得。Vritual Thread的提出就是为了解决这个问题,让开发者能够通过同步的方式进行编程,同时又能够获得异步模式的高吞吐量。

A virtual thread is an instance of java.lang.Thread that is not tied to a particular OS thread. A platform thread, by contrast, is an instance of java.lang.Thread implemented in the traditional way, as a thin wrapper around an OS thread.

虚线程 是java.lang.Thread的子类,但是并不和特定的OS线程绑定。当我们的代码运行在虚线程上时:

  • 如果是在CPU上执行计算,虚线程会消费一个OS thread
  • 如果调用了java.*下的阻塞I/O方法,运行时会进行一个非阻塞的OS调用然后自动将当前的虚线程挂起

创建虚线程的开销很低,因此绝对不要池化虚线程,大多数虚线程的生命周期应该伴随着一个后台task或者一个http请求而创建,结束后销毁。

我们可以通过下面的实例看一下如何使用虚线程

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 10_000).forEach(i -> {
        executor.submit(() -> {
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

这个例子中我们创建了10,000 个虚线程,但是实际上创建的OS的线程可能仅有10个,那么我们指定在虚线程运行的背后,到底有多少个真正的线程在执行?

配置含义
jdk.virtualThreadScheduler.parallelism可用于调度虚拟线程的平台线程数。它默认为可用处理器的数量
jdk.virtualThreadScheduler.maxPoolSize调度程序可用的最大平台线程数。默认为 256。

虚线程的调度

JDK普通线程(platform thread)的调度是借助OS来完成的,虚线程的调度是由JDK自己实现的。JDK的调度器(实际上是一个ForkJoinPool)将虚线程分配到给一个JDK线程(被称为carrier),再借助OS来实现JDK线程的调度。

在虚线程的生命周期中会有多个不同的carrier,每一次挂起后再执行都有可能是不同的carrier:

  • carrier的ID对虚线程的不可见,Thread.currentThread()返回的是虚线程自身。
  • carrier和虚线程的调用栈是分离的。虚线程中的异常也不会包括carrier的栈帧。虚线程 的Thread dump也不包含carrier,反之亦然
  • carrier的thread local变量对虚线程不可见,反之亦然。

注意这里并不是说不支持thread local只是相互之间不可见。同时由于虚线程不需要池化,应当谨慎使用thread local以避免占用过多的内存。

虚线程的执行

虚线程在运行时会mount到一个JDK线程上,当执行阻塞的I/O或者JDK其他的阻塞方法时(BlockingQueue.take())会进行unmount释放当前线程。当阻塞操作结束时,虚线程会重新提交给线程调度器,然后mount到一个新的carrier上继续执行。

然而操作系统的限制(文件系统操作)和JDK自身的限制(Object.wait())有一些阻塞操作JDK不会进行unmount的操作,虚线程和carrier会被同时阻塞。以下两类操作会将虚线程固定到carrier上:

  • synchronized代码块或者方法
  • 调用了native方法或者 foreign function

JDK提供了一些诊断工具来帮助我们判断是否有发生固定:

  • 在固定发生时,会发出一个JDK Flight Recorder (JFR)JFR事件
  • -Djdk.tracePinnedThreads=full 会在阻塞时输出完整的堆栈信息。-Djdk.tracePinnedThreads=short仅输出有问题的栈帧

参考

https://openjdk.org/jeps/444