Loom篇之java虚拟线程那些事儿

我们在之前的文章中提到了java推出纤程的背景和原因。在近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每个语句都在线程内执行,并且由于 Java 是多线程的,因此多个执行线程会同时发生。线程是 Java 的并发单元:一段顺序代码,与其他此类单元同时运行,并且在很大程度上独立于其他此类单元运行。每个线程都提供了一个堆栈来存储局部变量和协调方法调用,以及出现问题时的上下文:异常由同一线程中的方法引发和捕获,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的中心概念:调试器逐步执行线程方法中的语句,探查器可视化多个线程的行为以帮助了解其性能。
在jdk19发布的时候,java正式推出了纤程的实现,叫做虚拟线程。在JEP425中提出第一次预览。后续分别在JEP436中提出第二次预览,并且在JEP444中完成最终交付,后续只针对其中的bug进行修复。
那么我们就来看一下关于虚拟线程的内容。

一、摘要

将虚拟线程引入 Java 平台。虚拟线程是轻量级线程,可显著减少编写、维护和观察高吞吐量并发应用程序的工作量。这是一个预览版 API。

虚拟线程现在始终支持线程局部变量ThreadLocal,用户以前如何使用ThreadLocal,,那么在虚拟线程中依然保持原来的使用ThreadLocal。现在不再可能像在预览版中那样创建不能具有线程局部变量的虚拟线程。保证对线程局部变量的支持可确保更多现有库可以原封不动地与虚拟线程一起使用,并有助于将面向任务的代码迁移到使用虚拟线程。

默认情况下,直接使用 Thread.Builder API 创建的虚拟线程(而不是通过 Executors.newVirtualThreadPerTaskExecutor() 创建的虚拟线程)现在也在其整个生命周期内受到监视,并且可以通过 观察虚拟线程 部分中描述的新线程转储进行观察。该目的主要是因为虚拟线程不推荐池化,你并不需要使用线程池来使用虚拟线程。而是在你使用的地方new即可。

二、目标

1、支持以简单的“每个请求一个线程”样式编写的服务器应用程序,以近乎最佳的硬件利用率进行扩展。
2、启用使用 java.lang.Thread API 的现有代码,以最小的更改采用虚拟线程。他的api和之前的线程api高度一致,你之前的任务代码依然可以运行在虚拟线程中,无需进行改变。
3、使用现有 JDK 工具轻松对虚拟线程进行故障排除、调试和分析。我们之前的jdk分析工具,在虚拟线程中依然可以使用,比如JFR。

三、非目标

1、删除线程的传统实现,或静默迁移现有应用程序以使用虚拟线程,这不是一个目标。之前的线程依然存在,不会移除掉。
2、在 Java 语言或 Java 库中提供新的数据并行结构并不是一个目标。Stream API 仍然是并行处理大型数据集的首选方式。
3、改变 Java 的基本并发模型并不是一个目标。

四、设计动机

我们其实在第一篇就看过了他的设计背景,我们这里依然来看一下。
服务器应用程序通常处理彼此独立的并发用户请求,因此应用程序可以通过在整个持续时间内将线程专用于该请求来处理请求。这种“每个请求一个线程”样式易于理解、易于编程、易于调试和分析,因为它使用平台的并发单位来表示应用程序的并发单位。这个可能比较晦涩,我来简单解释一下,我们之前的线程模型在程序运行在线程Thread的时候,任务代码是无法终止的,只能执行完毕,或者执行崩溃。也就是任务(task) + 线程(thread)无法分离。这使得一个请求无法在结束之前从当前运行线程上卸载下来。当我们在spring web上可能更好的理解这个问题。
在这里插入图片描述
我们的springboot默认的servlet容器是tomcat,默认的worker线程是200,当我们发送请求给springboot服务的时候,每一个请求都会交给一个worker线程去处理,在原来的线程模型下这个请求不结束,不处理完,这个worker线程是无法结束去处理别的请求的。如果我们每个controller请求方法处理需要一秒,那么我们一秒钟的吞吐量就是200,那么如果你想提高单体的吞吐呢,那就加线程。别无他法。
这种模型下,因为task无法和thread分离。即便你的worker被阻塞了,比如你去访问数据库,访问rpc远程服务。此时这个线程也无法去处理其他请求,依然是阻塞在那里。

在这种并发模型下,服务器应用程序的可扩展性受利特尔定律的约束,该定律与延迟、并发性和吞吐量相关:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数量(即并发)必须与到达速率(即吞吐量)成比例增长。例如,假设平均延迟为 50 毫秒的应用程序通过并发处理 10 个请求实现了每秒 200 个请求的吞吐量。为了使该应用程序扩展到每秒 2000 个请求的吞吐量,它需要同时处理 100 个请求。如果在请求的持续时间内在线程中处理每个请求,则为了使应用程序跟上,线程数必须随着吞吐量的增长而增加。

遗憾的是,可用线程的数量是有限的,因为 JDK 将线程实现为操作系统 (OS) 线程的包装器,当你一个java线程被创建的时候,同时意味着你会在os层面创建一个原生线程。OS 线程成本高昂,因此我们不能拥有太多,这使得实现不适合 Thread-per-request 风格。如果每个请求在其持续时间内消耗一个线程,从而消耗一个 OS 线程,那么线程数通常会在其他资源(如 CPU 或网络连接)耗尽之前很久就成为限制因素。JDK 当前的线程实现将应用程序的吞吐量限制在远低于硬件可以支持的水平。即使线程池化,也会发生这种情况,因为池化有助于避免启动新线程的高成本,但不会增加线程总数。

