JAVA虚线程简介-译

本文加入了部分个人理解,省去了部分和主旨关系较弱的段落,可作为虚线程的概览,有时间推荐阅读非常优秀的原文。原文地址链接:Virtual Threads: New Foundations for High-Scale Java Applications

术语说明

  • virtual thread:虚线程。
  • thread-per-request:每个线程处理一个请求。
  • runtime:运行时。
  • scalability:可扩展性。
  • blocks:阻塞。
  • Platform threads:平台线程,即操作系统的线程。
  • async:异步,或异步编程模式。
  • reactive:响应式编程。
  • Littles Law:Littles定律。

引言

JAVA 19带来了虚线程的预览版,这是对Java的一个重大升级改变,同时也是一个不易察觉的改变。虚线程从根本上改变了「JAVA运行时」和底层操作系统的交互,但API基本没有改变,对于虚拟线程的学习,可能需要抛弃的知识要多于需要学习的知识。

线程

线程是JAVA中的基本概念,当我们执行JAVA程序的时候,main主函数会开启一个“main”线程。如果一个方法调用了另一个方法,被调用的方法会和调用者使用同一个线程,方法的返回值会记录在线程堆栈中。方法中如果使用了局部变量,也是存在线程堆栈中。当发生运行错误,我们可以通过堆栈信息的记录来还原运行上下文线程快照进行排错。线程让一切都很美好:顺序的执行流程,本地变量,异常处理,单步调试等等。线程也是JAVA程序的调度单元,当一个线程在阻塞等待存储设备,网络连接,或者锁的时候,CPU会终止本线程调度,把CPU资源调度到另一个线程运行。

尽管如此,大多数开发者都有过痛苦的,多线程共享状态的并发的开发和调试经历,这些让线程经常伴随着难用的坏名声。确实,共享状态的并发,以及使用线程和锁进行编程非常困难。只是了解编程语言和API文档并不够用,写出安全高效的并发程序需要理解很多微妙的概念,比如内存可见性以及很多并发书写规则,难度足以支撑起一本400页的书(比如作者的著作《Java Concurrency in Practice》)。

虽然线程并发处理起来非常困难,但不要忘记线程拥有更多的优点,比如上文提到的异常处理的堆栈信息,线程调试工具,远程调试,以及代码的顺序执行等。

操作系统平台线程

JAVA的目标是一次书写,到处运行。所以JAVA语言要对线程,线程协作机制,以及内存模型进行抽象,并能够高效的映射到不同的操作系统实现上。

今天大多数JVM都是对操作系统线程的单薄的包装,但这样有一个缺点,操作系统的线程创建相对昂贵和重量级。意味着我们创建的线程数是有限的,这就限制了我们应用程序可以创建的线程数量。

操作系统在线程创建的时候会为线程分配一个固定大小的内存,在分配以后不可以调整大小。虽然大小可以通过命令行或线程的构造函数进行调整,但这依然很难抉择,过大浪费内存,过小耗尽内存会抛出StackOverflowException异常。对比来看尽量为线程分配大些内存是个伤害更小的方案,但对于有限的系统内存来说,我们可以创建的线程数就更少了。

可以创建线程数的限制会造成问题,对于服务端应用来说,对「每个请求会分配一个线程」来处理,比如对于一个中等服务器来说,可以轻易的处理1000并发请求,但在使用同样的技术的前提下,就算CPU和IO依然充足,依然无法处理1万请求(每个线程分配大量内存,1W线程会造成内存不足)。

截止目前,JAVA开发者想要提高并发能力有几个折中的选择:通过写代码限制堆栈内存大小,提升硬件内存,或者使用「异步」或「响应式编程」编程风格。「异步编程」当前越来越流行,使用这些异步框架这意味着我们要放弃掉线程的好处,比如可读的线程堆栈,异常调试,以及可观测性。这些异步框架会进一步让我们放弃JAVA语言的很多优势,本质上这些框架是一种特定领域的语言,会管理整个的计算过程。整个过程牺牲了很多JAVA语言富有成效的东西。

虚线程

虚线程是java.lang.Thread的一个实现类,它的堆栈信息不是存储在操作系统,而是直接存储在了Java的堆内存中。我们不需要猜测一个线程使用多少内存,或者为每一个线程做一个初始化多少内存的评估。虚线程初始化的内存空间只有几百字节,伴随着需要会自动进行扩容或缩容。

操作系统仅仅知道平台线程。为了执行虚线程,JAVA运行时需要把这些虚线程绑定到平台线程上。绑定虚线程意味着把虚线程的堆栈拷贝到平台线程。

当代码在虚线程执行的时候,也会因为等待IO,锁而阻塞,虚线程会从平台线程上卸载,并把平台线程堆栈的变更更新到JAVA堆内存中,腾空后的平台线程可以去做其他事情(比如执行另一个虚线程)。基本上所有阻塞JDK都已经适配,JDK用卸载虚线程代替了阻塞处理。

