理解虚拟线程

理解虚拟线程 #

JDK21中正式引入了虚拟线程,这是Java标准库首次正式支持用户态轻量级线程,它的出现让Java并发编程更加简单高效。这篇文章将深入探讨虚拟线程的概念和原理,帮助你更好的理解和使用虚拟线程。

为什么需要虚拟线程 #

在虚拟线程出现之前,Java中主要依靠线程(Thread)实现并发编程,通过将多个任务分配给多个线程来实现并发执行,每个线程具有独立的执行栈,一个线程就是一个并发执行单元。 而Java语言中的线程和操作系统中的线程是一一对应的,Java代码中每创建一个线程,JVM都会调用操作系统的API来创建一个操作系统线程并映射到Java线程。这种并发方式有以下特点:

  • 创建销毁:线程的创建和销毁都由需要操作系统内核来完成,涉及用户态和内核态的切换,开销较大。
  • 内存占用:在Linux系统上运行的JVM,每创建一个线程,JVM会为其默认分配1MB的内存空间,用于存储线程的执行栈等信息,对应的操作系统线程也会分配约10KB的内存空间用于保存操作系统线程栈,内存占用量较大。
  • 上下文切换:线程由操作系统调度运行,每次切换线程都需要保存和恢复线程的上下文,这涉及保存和恢复大量寄存器、栈和其他状态信息,也会使CPU缓存失效,且同样涉及用户态和内核态的切换,开销较大,且过多的线程还会导致一个结果就是大量的CPU资源都消耗在调度和切换执行线程上,用到执行线程任务的比例就会变小,CPU负载也会很大。
  • 可用数量(支持并发数):在Linux系统中,一个进程可以创建的线程数有限,又由于线程创建占用内存资源较多,调度切换执行消耗的CPU资源也大,因此可用线程数很有限,通常为数千个,因此应用所能够支持的并发任务数也有限。

基于以上几个特点,一些现代编程语言纷纷引入了“轻量线程”的概念,例如Go语言中的协程、Kotlin语言中的协程等。Java语言也终于在JDK21中正式引入了虚拟线程,这是Java标准库首次正式支持用户态轻量级线程。

虚拟线程解决了哪些问题 #

虚拟线程实际上是用户态的轻量级线程,有以下特点:

  • 创建销毁:虚拟线程的创建和销毁都由JVM在用户态完成,不需要操作系统的参与,因此避免了操作系统内核态与用户态的切换带来的性能损耗。
  • 内存占用:虚拟线程使用的内存由JVM控制,通常只需要几KB的内存空间,远小于操作系统线程的1MB内存空间。
  • 上下文切换:全部在用户态,且足够轻量,只需保存和恢复少量的状态信息。
  • 可用数量(支持并发数):由于虚拟线程的控制权在JVM,内存占用小,调度执行时的上下文切换都在内核态的JVM中不会有操作系统线程上下文切换那样高的性能损耗,因此可创建的数量相比线程多很多,通常为数百万,意味着应用能够支持的并发任务数巨大。

总结来看,使用线程实现的并发系统,在创建销毁、内存占用、上下文切换、可用数量等方面都有明显的性能瓶颈,而使用虚拟线程实现的并发系统,在这些方面都有了明显的改善,能够支持更多的并发任务,提高系统的并发性能。

虚拟线程是如何运行的 #

JVM后台管理了一组用于运行虚拟线程的操作系统线程,虚拟线程会被JVM中内置的调度器调度到这些操作系统线程上运行,而当虚拟线程调用到阻塞API时,JVM运行时会将这个虚拟线程挂起,然后取调用操作系统非阻塞API去帮虚拟线程完成IO操作,此时运行该虚拟线程的操作系统线程就会被释放用于运行其他虚拟线程,而不会被IO阻塞。待虚拟线程的IO操作完成,调度器再选择合适的操作系统线程恢复被挂起的这个虚拟线程。 可见,是JVM识别了虚拟线程中的阻塞API,然后将实际调用替换为非阻塞API,这一点很关键,如果虚拟线程调用了JVM所不能识别并转换的阻塞API,那么运行虚拟线程的操作系统线程将被阻塞,无法运行其他虚拟线程,这一点需要特别注意。会造成操作系统线程阻塞的API有:

  • 文件IO操作 FileInputStream, FileOutputStream, FileChannel, RandomAccessFile等类的读写
  • Native(本地代码)中的阻塞操作,因为JVM无法干预本地代码的执行。
  • 一些数据库驱动,尤其是基于JDBC的数据库驱动如MySQL Connector/J、PostgreSQL JDBC 等。因为其底层使用了会阻塞操作系统线程的API。
  • synchronized 关键字方法,由于synchronized 关键字方法是基于操作系统的锁实现的,而虚拟线程无法直接使用操作系统的锁。

虚拟线程适合哪些业务场景 #

类似Golang的协程,虚拟线程以更低的成本支持了系统更高的并发性能,对于IO密集型系统有很大的提升,而对于CPU密集型系统,虚拟线程并没有优势。这是因为:

  • 对于CPU密集型系统,更重要的是将计算任务合理的分配到多个CPU核心上执行,并非支持很高的并发数。
  • 对于IO密集型系统,更重要的在于通过低成本创建大量并发执行单元而几乎不增加CPU负载。

其他问题 #

1. 用户态线程 #

进程的轻量化 => 线程

早期的操作系统没有线程这样的概念,最小的并发执行单元是进程,每个进程有自己独立的执行环境,包括内存空间、文件描述符等,进程之间相互隔离。进程的创建和销毁消耗很大,后来随着多核CPU的兴起,为了充分利用多核CPU的性能,使得进程这个最基本执行单元内能够同时使用多个CPU核心,于是引入了线程概念,操作系统调度执行的执行的最小单元变成了线程,由于多个线程可以共享同一个进程的执行环境如内存空间和文件描述符,而无需像进程那样独立创建,这使得线程的创建和销毁成本大大小于进程,成为当时的并发编程利器。 有意思的是,在此之前就已经有人实现了一种完全在用户态管理和调度的轻量级进程,随后操作系统也实现了内核线程,也就是内核态轻量级进程。

用户态线程支持的并发才能避免内核态和用户态的切换

在操作系统的调度逻辑中,线程和进程都是的 task_struct,区别是前者支持共享内存空间和文件描述符等,线程已经是进程的轻量级实现,操作系统继续优化线程的内存使用即可,而想要避免用户态和内核态的切换,就只能由应用程序本身实现完全用户态的轻量级线程即可。

2. 虚拟线程和传统基于JDBC数据库连接池搭配不是好的选择 #

先说结论,虚拟线程和JDBC连接池搭配使用并非好的选择。

根本矛盾是数据库连接对应的是真实的物理连接,这是一种稀缺资源,一个应用中的数据库连接数在虚拟线程所支持的超高并发面前显的微不足道,而以HikariCP为例的传统数据库连接池,当连接不足时获取连接的虚拟线程会被阻塞挂起,虽然操作系统线程不会被阻塞却也无法发挥作用。