高效并发

Java 内存模型与线程

并发处理的广泛应用是 Amdahl 定律取代摩尔定律成为计算机性能发展源动力的根本原因。

概述

让计算机同时去做几件事情,一个很重要的原因是计算机的运算速度与它的存储和通信子系统的速度差距太大,大量的时间都被花费在磁盘I/O、网络通信或者数据库访问上。

衡量一个服务性能的高低好坏,TPS(Transactions Per Second,每秒事务数)是重要的指标,它代表一秒内服务器能响应的请求总数,TPS 值与程序的并发能力又有非常密切的关系。对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就会越高;反之,线程之间频繁争用数据,互相阻塞,甚至死锁,将会大大降低程序的并发能力。

无论语言、中间件和框架再如何先进,开发人员都不应期望它们能独立完成所有并发处理的事情,了解并发的内幕仍然是成为一个高级程序员不可缺少的课程。

硬件效率与一致性

物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义。

“让计算机并发执行若干个运算任务” 与 “更充分的领用计算机处理器的效能” 之间的因果关系并没有想象中的简单,其中的重要的复杂性是绝大多数的运算任务都不可能只靠处理器”计算”就能完成。处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个 I/O 操作是很难消除的(无法仅靠寄存器来完成所有的运算任务)。由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或者多层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到告诉缓存中,让运算能快速进行,当运算结果结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。

基于高速缓存的存储交互很好的解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来更高的复杂度:缓存一致性(Cache Coherence)。在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统称为共享内存多核系统(Shared Memory Multiprocessors System),如图所示:

5-12-1.drawio.png

当多个处理器的运算任务都设计同一块主内存区域,将可能导致各自的缓存数据不一致。如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议有 MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及 Dragon Protocol等。内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象。

除了高速缓存,处理器可能会对输入代码进行乱序执行优化。处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序(Instruction Reorder)优化。

Java 内存模型