加载和卸载虚拟线程对JAVA代码并不可见。JAVA代码不知道具体绑定到了哪个平台线程(调用Thread::currentThread返回的也是虚线程);在虚线程的生命周期中,可能会交叉运行于多个不同的平台线程,但是依赖于线程标识的,比如锁,我们可以像使用线程一样方便,忽略掉平台线程的存在,得到一致的观感。

虚线程的命名是因为借鉴了虚拟内存的机制。通过虚拟内存,应用程序有可以访问非常庞大内存的幻觉,不再受物理内存的限制。硬件做了按需进行丰富的虚拟内存到稀缺的物理内存的映射,不需要的内容从内存调出到磁盘,需要的时候从磁盘调出到内存。类似的,虚线程便宜又丰富,它们按需共享稀缺而又昂贵的平台线程,不活跃的虚拟线程会被调出到堆内存,活跃的进行调入绑定到平台线程来运行。

虚线程有相对少的新API。有几个新的方法来创建虚线程(比如,Thread::ofVirtual),但是创建后,它们就和我们平时使用的Thread对象没什么两样。现存的Thread的API比如Thread::currentThread,ThreadLocal,堆栈信息等等,依然可以在虚线程中使用,现有代码基本不用改造就可以以使用虚线程来运行。

下面的例子介绍了如何使用虚线程并发的抓取两个URL,并且聚合它们的结果。它创建了一个ExecutorService来运行为每个运行任务分配一个虚拟线程,提交任务,然后等待结果。

```plaintext void handle(Request request, Response response) { var url1 = ... var url2 = ...

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    var future1 = executor.submit(() -> fetchURL(url1));
    var future2 = executor.submit(() -> fetchURL(url2));
    response.send(future1.get() + future2.get());
} catch (ExecutionException | InterruptedException e) {
    response.fail(e);
}

}

String fetchURL(URL url) throws IOException { try (var in = url.openStream()) { return new String(in.readAllBytes(), StandardCharsets.UTF_8); } } ```

通过阅读这段代码,我们可能立刻开始担心为「这么短的活动创建线程」是否过于浪费?但这正是我们要说的虚线程和线程用法的不同,这种用法是虚拟线程的正常用法,后续「要抛弃的观念」部分会进一步解释原因。

虚线程是为了扩展性

尽管创建的开销不同,虚线程并不会比平台线程更快。我们不可能让一秒内虚线程比平台线程执行更多的计算。这些都受可用的CPU核数限制,所以虚线程有什么好处呢?因为它们足够轻量,我们可以拥有比平台线程更多的不活跃的虚线程。乍听起来,好像并无大用!但是“许多的非活跃线程”实际正是服务器端应用的真实情况。服务器端程序大部分时间都在等待网络连接,文件,数据库I/O等阻塞,只有很少的时间在运算。所以如果我们运行每个任务拥有自己的线程,大部分线程大部分时间都在阻塞。虚拟线程可以让IO等待多的应用突破线程数量的瓶颈,更好的利用计算机硬件。虚线程让我们取得了很好的平衡:它对平台线程进行了很好的互补,更充分利用了硬件资源。

对于CPU密集型的应用,虚拟线程并不擅长,虚拟线程更擅长处理IO密集型工作。

Littles定律

一个稳定系统的扩展性受Littles定律的控制,如果每个请求都有一个处理周期(duration),我们同时执行N个并发任务(nTask),那么我们的吞吐量(throughput)可以如下计算:

plaintext throughput = nTask / duration

这样比较抽象,举个实际的例子,比如一个银行ATM处理每个顾客(处理周期duration)为6分钟/0.1小时,我们的自助银行有5个ATM机可以同时容纳5个顾客(N个并发任务nTask)同时存取款,那么我们自助银行的的吞吐量Throughput为每小时服务50个顾客。

plaintext throughput = 5个ATM并发 / 0.1小时 = 每小时50个顾客的吞吐量

如果我们想每个小时服务100个顾客,那么或者我们加大ATM的数量到10台(水平扩展);或者提高单个ATM的平均服务效率,从6分钟降低到3分钟(垂直优化)。

换做服务器端计算机模型就是:吞吐量=线程数/每个任务的平均处理时间。如果我们想提升服务器端程序的吞吐量,或者增加线程数,或者降低单个请求的平均处理时间,虚线程通过降低创建开销,可以极大提升线程数,根据Littles定律,也就大大提升了系统的吞吐量。

虚拟线程的运行

虚拟线程并不会取代平台线程;它们是互补的。很多服务器应用大概率会选择虚线程来达到更高的系统吞吐量。

下面的示例创建了10万虚拟线程,通过休息一秒钟来模拟IO阻塞。它创建了一个为每个任务分配一个虚拟线程的Executor,并且通过拉姆达表达式提交任务。

