Java虚拟线程

清醒点

Java虚拟线程

翻译自 screencapture-pradeesh-kumar-medium-an-era-of-virtual-threads-java

flowchart LR introduction-->a(why thread)-->b(parallel and concurrent execution)-->c(why threads?)-->d(how does thread work)-->e(java thread model)-->f(virtual thread)-->g(why virtual thread)-->h(comparasion) a-->c d-->f

简介

“虚拟线程”的概念越来越火,很多编程语言都尝试将其加入到线程库中,Java也不例外。JDK19中便加入了虚拟线程(预览版)JEP425。本文主要深入浅出介绍线程的前世今身,以及虚拟线程带来的全新体验和优势,最后会对几种不同的线程实现方式进行对比

线程(Thread)简介

一个电脑程序,本质上就是实现特定任务任务的一系列指令。当你加载一个程序时,操作系统加载程序文件,并将其放置在一个指定区域(地址空间),然后执行它所包含的指令。这时候,它就被作为了一个“进程”。换句话说,一个进程就是一个程序运行在电脑中的实例。

一个线程就是进程里的一系列可以独立运行的指令,通常线程在CPU的一个核上运行。一个进程可以拥有多个线程,允许多个线程同时执行即同时执行多个任务,这样可以更好的利用CPU资源,提高任务吞吐量。例如,当你加载谷歌浏览器,系统便创建了一个谷歌浏览器的进程。你可以同事做很多事情,比如说同时下载文件和浏览网页,因为这些功能运行在不同的线程之上。线程也可以叫做轻量级进程,因为线程之间共享了进程的地址空间。

flowchart LR disk--load-->memory--assign-->process process--include-->thread1 process--include-->thread2 process--include-->thread3 process--include-->thread4

并行(parallel)与并发(concurrent)执行

并行执行:同时执行多个任务。例如,一台四核的机器,可以每个核执行一个任务。所有的任务是同时执行的。

并发执行::电脑制造了一种同时执行任务数比CPU核数更多的错觉。例如,一个四核的电脑,可以执行8个不同的任务。因为你只有四核,所以必须做上下文切换来执行8个任务。在这里操作系统制造了一种并行执行8个任务的错觉。然而,实际上只有四个任务可以并行执行,因为只有四核。

并行执行图例:

flowchart LR core1-->thread1 core2-->thread2 core3-->thread3 core4-->thread4

并发执行图例:

flowchart LR core1-->a1(thread1)-->a2(thread4)-->a3(thread1)-->a4(thread8)-->a5(thread4) core2-->b1(thread2)-->b2(thread7)-->b3(thread2)-->b4(thread7)-->b5(thread2) core3-->c1(thread3)-->c2(thread5)-->c3(thread10)-->c4(thread3)-->c5(thread5) core4-->d1(thread5)-->d2(thread9)-->d3(thread5)-->d4(thread11)-->d5(thread1)

为什么要使用多线程?

首先,让我们研究一下线程是怎样提高我们的系统效率的。

假如你现在有一台四核的电脑,你要写一个两数之和的程序,同时要求必须执行在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任务属于哪一种呢?我们创建并初始化了两个变量ab,将他们求和并输出到命令行。从始至终都没有任何的IO操作,数值计算是一个CPU操作,将结果写到两一个文件就是一个IO操作了。

通常来说,一个任务会混合CPU和IO操作。例如读取一个文本文件,并计算出其中所有的不同的单词,然后将结果写入到另一个文本文件。在这种情况下,读取和写入文件是一个IO操作,计算不同的单词是一个CPU操作。

多线程如何影响系统的效率?

再思考一下上面的CPU密集型求和代码。我们使用了12个线程并发执行这段代码。CPU内部切换前后台线程,这样就可以用4个核心完成12个线程的任务。如果还没有完成一个任务的时候,CPU会切换到另一个线程。

12个线程真的会提升效率吗?并非如此。当前的情况下,这里浪费了很多时间在切换上下文上。对于CPU密集型任务,最好使用和核心数统统的并发线程数,以达到最高的效率。

对于唯一词计算效率又该如何呢?

考虑这样一种情况,需要读取20个文本文件。CPU在读取文件时处于空闲状态,因为文件读取发生在硬盘驱动器上。所以只有当文件上下文都获取到了之后,CPU才会开始计算然后将结果写入到另一个文件。再写入过程中,CPU始终处于空闲状态,等待硬盘驱动器。

也就是说,我们执行的是如下图所示的单线程操作。

flowchart LR a(CPU)--wait-->b(__________IO__________)--hook-->c(CPU calculation)--wait-->d(____________IO__________)--hook-->e(CPU)

如上图所示,CPU在IO进行的时候都是空闲的。这导致了CPU的核心始终跑不满100%占用率。

现在,我们考虑一下当我们在4核心CPU上跑4个线程。如下图所示:

flowchart LR a1(CPU)--wait-->b1(__________IO__________)--hook-->c1(CPU calculation)--wait-->d1(____________IO__________)--hook-->e1(CPU) a2(CPU)--wait-->b2(__________IO__________)--hook-->c(CPU calculation)--wait-->d2(____________IO__________)--hook-->e2(CPU) a3(CPU)--wait-->b3(__________IO__________)--hook-->c3(CPU calculation)--wait-->d3(____________IO__________)--hook-->e3(CPU) a4(CPU)--wait-->b4(__________IO__________)--hook-->c4(CPU calculation)--wait-->d4(____________IO__________)--hook-->e4(CPU)

每一个线程被赋予了一个指定的核以并发执行。结果就导致了四核出现了与上面单核一样占用率不满的问题,IO期间核心依然是空闲的。

增加线程是否可以解决这个问题呢?

然我们来看一下如果我们使用八个线程会怎样:

flowchart LR core1-->a1(thread1)-->a2(thread2__)-->a3(_____thread1______)-->a4(__thread2__)-->a5(thread1) core2-->b1(thread3)-->b2(____thread4____)-->b3(thread3)-->b4(thread4)-->b5(thread2) core3-->c1(thread6)-->c2(thread5)-->c3(thread10)-->c4(thread3)-->c5(thread5) core4-->d1(thread5)-->d2(thread6)-->d3(thread5)-->d4(thread8)-->d5(thread1)

首先来看第一个核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虚拟线程实例

  1. 在现存的线程使用新的ofVirtual()工厂方法:
for (int i = 0; i < 5; i++) {
    Thread vThread = Thread.ofVirtual().start(() -> System.out.println("Hellow World!!!"));
}
  1. 使用新的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

热门相关:超武穿梭   法医王妃不好当!      学霸女神超给力   法医娇宠,扑倒傲娇王爷