《Java虚拟机规范》中曾试图定义一种 “Java 内存模型”(Java Momory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致性的内存访问效果。

::: tip
定义Java内存模型并非一件容易的事情,这个模型必须足够严谨,才能让 Java 的并发访问操作不会产生歧义;但是也必须定义得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性来获取更好的执行速度。经过漫长的验证与修补,直至 JDK5 (实现了 JSR-133)发布后,Java内存模型才终于成熟、完善起来。
:::

主内存与工作内存

Java内存模型的主要目的是定义程序中的各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
::: tip
此处的变量包括了示例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数。
:::

Java内存模型规定了所有的变量都储存在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图所示:

5-12-2.drawio.png

::: warning
这里所讲的主内存、工作内存和Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有关系的。
如果硬要将两者勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优选存储于寄存器和告诉缓存中,因为程序运行时主要访问的是工作内存。
:::

内存间的交互操作

关于主内存于工作内存之间的具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了8中操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的:

  • lock:作用于主内存的变量,他把一个变量标识为一条线程独占的状态
  • unlock:作用于主内存的变量,他把一个处于锁定状态的变量释放出来
  • read:作用于主内存的变量,他把一个变量从主内存中传输至工作内存中
  • load:作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存的变量副本中
  • use:作用于工作内存的变量,它把工作内存中的一个变量传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign:作用于工作内存的变量,它把一个从执行引擎得到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store:作用域工作内存的变量,它把工作内存的一个变量值传送到主内存中
  • write:作用域主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就需要按顺序执行 store 和 write 操作。除此之外,Java内存模型还规定了子执行上述8中基本操作时必须满足如下规则:

  • 不允许 read 和 load、store 和 write 操作之一单独出现
  • 不允许一个线程丢弃它最近的assign操作
  • 不允许一个线程无原因地把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次lock后,只有执行相同次数的unlock操作,变量才会解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值
  • 如果一个变量事先没有被 lock 锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中。

::: tip
这8中内存访问操作以及上述规则限定,再加上volatile的一些特殊规定,就已经能准确描述出Java程序中哪些内存访问操作再并发下才是安全的。
但是这种定义虽然相当严谨,但也极为烦琐,实践起来更是无比麻烦。Java团队后续将内存模型简化为 read、write、lock和unlock,但这只是语言描述上的等价化简,Java内存模型的基础设计任然没有改变。
对于非虚拟机开发的普通开发人员来说,只需理解Java内存模型的定义即可,无需以这种思维思考并发问题。
:::

对于 volatile 变量的特殊规则

当一个变量定义为 volatile 之后,它具备两个特性:

  1. 保证此变量对所有线程的可见性,这里的可见性指的是当一个线程修改了这个变量的值,新值对于其他线程来说是立即可知的。
  2. 禁止指令重排序优化
可见性

volatile 变量在各个线程的工作内存中是不存在一致性问题的,但是 Java 里面的运算操作符并非原子操作,这导致 volatile 变量在并发下一样是不安全的。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class VolatileTest {

static volatile int race = 0;

static void increase() {
race++;
}

static final int THREAD_COUNT = 20;

@Test
@DisplayName("test")
void test() throws InterruptedException {
Thread[] threads = new Thread[THREAD_COUNT];

for (int i = 0; i < THREAD_COUNT; i++) {
threads[i] =
new Thread(
() -> {
for (int j = 0; j < 10000; j++) {
increase();
}
});

threads[i].start();
}

for (Thread thread : threads) {
thread.join();
}

System.out.println(race);
}
}

这个代码发起了 20 个线程,每个线程对 race 变量进行 10000 次自增操作,如果正确并发的话,最后的正确结果应该是 200000。但是运行结果总是小于 200000。

原因就出在 race++ 中,javap 反编译后得到字节码:

1
2
3
4
5
6
7
static void increase();
Code:
0: getstatic #7 // Field race:I
3: iconst_1
4: iadd
5: putstatic #7 // Field race:I
8: return

从字节码的层面上就可以分析出并发失败的原因:当 getstatic 指令把 race 值取到字节码栈顶的时候,volatile 关键字保证了 race 的值在此时是正确的,但是在执行 iconst_1 、iadd 这些指令的时候,其他线程可能已经将 race 的值改变了,而当前线程操作栈顶的值就变成了过期数据,所以 putstatic 指令就可能将较小的值同步回主内存之中。

::: warning 字节码分析并发的问题
使用字节码来分型并发问题是不严谨的,因为即使编译出来的只有一条字节码指定,也不意味着这条指令就是一个原子操作。一条字节码指令可以在解释执行时,解释器需要运行许多行代码才能实现它的语义。如果是编译执行,一条字节码指令也可能转化成若干条本地机器码指令。
:::

由于 volatile 只能保证变量的可见性,在不符合一下规则的运算场景中,仍然需要通过加锁来保证原子性:

  1. 运算结果并不依赖当前的值,或者确保只有单一的线程修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。
禁止指令重排序

普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在同一个线程的方法执行过程中无法感知到这点,这就是 Java 内存模型中描述的所谓 ”线程内表现为串行的语义“ (Within-Thread As-If-serial Semantics)。

以下的伪代码示例演示为何指令重排序会干扰程序的执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Map configOptions;
char[] configText;

// 此变量必须使用 volatile 修饰
volatile boolean initialized = false

// 假设以下代码在线程 A 中执行
// 模拟读取配置信息,当读取完成后将 initialized 设置为 true,通知其他线程配置可用
configOptions = new HashMap();
configText = readCOnfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

// 假设一下代码在线程 B 中执行
// 等待 initialized 为 true,代码线程 A 已经把配置信息初始化完成
while(!initialized){
sleep();
}
// 使用线程 A 初始化好的配置信息
doSomeThingWithConfig();

如果 initialized 没有使用 volatile 修饰,就可能会由于指令重排序的优化,导致线程 A 的最后一段代码 initialized = true; 被提前执行,这样在线程 B 中使用配置信息的代码就可能会出现错误,volatile 能避免这种情况。

Java内存模型中对 volatile 变量定义的特殊规则的定义。假设 T 表示一个线程,V 和 W 分别表示两个 volatile 型变量,那么在进行 read、load、use、assign、store和write操作时需要满足以下规则:

  • 只有当线程 T 对变量 V 执行的前一个动作是 load 时,线程 T 才能对变量 V 执行 use 动作;并且,只有当线程 T 对变量 V 执行的后一个动作是 use 的时候可以认为是和线程 T 对变量 V 的 load、read 动作相关联的,必须连续且一起出现。
  • 只有当线程 T 对变量 V 执行的前一个动作是 assign 的时候,线程 T 才能对变量 V 执行 store 动作;并且,只有当线程 T 对变量 V执行的后一个动作是 store 的时候,线程 T 才能对变量 V 执行 assign 动作。线程 T 对变量 V 的 assign 动作可以认为是和线程 T 对变量 V 的 store、write 动作相关联,必须连续且一起出现。
  • 假定动作 A 是线程 T 对变量 V 实施的 use 或 assign 动作,假定动作 F 是和动作 A 相关联的 load 或 store 动作,假定动作 P 是和动作 F 相应的对变量 V 的read或write动作;与此类似,假定动作B是线程T对变量W实施的use或 assign动作,假定动作 G 是和动作B相关联的 load 或 store 动作,动作 Q 是和动作G相应的对变量 W 的 read 或 write 动作。如果 A 先于 B,那么 P 先于 Q。

针对 long 和 double 型变量的特殊规则

Java内存模型要求八种操作都具有原子性,但是对于 64 位的数据类型,在模型中特别定义了一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64位数据的读写操作划分为两次 32 的操作来进行,即允许虚拟机实现自行选择是否要保证 64 位数据类型的 load 、store、read和write这四个操作的原子性,这就是所谓的 ”long 和 double的非原子性协定“

在实际的开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的 long 和 double 变量专门声明为 volatile。

原子性、可见性、有序性

原子性(Atomicity)

由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store和write这六个,基本类型的访问、读写都是具备原子性的。如果需要一个更大范围的原子性保证,JMM 提供了 lock 和 unlock 操作来满足,虽然虚拟机没有这两个操作直接开放给用户,却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。反映到Java代码就是 synchronized。

可见性(Visibility)

volatile 保证了多线程操作时变量的可见性。
synchronizedfinal 也能实现可见性。同步块的可见性是由 ”对一个变量执行 unlock 之前,必须先把变量同步回主内存中“ 这条规则获得的。

final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么在其他线程中就能看到 final 字段的值。

::: danger this 引用逃逸
this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到”初始化一半“的对象。
:::

有序性(Ordering)

Java 程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

Java语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性。volatile 关键字本身就包含了禁止指令出排序的语义,而 synchronized 则是由 ”一个变量在同一时刻只允许一条线程对其进行 lock 操作“ 这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行的进入。

绝大多数并发控制操作都能使用 synchronized 关键字来完成。synchronized 这种万能的特性间接导致了被程序员的滥用以及越大的性能影响。

先行发生原则

先行发生原则(Happens-Before)原则是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入 JMM 苦涩难懂的定义之中。

先行发生是Java内存模型中定义的两项操作之间的偏序关系,比如说A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,影响包括修改了内存中共享变量的值、发送了消息、调用了方法等。

Java内存模型天然的先行发生关系:

  • 程序次序规则(Program Order Rule):在一个线程中,按照控制流顺序,书写在前面的操作先于书写与后面的操作。这了的控制流顺序不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作先于发生于后面对同一个锁的 lock 操作。后面指的是时间上的先后。
  • volatile 变量操作(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作。后面指的是时间上的先后。
  • 线程的启动规则(Thread Start Rule):Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程的终止规则(Thread Termination Rule):线程中所有的操作都先行发生于对此线程的终止检测,我们可以通过 Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生与对中断线程的代码检测到中断事件的发生,可以通过 Thread::interrupted() 方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成先于发生于它的 finalize() 方法的开始。
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

时间先后顺序于先行发生原则之间没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准。

Java 与线程

并发并不一定依赖多线程,但是在 Java 中讨论并发,基本上都与线程脱不开关系。

线程的实现

线程是比进程更轻量的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源,又可以独立调度。目前线程是 Java 里面进行处理器资源调度的基本单位,不过如果日后 Loom 项目能成功为 Java 引入纤程(Fiber)的话,可能就会改变这一点。

主流的操作系统都提供线程的实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个调用过 start() 方法且还未结束的 java.lang.Thread 类的实例就代表一个线程。

实现线程主要有三种方式:使用内核线程实现(1:1实现);使用用户线程实现(1:N实现);使用用户线程加轻量级进程混合实现(N:M实现)。

1.内核线程的实现

内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可视为内核的一个分身,这样操作系统就有能了同时处理多件事情,支持多线程的内核就被成为多线程内核(Mutil-Threads Kernel)。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口–轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级线程都由一个内核线程支持,因此只有先支持内核级线程,才能由轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型:如图所示:

5-12-4-1.drawio.png

由于内核线程的支持,每一个轻量级线程都成为一个独立的调度单元,即使某一个阻塞了,也不会影响整个进程继续工作。轻量级线程也有它的局限性:首先,由于是基于内核线程实现的,所有各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用代价高昂,需要在用户态和内核态中来回切换。其次,每一个轻量级线程都需要有一个内核线程的支持,因此轻量级京城要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程数量是有限的。

2.用户线程实现

广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上来看,轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此系统效率会受限制,并不具备通常意义上的用户线程的优点。

狭义上来讲,用户线程指的是完全建立在用户空间上的线程库上,系统内核不能感知用户线程的存在及其是如何实现的。用户线程的建立、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高耗能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间 1:N 的关系称为一对多的线程模型,如图所示:

5-12-4-2.drawio.png

用户线程的优势在于不要系统的内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难,甚至有些是不可能实现的。因为使用用户线程实现的程序都比较复杂,除了有明确的需求外,一般的应用程序都不倾向使用用户线程。Java、Ruby 等语言都曾使用过用户线程,最终又都放弃了它。但是近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,如 Golang、Erlang等,使得用户线程的使用率有所回升。

3.混合实现

将内核线程与用户线程一起使用的实现方式,被称为 N:M 实现。在这种混合实现下,即存在用户线程,也存在轻量级线程。用户线程还是完全建立在用户空间内中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的调度需要通过轻量级进程来实现,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是 N:M 的关系,如图所示:

5-12-4-3.drawio.png

4.Java线程的实现

Java线程的实现并不受Java虚拟机规范的约束,这是一个与具体虚拟机相关的话题。Java线程在早期的Classic虚拟机上是基于一种被称为 “绿色线程”的用户线程实现的,但从 JDK1.3 起,主流平台上的主力商用Java虚拟机的线程模型普遍被替换为基于操作系统原生线程模型来实现,即采用 1:1 的线程模型。

以 Hotspot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以 Hotspot 是不会去干涉线程调度的,全权交给操作系统去处理。

操作系统支持怎样的线程模型,在很大程度上会影响上面的Java虚拟机的线程是怎么映射的,这一点在不同的平台上很难达成一致,因此《Java 虚拟机规范》中才不去限定Java线程需要使用哪种线程模型来实现。

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种:协同式线程调度和抢占式线程调度。

协同式线程调度

线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另一个线程上去。

优点:没有线程同步问题。因为线程要把自己的事情干完之后才会进行线程切换,切换操作对线程自己来说是可知的。
缺点:线程执行时间不可控。如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

抢占式线程调度

每个线程由系统来分配时间,线程的切换不由线程本身来决定。Java使用的线程调度就是抢占式调度。

线程优先级

虽然Java线程调度是系统自动完成的,但是我们仍然可以建议操作系统多分配时间给某些线程或少分配一些时间给某些线程,这个操作是通过设置线程优先级来实现的。

Java语言设置了 10 个线程优先级(Thread.MIN_PRIORITY 至 Thread.MAX_PRIORITY)。在两个线程处于 Ready 状态时,优先级越高的线程越容易被系统选择执行。

但是,线程优先级并不是一个稳定的调节手段,很显然因为主流虚拟机上的Java线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。虽然现代操作系统都有线程优先级的概念,但是不一定与Java线程的优先级一一对应。如果操作系统的线程优先级比Java线程优先级更多,问题比较好处理,中间留出空位就行了;但是比Java线程优先级少的系统,就不得不出现几个线程优先级对应到一个操作系统优先级的情况了。

::: tip 不同系统的线程优先级
Solaris中有 2^31^ 种优先级,Windows中就只有 七 种。
:::

线程优先级还可能被系统自行改变。比如,windows 系统中存在一个叫做“优先级推进器”的功能,大致作用是当系统中发现一个线程被执行得特别频繁时,可能会越过线程优先级去为它们分配执行时间,从而减少因为线程频繁切换而带来的性能损耗。

状态转换

Java语言定义了 6 中线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态,并且可以通过不同的方法在不同的状态之间进行转换。

线程的状态:

  • 新建(New):创建后尚未启动的线程处于这种状态。

  • 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是出于此状态的线程有可能正在执行,也有可能正在等待着操作烯体哦那个为它分配执行时间。

  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显示唤醒。一下方法会让线程陷入无限期的等待状态:

    • 没有设置等待时间的 Object::wait()方法
    • 没有设置 Timeout 参数的Thread::join()方法
    • LockSupport.park() 方法。
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:

    • Thread::sleep()方法
    • 设置了 Timeout 参数的 Object::wait() 方法
    • 设置了 Timeout 参数的 Thread::join() 方法
    • LockSupport::parkNanos() 方法
    • LockSupport::parkUntil() 方法
  • 阻塞(Bolcked):线程被阻塞了,“阻塞状态” 与 “等待状态”的区别是: “阻塞状态”在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。

  • 结束 (Terminated):已终止线程的线程状态,线程已经结束执行。

5-12-4-4.drawio.png

Java 与 协程

Java语言与框架自动屏蔽了相当多同步和并发的复杂性,对于普通开发者,几乎不需要专门针对多线程进行学习训练就能完成一般的并发任务。这个便捷的并发编程方式和同步的机制依然在有效的运作着,但是在某些场景下,却也已经显现了疲态。

内核线程的局限

今天对于 Web 应用的服务要求,不论在请求数量上还是在复杂度上,与十年前相比已不可同步而与。一方面源自业务量的增长,另一方面来自于为了应对业务复杂化而不断进行的服务细分。

Java目前的编程机制有天然的缺陷:切换、调度成本高昂,系统能容纳的线程数量很有限。现在每个请求的执行时间可能会很短、数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。

协程的复苏

为什么内核线程调度切换起来成本就要更高?

内核线程的调度成本主要来自于用户态与核心态转换,而这两种状态转换的开销主要来自于响应中断、保护和恢复 执行现场的成本。

程序是数据与代码的组合体,代码执行时还必须要有上下文的支撑。这里说的上下文,以程序员的角度来看,是方法调用过程中的各种局部变量与资源;以线程的角度来看,是方法调用栈中存储的各类信息;以操作信息和硬件的角度来看,则是存储在内存、缓存和寄存器中的一个个具体数值。

如果说内核线程的切换开销是来自于保护和恢复现场的成本,那如果改用用户线程,这部分开销就能够省略掉吗?答案是“不能”。但是一旦把保护、恢复现场及调度的工作从操作系统交到程序员手上,那我们就可以打开脑洞,通过玩出很多新的花样来缩减这些开销。

自从操作系统开始提供多线程的支持,靠应用自己模拟多线程的做法自然是变少了许多,但也没完全消失,而是演化为用户线程继续存在。由于最初多数的用户线程是被设计成协同式调度,所以它有了一个别名–协程。又由于这时候的协程会完整地做调用栈地保护、恢复工作,所以今天也被称为有栈协程,起这样地名字是为了便于跟后来地“无栈协程”区分开。无栈协程地典型应用,即 await、async、yield这类关键字。无栈协程本质上是一种状态机,状态保存在闭包中,自然比有栈协程要轻量地多,但功能也相对更有限。

Java 的解决方案

有栈协程,有一种特殊的实现名为纤程(Fiber)OpenJDK在2018年创建了 Loom 项目。

Loom 项目背后的意图是重新提供对用户纤程的支持,但与过去的绿色纤程不同,这些新功能不是为了取代当前基于操作系统的线程实现,而是会有两个并发编程模型在Java虚拟机中并存,可以在程序中使用。新模型有意保持了于目前线程模型相似的API设计,它们甚至拥有一个共同的基类,这样现有的代码就不需要为了使用纤程而进行过多改动,甚至不需要知道背后采用了哪个并发编程模型。

在新的并发模型下,一段使用纤程并发的代码会被分为两部分–执行过程和调度器。执行过程主要用于维护执行现场,保护、恢复上下文状态,而调度器则负责编排所有要执行的代码的顺序。将调度程序于执行过程分离的好处是,用户可以选择自行控制其中的一个或者多个,而且Java中现有的调度器也可以被直接重用。事实上,Loom 中默认的调度器就是原来已存在的用于任务分解的Fork/Join 池。