【Java虚拟线程】Java21、SpringBoot3中使用虚拟线程

飒飒的宇y 2024-06-21 09:35:02 阅读 96

Java21、SpringBoot3中使用虚拟线程

前言

最近有读取文件中数据的需求,且数据量百万至千万,普通的多线程读取方式还是很慢。遂想到Java21中虚拟线程,在网上学习了一圈,简单的在这里记一个笔记,方便日后查找。

以下内容是个人理解,注解辨别。

一、什么是虚拟线程,有什么用

1、虚拟线程是Java19提出来的一个概念,Java19提供特性预览,开放实装是Java21(2023年9月),目前来说(2024年3月),还是一个比较新的特性,对于一些不是很常见的库可能尚未适配。

2、虚拟线程主要解决的问题是减少I/O密集型任务的I/O阻塞。传统的多线程在处理I/O的时候,例如某个线程在处理某个任务,如果遇到I/O例如网络通信、文件读取,受限于网络速度、机器的硬盘I/O速度等,这个线程会阻塞等待I/O的完成,然后再继续往下执行任务。这就带来了一个问题,由于存在I/O过程,这个线程它不干活了,就在那里阻塞干等I/O完成,偷懒了一会儿,导致了CPU的利用率其实没有达到理想状态。

3、而虚拟线程则是在线程遇到I/O阻塞时,会放暂时放弃等待去做其他事,等I/O完成它才会回来继续执行任务,无疑这种方式提高了CPU的利用率,让它无法再偷懒。

虚拟线程是由JVM来管理的,不由系统管理,并且它十分轻量,虚拟线程之间的切换开销十分的小,所以你甚至可以开启上百万个虚拟线程。

4、虚拟线程并没有增加实际的CPU可用线程,而是增加了线程的利用率,所以在面对CPU密集型任务时,如数学计算等,它与传统的线程没有区别,因为CPU密集型任务不存在大量的I/O等待。

如果你的需求中存在大量I/O等待导致性能瓶颈,那么可以考虑使用虚拟线程。

二、JavaSE中使用虚拟线程

官方对虚拟线程做了很多封装,你可以很简单的使用它

