Java虚拟线程
清醒点
Java虚拟线程
翻译自 screencapture-pradeesh-kumar-medium-an-era-of-virtual-threads-java
简介
“虚拟线程”的概念越来越火,很多编程语言都尝试将其加入到线程库中,Java也不例外。JDK19中便加入了虚拟线程(预览版)JEP425。本文主要深入浅出介绍线程的前世今身,以及虚拟线程带来的全新体验和优势,最后会对几种不同的线程实现方式进行对比
线程(Thread)简介
一个电脑程序,本质上就是实现特定任务任务的一系列指令。当你加载一个程序时,操作系统加载程序文件,并将其放置在一个指定区域(地址空间),然后执行它所包含的指令。这时候,它就被作为了一个“进程”。换句话说,一个进程就是一个程序运行在电脑中的实例。
一个线程就是进程里的一系列可以独立运行的指令,通常线程在CPU的一个核上运行。一个进程可以拥有多个线程,允许多个线程同时执行即同时执行多个任务,这样可以更好的利用CPU资源,提高任务吞吐量。例如,当你加载谷歌浏览器,系统便创建了一个谷歌浏览器的进程。你可以同事做很多事情,比如说同时下载文件和浏览网页,因为这些功能运行在不同的线程之上。线程也可以叫做轻量级进程,因为线程之间共享了进程的地址空间。
并行(parallel)与并发(concurrent)执行
并行执行:同时执行多个任务。例如,一台四核的机器,可以每个核执行一个任务。所有的任务是同时执行的。
并发执行::电脑制造了一种同时执行任务数比CPU核数更多的错觉。例如,一个四核的电脑,可以执行8个不同的任务。因为你只有四核,所以必须做上下文切换来执行8个任务。在这里操作系统制造了一种并行执行8个任务的错觉。然而,实际上只有四个任务可以并行执行,因为只有四核。
并行执行图例:
并发执行图例:
为什么要使用多线程?
首先,让我们研究一下线程是怎样提高我们的系统效率的。
假如你现在有一台四核的电脑,你要写一个两数之和的程序,同时要求必须执行在12个线程中。
代码如下:
public class SumOfNums {
static void sum() {
int a = 1;
int b = 2;
int sum = a + b;
System.out.println(sum);
}
public static void main(String[] args) {
for (int i = 0; i < 12; i++) {
Thread t = new Thread(SumOfNums::sum);
t.start();
}
}
}
那么有多少线程可以并行执行呢?是否12个线程都可以并行执行?我们创建了12个线程,是否意味着12个线程是同时开始执行的?答案是否定的。我们的CPU只有四核,意味着我们并行执行的线程数上线就是四核。每个线程都必须分配到一个指定的核去执行,通过上下文切换来完成12个线程的并发执行。
那么为什么一个应用会有数百个线程?有什么用处?为什么我们不创建恰好等于CPU核数的线程?让我们更深入的研究一下。
任务通常有两种类型:CPU密集型以及IO密集型。
CPU密集型:任务执行高度依赖CPU,例如算术,逻辑,关联关系等,这些任务都是CPU密集型;
IO密集型:任务执行高度依赖输入/输出操作,例如网络交互,读取/存储文件,这些任务都是IO密集型;
那么我们上面提到的sum
任务属于哪一种呢?我们创建并初始化了两个变量a
和b
,将他们求和并输出到命令行。从始至终都没有任何的IO操作,数值计算是一个CPU操作,将结果写到两一个文件就是一个IO操作了。
通常来说,一个任务会混合CPU和IO操作。例如读取一个文本文件,并计算出其中所有的不同的单词,然后将结果写入到另一个文本文件。在这种情况下,读取和写入文件是一个IO操作,计算不同的单词是一个CPU操作。
多线程如何影响系统的效率?
再思考一下上面的CPU密集型求和代码。我们使用了12个线程并发执行这段代码。CPU内部切换前后台线程,这样就可以用4个核心完成12个线程的任务。如果还没有完成一个任务的时候,CPU会切换到另一个线程。
12个线程真的会提升效率吗?并非如此。当前的情况下,这里浪费了很多时间在切换上下文上。对于CPU密集型任务,最好使用和核心数统统的并发线程数,以达到最高的效率。
对于唯一词计算效率又该如何呢?
考虑这样一种情况,需要读取20个文本文件。CPU在读取文件时处于空闲状态,因为文件读取发生在硬盘驱动器上。所以只有当文件上下文都获取到了之后,CPU才会开始计算然后将结果写入到另一个文件。再写入过程中,CPU始终处于空闲状态,等待硬盘驱动器。
也就是说,我们执行的是如下图所示的单线程操作。
如上图所示,CPU在IO进行的时候都是空闲的。这导致了CPU的核心始终跑不满100%占用率。
现在,我们考虑一下当我们在4核心CPU上跑4个线程。如下图所示:
每一个线程被赋予了一个指定的核以并发执行。结果就导致了四核出现了与上面单核一样占用率不满的问题,IO期间核心依然是空闲的。
增加线程是否可以解决这个问题呢?
然我们来看一下如果我们使用八个线程会怎样:
首先来看第一个核core1
,当thread1
进行IO操作时,core1
此时是空闲的。然而,当存在多个未执行线程时,核心会切换到另一个线程开始执行,直到thread1
完成了IO操作。这种方式可以最大化利用资源,提升多线程时的表现。
所以,在我们的第一个求和任务中,我们使用了比核心更多的线程数量没有获得效率提升,是因为浪费了过多的资源在上下文切换上。但是这里,8个线程提高了执行效率。我们从中可以学到,选择线程的数量,取决于IO操作频率和时间占用。IO操作占比越高,就需要越多的线程来提高执行效率。
线程内部是怎样工作的?
在我们继续研究虚拟线程前,必须要知道线程有哪些分类,以及他们怎么工作。
通常,现在操作系统中有两种不同的线程:核心线程以及用户线程
1. 核心线程
通常也叫做系统线程。核心线程通常由操作系统内核来安排管理。每个内核中的线程线程有一个TCB(Thread Control Block),其中包括了线程的优先级,状态,以及其它配置信息。核心线程是重量级的,需要系统调用来创建,调度,以及同步。
2. 用户线程
用户线程一般使用用户线程库进行管理和调度,不需要操作系统内核的干涉。内核不能意识到用户线程的变化。每个用户线程代表了一个应用中不同的数据结构,包括线程的状态信息和配置信息。用户线程是轻量级的,创建和销毁比系统线程更快。但是,依然会收到一些明确的限制,例如不能够享受到多处理器或者多核的优秀性能。
简单讲,当一个进程启动时,会启动一个默认线程,执行应用入口的main
方法。随后,进程会创建自己需要的额外线程。用户线程是不能直接执行的,必须映射到一个制定的内核线程,然后通过内核线程执行执行。用户线程和内核线程的映射关系有以下三种:
- M:1:所有的用户线程对应到一个内核线程上,通过库调度器进行调度;
- 1:1:每个用户线程对应一个内核线程;
- M:M:所有的用户线程映射到了一个内核线程池;
Java内部线程实现模式
绿色线程(Green Thread):远古时期,Java使用绿色线程模式。这个模式下,多线程的调度和管理有JVM完成。绿色线程模式才作用M:1线程映射模型。这里就有一个问题,Java不能够规模化管理这种线程,也就无法充分发挥硬件性能。同样的实现绿色线程也是一件非常有挑战性的事情,因为它需要非常底层的支持才能够良好运行。随后java移除了绿色线程,转而使用本地线程。这使得Java的线程执行比绿色线程更慢。
本地线程(Native Thread):从Java1.2开始从绿色线程切换到了本地线程模式。在操作系统的帮助下,JVM得以控制本地线程。本地线程的执行效率很高,但是开启和关闭他们的资源消耗较大。这就是为什么我们现在要使用线程池。这个模型遵循着1:1线程映射,即一个Java线程映射到一个内核线程。当一个java线程被创建时,相应的一个对应的核心线程也会被创建,用来执行线程代码。自此之后,本地线程模型的做法就延续到了今天。
当前Java线程模型有什么问题吗?
上面的章节中中,我们知道Java已经使用了本地线程模式。让哦我们看看这个模式有什么问题:
- Java的线程库已经很老旧了;
- 只是对于本地线程的一个简单包装;
- 本地线程的创建和管理资源消耗较大;
- 本地线程需要保存他们的调用栈在内存中,大概2MB~20MB的预留空间。如果你有4GB内存,如果每个线程占用20MB内存,那么你就只能创建大概200个线程;
- 因为本地线程是一种系统资源,加载一个新的本地线程大概需要1毫秒;
- 上下文切换代价昂贵,需要一个到内核的系统调用;
- 上面这些强制性的限制会限制线程创建的数量,同时会导致性能下降和过度的内存消耗。因为我们不能创建更多的线程;
- 我们不能通过增加更多的线程来增应用规模,因为上下文切换和内存占用的代价高昂;
现实世界的例子
考虑一台16GB内存的网络服务器。对于每个服务请求,都分配一个不同的线程。我们假设每个线程需要20MB内存空间,那么这台机器可以支持800个线程。当前,后端的API一般使用REST/SOAP调用方式,例如数据库操作和API信息转发这些IO密集型操作。由此可见,后端服务的主要是IO密集型而不是CPU密集型。
接着假设一下,一个IO操作需要100毫秒,请求执行(IO密集型)需要100毫秒,以及返回结果也需要100毫秒。同时,当每秒有800个请求时,线程数得到了最大容量。
让我们来计算一下单个请求的CPU占用时间
CPU时间 = 请求准备时间 + 返回结果准备时间
= 0.1ms + 0.1ms
= 0.2ms
对于800个请求呢?
800个线程的请求时间= 800 * 0.2ms
= 160ms
受限于我们的内存容量,我们只能创建800个请求,也就导致了我们CPU使用率并不高
PUC使用率=160ms / 1000ms
= 16%
那么如何才能使CPU的利用率到达90%呢?
16% = 800个线程
90% = X个线程
X = 4500
但是我们当前因为内存的限制不能创建那么多的线程,除非我们能突破这个限制,拥有90G内存。
90G的内存是一个比较离谱的数字,所以说创建本地线程很明显不能充分利用硬件资源。
虚拟线程(Virtual Thread)
虚拟线程是一个Java线程的轻量级实现版本,最早于JDK19中出现,当前仍是预览状态,可以通过Jvm配置项开启。
虚拟线程是JVM项目loom的一部分
虚拟线程解决了传递和维护本地线程的瓶颈问题,同时可以用之编写高吞吐的并发应用,榨干硬件资源的潜力。
与本地线程不同,虚拟线程并不有操作系统控制,虚拟线程是一个有JVM管理的用户态线程。对比于本地线程的高资源占用,每个虚拟线程只需要几个字节的内存空间。这是的它更适合控制管理大量的用户访问,或者说处理IO密集型任务。
在创建虚拟线程的数量上几乎没有限制,甚至可以创建一百万个,因为虚拟线程并不需要来自内核的系统调用。
在虚拟线程如此轻量化的条件下,线程池不再成为必须品,只需要在需要的时候尽情创建虚拟线程就好。
虚拟线程和传统的本地线程操作完全兼容,例如本地线程变量,同步块,线程中断,等等。
虚拟线程如何工作
JVM管理着一个本地线程的线程池。一个虚拟线程想要进行CPU操作时,就把自己关联到一个池中本地线程的队列中。当虚拟线程中的CPU操作执行完毕后,JVM会自动解除关联并挂起该虚拟线程,同时切换到并执行另一个虚拟线程。这就是为什么我们可以创建很多的虚拟线程,并且他们是如此的轻量级。
JVM使用M:N来完成虚拟线程与本地线程的映射。
Java虚拟线程实例
- 在现存的线程使用新的
ofVirtual()
工厂方法:
for (int i = 0; i < 5; i++) {
Thread vThread = Thread.ofVirtual().start(() -> System.out.println("Hellow World!!!"));
}
- 使用新的
Executors
工厂的newVirtualThreadExecutor()
方法:
public static void main(String[] args) throws InterruptedException {
var executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 16; i++) {
executor.submit(()-> System.out.println("Hello World!!!"));
}
executor.awaitTermination(1, TimeUnit.SECONDS);
System.out.println("Finished");
}
绿色线程 VS 虚拟线程
线程类型 | 简介 | 映射 |
---|---|---|
绿色线程 | 在一个系统线程上运行多个绿色线程 | M:1 |
平台线程(Java当前使用) | 系统线程的包装 | 1:1 |
虚拟线程 | 在多个系统线程中运行多个虚拟线程 | M:N(M>N) |
总结
总而言之,虚拟线程的新特性先对于传统多线程拥有很多优势。通过在用户空间提供的轻量化并发模型,虚拟线程使得编写并发程序更容易,使得大规模的线程并发成为可能。
参考
[1] screencapture-pradeesh-kumar-medium-an-era-of-virtual-threads-java
热门相关:超武穿梭 法医王妃不好当! 朕 学霸女神超给力 法医娇宠,扑倒傲娇王爷