跳到主要内容

理解虚拟线程(Java 21 Virtual Threads)

虚拟线程(Virtual Threads,简称:VT)最大的优势在于I/O处理。因为不会阻塞(平台线程),所以能够使I/O密集型系统拥有更大的吞吐量。这无疑是一种相当可观的性能提升。

线程的种类

  • 平台线程:泛指由 JVM 实现的线程(如 java.lang.Thread)。属于用户态线程。用于执行用户态代码(如应用程序中的任务)
  • 内核线程:由操作系统管理。属于内核态线程。用于访问系统资源。在 HotSpot 中,平台线程和内核线程通常是一对一的关系(。不同的JVM可能有不同的实现)
  • 虚拟线程(java.lang.VirtualThread):基于java.lang.Thread实现,所以同属用户态线程(Thread 实现)。在 Java 中,平台线程和虚拟线程是一对多的关系;在该上下文中,平台线程被称为VT的载体

虚拟线程(VT)

未引入 VT 之前,Java 程序要想异步执行某个任务就需要依赖平台线程。但平台线程存在一个问题;当任务执行期间遇到阻塞操作时,其执行(平台)线程就会被阻塞。 一旦线程被阻塞,它就会被 JVM 挂起直至阻塞操作返回获取到执行资源为止。 该过程称为上下文切换,对于应用程序而言是一种对吞吐量影响较大的操作(,因为切换期间并不执行应用代码)。

可将 VT 看作是一种特殊的任务包装器介乎于平台线程和异步任务之间的抽象层(,使得任务和平台线程得以解耦)。 客户端将任务交给 VT 后,Java Runtime 会动态地将 VT 绑定到某条平台线程上执行(,该平台线程由java.util.concurrent.ForkJoinPool#common提供)。 值得注意,VT 和平台线程的关系并不是固定的。一旦 VT 在执行任务期间被阻塞,其平台线程就会将其卸载,并绑定其他可执行的 VT 继续处理(其他)任务。 换句话说,性能提升并非来自于 VT 本身,而是来自于平台线程得到了更高的使用效率(,因为减少了上下文切换所导致的性能损耗)。

基本上阻塞操作都会导致 VT 被其载体卸载。然而,目前(JDK 24之前)还存在一些特例会导致载体无法卸载 VT。 无法卸载意味着会直接阻塞载体(即平台线程),这种情况称为固定。 之所以发生固定是因为历史遗留原因。一些线程调度工作需要依赖内核的支持,然而在目前版本的 Java Runtime 中暂时无法摆脱这一事实。 从另一个角度来看,固定实际上是一种安全措施。譬如 JVM 的synchronized语义实际上是参照监控器来实现的(,所以又叫“监控锁”)。当某条线程获取到synchronized之后,JVM 就会通过自旋(又叫“忙等待”)的方式来持续监控该线程以便及时地更新监控器信息。但问题在于 JVM 监控的是平台线程,而非 VT。也就说,就目前而言synchronized是针对平台线程来实现的。因此,若果不进固定的话就会出现这么一种情况。VT 获取到监控锁后因阻塞而被载体卸载,载体转而绑定 VT2(泛指其它可被直接执行的 VT)。此时,从 JVM 层面来看,真正持有监控锁的其实是当前 VT2 的载体。即因为载体的重新绑定了,导致 VT2 获得了监控锁。所以这显然是存在问题的。因此,针对以上问题。Java Runtime 会在 VT 阻塞时执行如下策略:

  • 发生条件阻塞时(如 synchronized、Object#wait、BlockingQueue#take 等),载体因受到 Java Runtime 限制而将无法卸载 VT。此时载体(平台线程)将会被阻塞
    • ⚠️ 该问题将会在 JDK 24(JEP-491)中解决
  • 发生I/O阻塞时,Java Runtime 就会让载体卸载 VT,并将 I/O 操作注册到内核中(如 epoll、kqueue),直到对应文件描述符就绪后才会恢复 VT(真正运行需要等待载体装载)

Java Runtime 并不会因为载体被固定而增加并行度(。其并行度默认为机器 CPU 的核心数,可参考java.util.concurrent.ForkJoinPool#common的实现),所以应该尽可能地避免固定。例如使用java.util.concurrent.locks.ReentrantLock替代synchronized。当出现固定时,意味着应用程序对 CPU 的使用率将会降低(因为载体/平台线程被阻塞)。为了解决这问题,可以添加系统变量jdk.virtualThreadScheduler.maxPoolSize来指定最大的平台线程数。但该值要大于jdk.virtualThreadScheduler.parallelism才有作用。

注意

  • VT 不需要被池化。因为 VT 的堆栈只是一个普通对象(,这意味着受到 GC 管理,并且可以被复用)。理论上只要堆内存足够大的话就可以大规模创建 VT
  • VT 无法通过jstackjcmd <pid> Thead.print进行堆栈转存,因为它们并不是针对 VT 设计的。想要获取包含 VT 的堆栈转存信息,可以使用jcmd <pid> Thread.dump_to_file -format=json <file> 命令
  • Tomcat 从 9.0 开始可在 server.xml 中手动添加一个 className 为org.apache.catalina.core.StandardVirtualThreadExecutor的 Executor,然后再配置到想要使用 VT 的 Connector 上就可以运用 VT 来处理网络请求了
  • VT 属于守护线程/精灵线程,所以不会影响进程的退出决策

拓展

参考

Dik Tam
作者
Dik Tam
只是喜欢写代码。