Thread.ofVirtual().start(() -> { //异步任务,这里的参数不用lambda表达式的话要传一个实现Runnable的类}); //开启一个虚拟线程

部分源代码:

public static Builder.OfVirtual ofVirtual() { return new ThreadBuilders.VirtualThreadBuilder();}public Thread start(Runnable task) { Thread thread = unstarted(task); thread.start(); return thread;}

同时,官方还提供了使用平台线程(真实线程)的方法:

Thread.ofPlatform().start(() -> { //异步任务,这里的参数不用lambda表达式的话要传一个实现Runnable的类}); //开启一个平台线程

当然,在实际使用中,我们不可能只使用一个虚拟线程,所以官方提供了一个类似于线程池的方法:

var executors = Executors.newVirtualThreadPerTaskExecutor() //开启一个“虚拟线程池”executors.submit(() -> { //异步任务,这里的参数不用lambda表达式的话要传一个实现Runnable的类}); //向“虚拟线程池”中提交一个任务

说明一下:

首先var关键字是Java10引入的,类似于C++中的auto,它可以自动类型推断,就是说我们以后可以不用写类似 ArrayList<String> list = new ArrayList<>(); 这样的代码了,直接 var list = new ArrayList<String>(); 编译器会自己类型推断,但是这么写你得注意变量名,不然读起来会很麻烦。还有var关键字只能用于局部变量,成员变量、形参等都不行。

其次,这种方式用虚拟线程,JVM会自动利用机器可用的真实线程来跑这些虚拟线程,不用额外配置

使用“虚拟线程池”的时候最好搭配try-with-resource,以达到资源的自动释放:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 1_000_000; i++) { // 一种计数方式,类似于 1,000,000 int finalI = i; var future1 = executor.submit(() -> { System.out.println("任务 " + finalI +" 运行在 " + Thread.currentThread()); TimeUnit.SECONDS.sleep(3);//模拟阻塞 // 执行其他操作 return "Result"; });}}

你可以在下方使用 future.get() 的方式来等待线程执行完毕

以上代码执行结果:

...任务 992 运行在 VirtualThread[#1038]/runnable@ForkJoinPool-1-worker-12任务 578 运行在 VirtualThread[#622]/runnable@ForkJoinPool-1-worker-6任务 802 运行在 VirtualThread[#848]/runnable@ForkJoinPool-1-worker-4任务 997 运行在 VirtualThread[#1043]/runnable@ForkJoinPool-1-worker-13...

我们可以看到线程名成为了 VirtualThread ,它们运行在不同的真实线程上 worker-13worker-4

三、SpringBoot中使用虚拟线程

在SpringBoot中使用虚拟线程要求SpringBoot的版本最低是 3.x ,JDK的版本不能低于21

使用JDK21,Maven引入SpringBoot3.x:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>3.2.1</version></dependency>

新建一个配置类,添加两个Bean:

@Configurationpublic class Config { @Bean public AsyncTaskExecutor asyncTaskExecutor(){ return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); } @Bean public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() { return protocolHandler -> protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); } }

------------------------------------------------------------------- 分界线 2024-03-26补充 ---------------------------------------------------------------------

暂时不要用Undertow作为Servlet容器来使用虚拟线程,可能会有内存溢出等问题,你的内存占用会飙得老高,SpringBoot官方目前说的是Undertow的问题,另外还补充说如果这个问题没能得到解决的话,未来可能移除对Undertow的虚拟线程支持,所以暂时别在生产环境中使用。

官方GitHub issues 原文地址

------------------------------------------------------------------------------- 分界线 ------------------------------------------------------------------------------

说明一下,第一个Bean AsyncTaskExecutor 是为了让SpringBoot在你使用 @Async 注解的时候用虚拟线程执行你的 @Async 任务。

第二个Bean TomcatProtocolHandlerCustomizer 是为了让Tomcat用虚拟线程来接请求的。我们可以测试一下,写一个Controller和一个Service:

@RestController@RequestMapping("/base")public class TestController { @Resource private TestService service; @GetMapping("/test") public String test(){ System.out.println("---> " + Thread.currentThread()); service.test(); return "ok"; } }

@EnableAsync@Servicepublic class TestService { @Async public void test(){ System.out.println("================> " + Thread.currentThread()); } }

运行这个服务,访问 localhost:8080/base/test 接口的时候,会打印:

---> VirtualThread[#65]/runnable@ForkJoinPool-1-worker-1================> VirtualThread[#71]/runnable@ForkJoinPool-1-worker-4

发现都是虚拟线程,我们去刚才新建的配置类中注释掉下面的 TomcatProtocolHandlerCustomizer 这个Bean,重启服务再访问接口:

---> Thread[#61,http-nio-8080-exec-1,5,main]================> VirtualThread[#78]/runnable@ForkJoinPool-1-worker-1

会发现接收请求的线程不是虚拟线程了,同样的,注释掉上面的 AsyncTaskExecutor 那么我们用 @Async 注解的方法也是不使用虚拟线程了。

如果你想提高请求处理量的话,就注入下面这个Bean,只是想使用虚拟线程做其他事的话,下面这个Bean是可选的。

------------------------------------------------------------------- 分界线 2024-03-26补充 ---------------------------------------------------------------------

** 如果你的打印内容出现:

.s.a.AnnotationAsyncExecutionInterceptor : More than one TaskExecutor bean found within the context, and none is named 'taskExecutor'. Mark one of them as primary or name it 'taskExecutor' (possibly as an alias) in order to use it for async processing: [asyncTaskExecutor, taskScheduler]

或者你打印的线程信息类似:

Thread[#129,SimpleAsyncTaskExecutor-2,5,VirtualThreads]

第一个警告的意思是在上下文中找到多个TaskExecutor bean,但没有一个名称为“TaskExecutor”。将其中一个标记为主任务或将其命名为“taskExecutor”(可能是别名),以便将其用于异步处理:[asyncTaskExecutor,taskScheduler]

问题是项目中引入的线程池过多,Spring容器不知道用哪一个,我们只需要在AsyncTaskExecutor这个Bean上添加一个@Primary将我们设置的线程池设置为主线程池就行了:

@Bean@Primarypublic AsyncTaskExecutor asyncTaskExecutor(){ return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());}

---------------------------------------------------------------------------------- 分界线 ----------------------------------------------------------------------------

四、总结

目前我没有做具体的性能测试,有兴趣的可以做做压测。后续我做了测试的话会更新这篇博客。

虚拟线程目前来说是一个很新的特性,可能会有很多问题尚未发现,使用要谨慎。

此外,CPU密集型任务目前来说就不建议用虚拟线程了,性能没提升。

参考文献:

1、简单记录下 Spring Boot 使用虚拟线程Virtual Threads(Java的协程)的方法

2、Java21手册(一):虚拟线程 Virtual Threads



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。