plaintext try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 100_000).forEach(i -> { executor.submit(() -> { Thread.sleep(Duration.ofSeconds(1)); return i; }); }); } // close() called implicitly

在一个普通的桌面系统冷启动运行这个程序大概需要1.6秒,如果通过预热后运行只需要1.1秒。作为对照如果我们通过一个线程池来运行,取决于我们有多少内存可用,它可能会在没有完成提交所有任务就崩溃并抛出OutOfMemoryError。如果我们通过一个固定大小的线程池,比如设定1千个线程,虽然不会崩溃,但根据Littles定律可以准确的预测会需要100秒来完成。

需要抛弃的观念

因为虚线程和线程的API基本相同,相对来说学习虚拟线程的使用的成本很低。但是如果我们想要更高效的使用虚线程,需要抛弃很多原来线程的固有观念。

虚线程不需要都线程池

最需要抛弃的是线程的创建方法,Java5带来了并发编程包,Java开发者已经学会了要通过线程池来创建线程,这比直接创建要好。但随着虚拟线程的到来,线程池成了反模式。(实际使用中,线程池的创建做了API兼容,我们并不需要抛弃ExcutorService,只需要使用新的工程方法Excutors::newVirtualThreadPerTaskExecutor 来得到一个ExecutorService,它会为每个任务创建一个虚拟线程。)

因为创建虚拟线程的开销非常小,创建虚线程是非常的廉价,不管是时间上还是内存开销上都远低于平台线程,所以我们创建线程的经验并不适用于虚线程。关于平台线程,我们需要池化处理,是因为受资源的限制,并没有充足的内存,通过把「单个线程的创建成本」均摊到「处理多个请求」来降低成本。另一方面来说,创建虚线程成本如此廉价,池化反而变成了一个愚蠢的主意!我们并没有取得降低内存使用的收益,因为开销太小了,创建百万级别的虚拟线程仅需要1G的内存。我们也很难取得摊薄创建成本的收益,因为创建开销也很小。而且线程池也有自己的问题,比如ThreadLocal的污染问题。

虚拟线程如此轻量,可以非常完美的来创建短生命周期的任务。实际上,虚拟线程就是为这些短生命周期的任务而设计的,比如抓取HTTP的请求或者执行一个JDBC的查询。

响应式编程(reactive)

很多所谓的“异步化”或“响应式编程”框架提供了提升资源利用率的手段,要求研发人员在「每个线程处理一个请求」的场景下通过如下方式实现:异步IO,回调,以及线程共享模型。在这种模型下,如果一个活动需要执行IO,它需要初始化一个异步的操作,这个异步操作会在完成的时候执行回调函数。这个框架会在一些线程上执行回调函数,并不一定是初始化操作那个线程。这意味着开发者必须打破逻辑思考链条,纠结到IO和计算分离开,然后缝合在一起的复杂过程中去。因为一个请求仅在实际执行「计算点啥」的时候才需要使用线程,所以并发请求数并不应该受线程数的制约,线程数量也不应该成为应用吞吐量的制约因素。

但是这种扩展性付出了高昂的代价:你需要放弃掉JAVA平台和生态的很多基础功能。在「每个线程处理一个请求」的模型中,如果你想要顺序的执行两件事,只需要顺序的书写代码。如果你想要构建你的循环,条件,或者异常处理代码块,直接写就好了。但是在异步化编程风格中,你经常不能方便的进行顺序的合成,循环迭代,以及编程语言给你的其他特性。这些都需要异步框架的API来模拟。一个模拟循环或者条件语句的API永远不会比编程语言本身的更灵活和容易阅读维护。如果我们使用一些执行阻塞的操作的框架库,如果这些框架没有做异步化风格的适配,我们就无法使用它们。所以我们虽然从这些模型得到了扩展性,但我们放弃了编程语言和生态中非常有用的部分。

这些框架还让我们放弃了很多让JAVA更简单的「运行时」的特性。因为请求在每个阶段可能在不同的线程执行,同一个服务的线程可能会不同的时间片交织分配用于处理不同的请求,我们平时使用的异常调试工具会失效,比如堆栈信息,线程调试工具,线程分析工具。这些异步编程风格和JAVA平台并不一致。另一方面,虚拟线程,允许我们获取同样吞吐量提升的收益,但是并不要求我们放弃语言和运行时的特性。

API的变化

虚拟线程是java.lang.Thread 的一个实现类,所以只是对Thread的API进行了一些扩展来支持新的特性。比如新的工厂方法Thread::ofVirtual和Thread::ofPlatform,一个新的Thread.Builder类,和一个Thread::startVirtualThread来创建一个一次性执行的任务。原来的线程构造函数和以前一样,只是仅可以用来创建平台线程而已(当然这是合理的兼容,老的API本就是创建平台线程)。

----剩余部分有时间继续翻译。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值