Java并发编程(01)—— 并发问题从何而来
引文
在计算机存储结构中, CPU、内存、I/O 设备三者的速度差异简直是“天上一天地下一年”,为了提高CPU的性能减少执行等待时间,平衡三者的速度差异。我们引入了一些技术来改善这三者的差异,但又引入了新的问题——并发问题
- CPU 增加了缓存,以均衡与内存的速度差异 —— 可见性问题
- 操作系统增加了进程、线程,以分时复用 CPU(线程切换),进而均衡 CPU 与 I/O 设备的速度差异 —— 原子性问题
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用 —— 有序性问题
CPU与缓存 —— 可见性
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。
现在的CPU大多数都是多核的,比如线程 A 操作的是 CPU-1 上的缓存,而线程 B 操作的是 CPU-2 上的缓存,很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了。
一个最经典的可见性问题的代码就是两个线程对同一个变量进行累加操作其结果在每次运行完都不相同。
public class VisibilityProblem {
private static long count;
public static void main(String[] args) throws InterruptedException {
VisibilityProblem visibilityProblem = new VisibilityProblem();
Thread thread1 = new Thread(() -> {
visibilityProblem.add();
});
Thread thread2 = new Thread(() -> {
visibilityProblem.add();
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(count);
}
public void add() {
int num = 0;
while (num++ < 100000) {
count++;
}
}
}
线程切换 —— 原子性
多线程技术,使得在一个程序内部能拥有多个线程并行执行。一个线程的执行可以被认为是一个CPU在执行该程序。当一个程序运行在多线程下,就好像有多个CPU在同时执行该程序。操作系统允许某个进程执行一小段时间,过了这个时间操作系统就会重新选择一个进程来执行(任务切换),这个进程执行的时间称为“时间片”。
早期的操作系统基于进程来调度 CPU,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程做任务切换成本就很低了。现代的操作系统都基于更轻量的线程来调度,现在我们提到的“任务切换/上下文切换”都是指“线程切换”。
进程 | 线程 |
---|---|
地址空间 | 程序计数器 |
全局变量 | 寄存器 |
文件 | 堆栈 |
子进程 | 状态 |
报警 | - |
信号与信号处理程序 | - |
账户信号 | - |
同步,互斥信号量 | - |
Java 并发程序都是基于多线程的,自然也会涉及到任务切换。由于CPU执行的是CPU指令,而Java这种高级语言一条语句往往需要多条 CPU 指令完成。如在Java中 count += 1,在CPU中就需要三条CPU指令:
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
由于操作系统做任务切换可以发生在任何一条CPU 指令执行完。于是count += 1 在多线程下就有意想不到的结果。
原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只执行其中的一部分。而CPU 能保证的原子操作是 CPU 指令级别的。因此,很多时候我们需要在高级语言层面保证操作的原子性,比如对一段代码加锁来保障执行这段代码的时候不会发生线程切换。
编译优化 —— 有序性
在Java中看似顺序的代码在JVM中,可能会出现编译器或者CPU对这些操作指令进行了重新排序;在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。
public class CommandSort {
int num = 0;
boolean flag = false;
public void fun1() {
num = 1;
flag = true;
}
public void fun2() {
if (flag) {
num = num + 5;
}
System.out.println(num);
}
}
在多线程的环境下,当fun1发生指令重排后
flag = true;
num = 1;
在flag=true后又发生了线程,执行fun2,此时num的结果就有可能是5;
另一个经典的例子是利用双重检查创建单例对象
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
线程A进入第二个判空条件,进行初始化时,而在new Singleton()这行代码对CPU来讲有3个指令:
- 分配内存空间
- 初始化对象
- instance引用指向内存空间
这3个指令可能发生指令重排,变成:
- 分配一块内存 M;
- 将 M 的地址赋值给 instance 变量;
- 最后在内存 M 上初始化 Singleton 对象。
重排后如果在2.将 M 的地址赋值给 instance 变量后发生了时间片切换,即使没有释放锁,线程B刚要进入第一个判空条件时,发现条件不成立,直接返回instance引用,不用去获取锁。这个时候访问 instance 的成员变量就可能触发空指针异常。
将Singleton instance改为volatile或者final就完美了,这里面其实涉及Java的happen-before原则与volatile的Load,Store屏障。