前言
在JDK 19中提供了一个非常重要的新特性就是虚拟线程,虚拟线程 换成go语言就是对应的协程
为什么需要虚拟线程
为什么要虚拟线程,它到底解决了什么问题?
这就要涉及到标准的普通线程了
要知道在我们操作系统层面上,要进行高并发的程序处理,都要创建一个一个普通的线程,而这个线程是CPU分配时间片的基本单位,如果一个CPU的时间片在某一个线程上分配的越多,那么这个线程的处理速度从理论上来说就是越快的,因为CPU为其分配的资源多。
但是在实际的工作中,我们会遇到这样的问题,在我们普通的应用环境下,首先会出现 计算密集型 和 io密集型两种不同的形态。
计算密集型:在开发的时候,并不需要有更多的写入磁盘或者做其他的业务处理,只是充分的去利用CPU的计算能力来完成科学运算等操作,所以在线程上面它得到CPU的时间片是相对较多的,我们把它称为长时间片。
io密集型:相当于计算一点,然后把数据往数据库或者硬盘上面写一点。真正由CPU执⾏的代码消耗的时间⾮常少,线程的⼤部分时间都在等待IO。
虚拟线程是如何工作的
虚拟线程到底是什么样子的呢?
在JDK19中它提供了一个中间层,介于我们JAVA应用程序和JVM虚拟机之间的,这里提供了一系列的虚拟线程层。虚拟线程层是被我们JVM所管理的,然后在由JVM来创建对应的真实的线程,我们将JVM管理的普通线程或者又叫真实线程,也称为平台线程。
以前我们写程序的时候,创建一个线程,那么这个线程就是对应了操作系统的一个线程,这样CPU调度和分配的时候都是一对一的,但是现在当使用到虚拟线程以后,我们在原有的线程层面上,增加了一系列的虚拟线程,而虚拟线程和真实的线程是多对一的关系。在运行的时候,正是因为虚拟线程并不和真实的操作系统线程来进行绑定,而虚拟线程的创建和销毁又是特别轻量级的,所以在我们进行构建的时候,操作系统为某一个线程分配了时间片,在这个时间片内,由JVM决定计算谁的工作,在这个过程中要是某一个虚拟线程进入阻塞,那就交给其他虚拟线程来进行运算。
有一个关键特性,就是在任何时刻,只能执行一个虚拟线程,但是,一旦该虚拟线程执行一个IO操作进入等待时,它会被立刻”挂起”,然后执行下一个虚拟线程。什么时候IO数据返回了,这个挂起的虚拟线程才会被再次调度。因此,若干个虚拟线程可以在一个普通线程中交替运行。
所以说,在我们日常的IO密集型计算业务下,它可以更大力度的利用更少的线程处理更多的并发。这样的操作其实对于我们大规模应用是非常有效的。
对于虚拟线程来说,它有如下好处:
摆脱了操作系统对于线程调度上的效率高于低的问题。我们在应用开发的时候,有的系统对于多线程CPU调度效率高,那自然处理就快一些,而调度效率低的自然效率就慢一些。但是现在我们用尽量少的线程来获取CPU时间片,然后交给谁来处理是由JVM来进行的分配,就可以让我们使用更少的线程来处理更多的事情。因此在项目开发的时候,虚拟线程对性能的影响还是非常大的。
如何使用虚拟线程
虚拟线程的时候是非常简单的,在API层面上只需要做极少的修改就可以了。
虚拟线程在JDK 19中还是预览版中的特性,因此在程序运行的时候,需要在Maven中启用预览版特性
<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>19</maven.compiler.source> <maven.compiler.target>19</maven.compiler.target> </properties> <plugin> <!-- maven编译插件 --> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.10.1</version> <configuration> <compilerArgs> <arg>--add-modules=jdk.incubator.concurrent</arg> <!-- 启⽤预览版特性 --> <arg>--enable-preview</arg> </compilerArgs> </configuration> </plugin>
使用虚拟线程的两种方法:
1、Thread.startVirtualThread(Runnable r)
在原有的程序开发中都是使用new Thread标准创建线程的方式,现在直接改成了Thread.startVirtualThread,然后传入runnable接口的实现,完成的任务都是相同的,不过呢它的调用方法发生了变化,通过这样的方式来处理的,它每一次运行都会创建一个虚拟线程。这里虚拟线程和真实线程的关系是多对一的。它可以更加良好的利用这个处理。
import java.util.Random; // 当使⽤参数运⾏时,代码将使⽤虚拟线程; 否则将使⽤常规线程。程序会⽣成您选择的线程类型的50,000迭代。然后⽤随机数做⼀些简单的数学运算,并跟踪执⾏时⻓。 // 要使⽤虚拟线程运⾏代码,请键⼊: mvn compile exec:java -Dexec.args = “true”。 // 要使⽤标准线程运⾏,请键⼊: mvn compile exec:java。我 public class App { public static void main( String[] args ) { boolean vThreads = args.length > 0; System.out.println( "Using vThreads: " + vThreads); long start = System.currentTimeMillis(); Random random = new Random(); Runnable runnable = () -> { double i = random.nextDouble(1000) % random.nextDouble(1000); }; for (int i = 0; i < 50000; i++){ if (vThreads){ Thread.startVirtualThread(runnable); } else { Thread t = new Thread(runnable); t.start(); } } long finish = System.currentTimeMillis(); long timeElapsed = finish - start; System.out.println("Run time: " + timeElapsed); } }
咱们看到的这个代码它其实做了两个版本,一个是针对50000次的随机数运算,它使用的是标准线程来进行处理,另外一个是使用虚拟线程来进行处理。
让我们看一下它的运行结果。
在使用的时候,我们如果在参数中传入true,代表开启虚拟线程进行运算,50000次总时长174毫秒,使用标准线程运行,50000次总时长5450毫秒。
虽然计算过程显得不够严谨,但是也可以从中窥探到虚拟线程对于性能的提升和影响。
但是也说明了作为普通线程创建对于操作系统的调度和分配来说无疑是一个重量级的操作,线程越多,不但不会提高操作系统的性能,可能还会降低。
2、Using an executor
在我们平时开发的时候,大多数应用都会内置线程池这样的概念,在我们java中使用executor执行器的方式,去创建各种各样的线程池,比如这里的固定定长线程池。但是为了能够向以前版本进行良好的兼容,我们以前使用线程池,现在要改成虚拟线程的话,本身虚拟线程属于特别轻量级的,原则来说它并不需要一个线程池来对其进行管理,不过为了兼容性的需要,它也做了兼容性的处理使用了newVirtualThreadPerTaskExecutor这样的方法创建了一个虚拟线程版本的executor执行器,至于使用的时候因为它们都是实现的executor这个对象,所以使用方式完全一样,只是在我们进行实例化的时候有所不同,其他代码都不用发生任何变化。
启动虚拟线程的另⼀种主要⽅法是使⽤执⾏器(Executor)。执⾏器在处理线程时很常⻅,它提供了⼀种协调许多任务和线程池的标准⽅法。
虚拟线程不需要池,因为创建和处理它们的成本很低,因此池是不必要的。但是,许多程序仍然在使⽤ 执⾏器,因此Java 19在执⾏器中包含了⼀个新的预览⽅法,以简化对虚拟线程的重构。
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); //New method ExecutorService executor = Executors.newFixedThreadPool(Integer poolSize); // Old method
虚拟线程的最佳实践
虚拟线程从使用的角度来说就是一个Thread类,所以在我们原本代码中任何一个可以使用Thread的地方,使用虚拟线程也可以达到相同的效果。另外使用虚拟线程并不拘泥于必须使用线程池。我们也可以改为使用JAVA并发包中的semaphores(信号量)来控制线程的数量,这也是一个很好的最佳实践。
除此以外作为虚拟线程,它呢类似于一个守护进程,也就是说在使用了虚拟线程以后,在任何时间最少有一个处于活动状态的虚拟线程来运行,它其实也保护了我们程序这种激活的状态。
转载请注明:西门飞冰的博客 » 虚拟线程——JDK19