在这种背景下,一些希望充分利用硬件的开发人员已经放弃了每个请求一个线程的样式,转而使用线程共享样式(netty)。请求处理代码在等待另一个 I/O 操作完成时将其线程返回到池中,以便该线程可以为其他请求提供服务,而不是从头到尾处理一个线程上的请求。这种细粒度的线程共享(其中代码仅在执行计算时保留线程,而不是在等待 I/O 时保留线程)允许大量并发操作,而不会消耗大量线程。虽然它消除了 OS 线程稀缺性对吞吐量的限制,但它的代价很高:它需要所谓的异步编程风格,采用一组单独的 I/O 方法,这些方法不等待 I/O 操作完成,而是稍后向回调发出完成信号(注册回调)。如果没有专用线程,开发人员必须将其请求处理逻辑分解为小阶段,通常编写为 lambda 表达式,然后使用 API 将它们组合成一个顺序管道(例如,请参阅 CompletableFuture 或所谓的“反应式”框架)。因此,它们放弃了语言的基本顺序组合运算符,例如循环和 try/catch 块。

在异步样式中,请求的每个阶段可能在不同的线程上执行,并且每个线程都以交错方式运行属于不同请求的阶段。这对理解程序行为具有深远的影响:堆栈跟踪不提供可用的上下文(响应式的框架堆栈无法全部跟踪),调试器无法单步执行请求处理逻辑,探查器无法将操作的成本与其调用方相关联。当使用 Java 的流 API 在短管道中处理数据时,编写 lambda 表达式是可管理的,但当应用程序中的所有请求处理代码都必须以这种方式编写时,则会出现问题。这种编程风格与 Java 平台不一致,因为应用程序的并发单元 — 异步管道 — 不再是平台的并发单元。这种把函数当成了第一公民,他是注册函数回调函数实现的。所以和java不一样。

为了使应用程序能够扩展,同时保持与平台的和谐,我们应该努力保持每个请求一个线程的样式。我们可以通过更高效地实现线程来做到这一点,因此它们可以更加丰富。操作系统无法更高效地实现 OS 线程,因为不同的语言和运行时以不同的方式使用线程堆栈。但是,Java 运行时可以以切断 Java 线程与 OS 线程的一对一对应关系的方式实现 Java 线程。正如操作系统通过将大型虚拟地址空间映射到有限数量的物理 RAM 来产生大量内存的错觉一样,Java 运行时可以通过将大量虚拟线程映射到少量的 OS 线程来产生大量线程的错觉。这就是虚拟线程的设计目标。

他在我们原有的thread(称之为平台线程),以及操作系统线程(称之为原生线程),之外设计出了第三种线程(称之为虚拟线程),虚拟线程在用户态层面存在,类似于一个对象一样的地位,他和原生线程以及平台线程不存在一对一的关系,不存在必然的关系。虚拟线程是 java.lang.Thread 的一个实例,它不与特定的 OS 线程绑定。相比之下,平台线程是以传统方式实现的 java.lang.Thread 实例,作为 OS 线程的薄包装器。

每个请求一个线程样式的应用程序代码可以在整个请求持续时间内在虚拟线程中运行,但虚拟线程仅在 CPU 上执行计算时消耗操作系统线程。结果是与异步样式相同的可伸缩性,只是它是透明地实现的:当在虚拟线程中运行的代码调用 java.* API 中的阻塞 I/O 操作时,运行时将执行非阻塞 OS 调用并自动挂起虚拟线程,直到以后可以恢复为止。对于 Java 开发人员来说,虚拟线程只是创建成本低廉且几乎无限丰富的线程。硬件利用率接近最佳,允许高级别的并发性,从而实现高吞吐量,同时应用程序与 Java 平台及其工具的多线程设计保持和谐。

在这种模型下虚拟线程你需要理解为一个任务,他运行在平台线程之上,当发生阻塞的时候该任务会"挂起",从而卸载到其他地方(堆上的一个队列)。此时平台线程可以运行其他的虚拟线程。从而无需和任务一一绑定。而这一切都是透明的对于用户,用户无感知这一切,都是在jvm中实现的。

五、虚拟线程的含义

虚拟线程既便宜又丰富,因此永远不应该共用:应该为每个应用程序任务创建一个新的虚拟线程。因此,大多数虚拟线程的生存期较短,并且具有较浅的调用堆栈,执行的次数少至单个 HTTP 客户端调用或单个 JDBC 查询。相比之下,平台线程很重且成本高昂,因此通常必须共用。它们往往生存期很长,具有较深的调用堆栈,并在许多任务之间共享。这里就有一条虚拟线程的金科玉律,我们永远不到池化虚拟线程。

总之,虚拟线程保留了可靠的“每个请求一个线程”样式,该样式与 Java 平台的设计相协调,同时以最佳方式利用了可用的硬件。使用虚拟线程不需要学习新概念,尽管它可能需要养成忘却习惯,以应对当今高昂的线程成本。虚拟线程不仅会帮助应用程序开发人员,还会帮助框架设计人员提供易于使用的 API,这些 API 与平台设计兼容,而不会影响可扩展性。

如今,JDK 中的每个 java.lang.Thread 实例都是一个平台线程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值