目录
线程的实现(OS&&JVM)
1.内核线程实现
2.用户线程实现
3.混合实现
4.Java线程的实现 ——如何实现不受Java虚拟机规范的约束
Java线程调度——系统自动完成(可能被干预)
Java线程状态转换
内核线程的局限
协程的复苏——Java19中的虚拟线程
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。
目前线程 是Java里面进行处理器资源调度的最基本单位。
主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作 的统一处理,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。
实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现), 使用用户线程加轻量级进程混合实现(N:M实现)。
使用内核线程实现的方式也被称为1:1实现。
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核 (Multi-Threads Kernel)。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。
这种轻量级进程与内核线程之间1:1 的关系称为一对一的线程模型。
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程 在系统调用中被阻塞了,也不会影响整个进程继续工作。
轻量级进程也具有它的局限性:
使用用户线程实现的方式被称为1:N实现。
广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不具备通常意义上的用户线程的优点。
狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存 在及如何实现的。
用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。
如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
这种进程与用户线程之 间1:N的关系称为一对多的线程模型。
用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都 需要由用户程序自己去处理。
线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。
因为使用用户线程实现的程序通 常都比较复杂,除了有明确的需求外(譬如以前在不支持多线程的操作系统中的多线程程序、需要支持大规模线程数量的应用),一般的应用程序都不倾向使用用户线程。
Java、Ruby等语言都曾经使用过用户线程,最终又都放弃了使用它。
但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等,使得用户线程的使用率有所回升。
一种将内核线程与用户线程一起使用的实现方式,被称为N:M实现。
在这种混合实现下,既存在用户线程,也存在轻量级进程。
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁, 这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。
在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系
Java线程在 早期的Classic虚拟机上(JDK 1.2以前),是基于一种被称为“绿色线程”(Green Threads)的用户线程 实现的,但从JDK 1.3起,“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。
HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提 供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等,都是由操作系统完成的,也都是由操作系统全权决定的。
线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式 (Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。
协同式调度
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行 完了之后,要主动通知系统切换到另外一个线程上去。
协同式多线程的最大好处是实现简单,而且由 于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。
Lua语言中的“协同例程”就是这类实现。它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
很久以前的Windows 3.x系统就是使用协同式来实现多进程多任务的,那是相当不稳定的,只要 有一个进程坚持不让出处理器执行时间,就可能会导致整个系统崩溃。
抢占式调度
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线 程本身来决定。
譬如在Java中,有Thread::yield()方法可以主动让出执行时间(线程变成Ready态),但是如果想要主动获取执行时间,线程本身是没有什么办法的。
在这种实现线程调度的方式下,线程的执行时间是系统可控 的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。Java使用的线程调度方式就是抢占式调度。
与前面所说的Windows 3.x的例子相对,在Windows 9x/NT内核中就是使用抢占式来实现多进程 的,当一个进程出了问题,我们还可以使用任务管理器把这个进程杀掉,而不至于导致系统崩溃。
设置线程优先级——并不是一项稳定的调节手段
“建议”操作系统给某些线程多分配一 点执行时间,另外的一些线程则可以少分配一点——这项操作是通过设置线程优先级来完成的。
Java 语言一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
现代的操作系统基本都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应,如Solaris中线程有 2147483648(2的31次幂)种优先级,但Windows中就只有七种优先级。
Java线程优先级与Windows线程优先级之间的对应关系优先级可能会被系统自行改变,例如在Windows系统中存在一个叫“优先级推进器”的功能(Priority Boosting,当然它可以被关掉),大致作用是当系统发现一个线程被执行得特别频繁时,可能会越过线程优先级去为它分配执行时间,从而减少因为线程频繁切换而带来的性能损耗。因此,我们并不能在程序中通过优先级来完全准确判断 一组状态都为Ready的线程将会先执行哪一个。
Java语言定义了6种线程状态,在任意一个时间点中,一个线程只能有且只有其中的一种状态,并 且可以通过特定的方法在不同状态之间转换。
现代B/S系统中一次对外部业务请求的响应,往往需要分布在不同机器上的大量服务共同协作来实现,这种服务细分的架构在减少单个服务复杂度、增加复用性的同时,也不可避免地增加了服务的数量,缩短了留给每个服务的响应时间。
这要求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;也要求每一个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待。
Java目前的并发编程机制就与上述架构趋势产生了一些矛盾,1:1的内核线程模型是如今Java虚拟 机线程实现的主流选择,但是这种映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。
以前处理一个请求可以允许花费很长时间在单体应用中,具有这种线程切换的成本也是无伤大雅的,但现在在每个请求本身的执行时间变得很短、数量变得很多的前提下, 用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。
内核线程的调度成本主要来自于用户态与核心态之间的状态转换,而这两种状态转换的开销主要 来自于响应中断、保护和恢复执行现场的成本。
假设发生了这样一次线程切换: 线程A -> 系统中断 -> 线程B
处理器要去执行线程A的程序代码时,并不是仅有代码程序就能跑得起来,程序是数据与代码的 组合体,代码执行时还必须要有上下文数据的支撑。
而这里说的“上下文”,以程序员的角度来看,是方法调用过程中的各种局部的变量与资源;
以线程的角度来看,是方法的调用栈中存储的各类信息;
而以操作系统和硬件的角度来看,则是存储在内存、缓存和寄存器中的一个个具体数值。
物理硬件的各种存储设备和寄存器是被操作系统内所有线程共享的资源,当中断发生,从线程A切换到线程B去执行之前,操作系统首先要把线程A的上下文数据妥善保管好,然后把寄存器、内存分页等恢复到线程B挂起时候的状态,这样线程B被重新激活后才能仿佛从来没有被挂起过。
这种保护和恢复现场的工作,免不了涉及一系列数据在各种寄存器、缓存中的来回拷贝,当然不可能是一种轻量级的操作。
如果说内核线程的切换开销是来自于保护和恢复现场的成本,那如果改为采用用户线程,这部分 开销就能够省略掉吗?答案是“不能”。
协程——协同式、非协同式、可自定义调度
“有栈协程”:会完整地做调用栈的保护、恢复工作。
“无栈协程”:无栈协程本质上是一种有限状态机,状态保存在闭包里,自然比有栈协程恢复调用栈要轻量得多,但功能也相对更有限。典型应用,即各种语言中的await、async、yield这类关键字。
协程的主要优势是轻量,无论是有栈协程还是无栈协程,都要比传统内核线程要轻量得多。
协程局限:需要在应用层面实现的内容(调用栈、调度器这些)特别多