硬件
并发问题很多时候是和硬件设计相关的。
众所周知,IO 操作比 CPU 慢很多。但 CPU 却不得不与内存交互,因为图灵机的基础要求就是能够存储。现代 CPU 都有高速缓存(cache),而缓存又会同时共享同一主内存(main memory),这种架构也叫共享内存多核系统(Shared Memory Multiprocessors System)。引入了缓存,必然也有一致性问题。所以需要缓存一致性协议来解决这一问题。
除了高速缓存,处理器也会对输入的指令乱序执行(Out-Of-Order Execution)优化。不仅仅是处理器,编译器也会对指令重排序(Instruction Reorder)。所以程序代码的运行顺序是极不可预测的。
Java 内存模型
Java 内存模型(Java Memory Model,JMM)以屏蔽硬件和操作系统内存差异为目标,实现了 Java 程序在各种平台下都能达到一致内存访问的效果。
主内存和工作内存
主内存和之前提到的概念类似,连名字也一样。而工作内存(Working
Memory),可以类比高速缓存,每条线程都拥有一份。每当 save
和 load
时,都不会直接读写主内存,而是通过工作内存完成。
当然,本地变量和方法参数不包括在此返回,因为永远都不会被多线程访问。
内存互操作
JMM 定义了 8 种操作来完成内存操作,并要求 JVM 虚拟机必须允许下面的每一种操作都是原子的、不可再分的:
- lock:将主内存变量标识为一条线程独占的状态。
- unlock:将主内存变量解除独占状态。
- read:读取主内存变量,并转移到线程的工作内存中。
- load:把 read 操作从主内存中得到的变量值,放入工作内存的变量副本中。
- use:把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
- assign:把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
- store:把工作内存中一个变量的值传送到主内存中
- write:把 store 操作从工作内存中得到的变量的值放入主内存的变量中
read-load、store-write 必须按顺序执行,但不要求是连续执行,在它们之间是可以插入其他指令的。
此外,还需要满足以下规则:
- 不允许 read、load、store、write 之一单独出现,即不允许一个变量从主内存读取了但工作内 存不接受,或者工作内存发起回写了但主内存不接受的情况出现
- 不允许线程丢弃 assign 操作,即工作内存中的变化必须同步回主内存
- 不允许线程在没有发生过任何 assign 操作的情况下,把数据从线程的工作内存同步回主内存中
- 在 use、store 之前,必须先执行 assign 和 load,这避免了未初始化的值
- 在同一个时刻只允许一条线程对其 lock。同时允许一条线程多次锁(可重入),随后需要执行相同次数的 unlock,变量才会释放。
- lock 后,会清空工作内存中此变量的值,在执行引擎使用其前,需 load 或 assign 以初始化变量的值
- 一个变量若没有被 lock,那就不允许 unlock,也不允许 unlock 其他线程的 lock
- unlock 前必须先 store 和 write 同步回主内存。
最新的 JSR-133 文档中对描述有所简化,将操作缩减为 4 种,但模型并未改变。
对 volatile 的特殊规则
Volatile 是 Java
提供的最轻量级的同步机制,但容易被错误理解。甚至有人完全避免
Volatile,仅仅使用 synchronized
同步。
Volatile 保证了两个特性:
- 可见性。一条线程修改了值,另一线程可以立刻得知。
请注意,这不意味着 volatile 在并发下是现成安全的。仍然存在不一致问题。volatile 运算仍然不是原子的。这里使用经典的计数器例子:
@Volatile
var a = 0
fun main() = runBlocking {
(16) {
repeat{
thread (1000) {
repeat++
a}
}
}
("a: $a")
println}
结果并不是 16000,而且每次运行结果都不一样,可能是 8528
14782
等数字…… 问题出自
a++
,实际上这一个操作有读和存两个步骤。它们的顺序并不能保证。
只有布尔值这类简单场景建议使用 volatile:
@Volatile
var shutdown = false
fun shutdown() {
= true
shutdown }
fun doWork() {
while (!shutdown) {
// ...
}
}
- 禁止指令重排序优化。普通的变量只会保证结果正确,而不能保证执行顺序一致。
例如经典的双重校验锁单例模式:
public class Singleton {
private volatile static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singeleton.class) {
if (instance == null) {
= newSingleton();
instance }
}
}
return instance;
}
}
就需要使用 volatile
变量。
针对 long 和 double 变量
JMM 允许 long
double
两个类型操作为非原子的。但实际而言不需要担心。除非使用 Java
做嵌入式开发,主流的 ARMv6、ARMv7、x86、x86-AMD64、PPC 等架构的
64 位平台都支持原子性操作这两个类型。但如果目标为 32
位虚拟机,则需要注意。
原子性、可见性、有序性
- 原子性(Atomicity),即一个操作不可再分。
基本可以认为,基础数据类型的读写操作都是原子的。
同时还可以使用 synchoronized
锁,来保证代码块的原子操作。
- 可见性(Visibility),即当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
Java 的 volatile 关键字通过强制刷新缓存,保证了变量的多线程可见性。
而 synchronized
则是用锁的方式,自然也可以保证。
最后,final
也相当于保证了可见性。因为只读变量不可能在初始化之后被修改。因此无需同步。
- 有序型(Ordering)之前已经提到过。这里可以总结为一句话:在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程, 所有的操作都是无序的。
以上三点,似乎都可以用 synchoronized
锁来保证,然而锁是很耗时的操作,往往会被滥用,造成性能影响,因此 JVM
会尝试优化锁,这会在以后讲到。
线程
线程的实现
实现线程主要有三种方式:内核线程实现(1:1)、用户线程实现(1:N), 混合实现(N:M)。
内核线程实现
内核线程(Kernel-Level Thread,KLT)即为直接由系统内核支持的现成,由内核来完成线程切换,由调度器(Scheduler)完成调度,并将线程任务映射到各个处理器上。每个内核线程可以视作为内核的一个分身,因此支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。
程序一般不直接使用内核线程,而是使用更高级的接口,轻量级进程(Light Weight Process,LWP),轻量级进程都由一个内核线程支持。它们之间的关系是一对一的。
LWP 具有一定的局限性,系统调用的代价较高,需要在用户态(User Mode)和内核态(Kernel Mode)之间来回切换。因此一个系统支持的 LWP 的数量有限。
用户线程实现
这种方法也称为 1:N 实现。用户线程(User Thread)指完全建立在用户空间的线程库上,系统内核不能感知用户线程的存在及实现。且用户线程完全在用户态中完成调度。能够支持更多数量的线程。
混合实现
即 LWP 和 UT 两者都有,也称 N:M 实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。 用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以 支持大规模的用户线程并发。
线程调度
调度主要方式有两种,分别是协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。
如果使用协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。
如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。Java 的线程就是这种系统,共设置了 10 个优先级,越高的越先执行。
协程
请参见 Kotlin Coroutine 协程。
以上,即为本节的所有内容。