一、为什么会出现并发问题?
为了合理利用 CPU 的高性能,平衡CPU、内存和IO设备这三者的速度差异,在计算机体系结构、操作系统、编译程序等方面都做了许多优化,这些优化带来性能提升的同时,也带来了一些问题:
- CPU 增加了缓存,以均衡与内存的速度差异;但导致了以下问题:
- 导致可见性问题
- 两个cpu缓存看到的值不一致,写入内存时可能导致覆盖问题
- 操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
- 线程切换带来原子性问题
- 高级语言里一条语句往往需要多条 CPU 指令完成,例如count += 1,至少需要三条 CPU 指令。
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存
- 操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。
- 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。
- 编译优化带来的有序性问题
- instance = new Singleton();
- 我们以为的 new 操作应该是:
- 分配一块内存 M;
- 在内存 M 上初始化 Singleton 对象;
- 然后 M 的地址赋值给 instance 变量。
- 但是实际上优化后的执行路径却是这样的:
- 分配一块内存 M;
- 将 M 的地址赋值给 instance 变量;
- 最后在内存 M 上初始化 Singleton 对象。
总结:只要我们能够深刻理解可见性、原子性、有序性在并发场景下的原理,很多并发 Bug 都是可以理解、可以诊断的。
二、并发问题的现象例子
1、可见性例子
public class Test {
private long count = 0;
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
public static long calc() {
final Test test = new Test();
// 创建两个线程,执行add()操作
Thread th1 = new Thread(()->{
test.add10K();
});
Thread th2 = new Thread(()->{
test.add10K();
});
// 启动两个线程
th1.start();
th2.start();
// 等待两个线程执行结束
th1.join();
th2.join();
return count;
}
}
直觉告诉我们应该是 20000,因为在单线程里调用两次 add10K() 方法,count 的值就是 20000,但实际上 calc() 的执行结果是个 10000 到 20000 之间的随机数。为什么呢?
我们假设线程 A 和线程 B 同时开始执行,那么第一次都会将 count=0 读到各自的 CPU 缓存里,执行完 count+=1 之后,各自 CPU 缓存里的值都是 1,同时写入内存后,我们会发现内存中是 1,而不是我们期望的 2
2、原子性例子
private void add10K() {
int idx = 0;
while(idx++ < 10000) {
count += 1;
}
}
- 线程切换带来原子性问题
- 高级语言里一条语句往往需要多条 CPU 指令完成,例如上面代码中的count += 1,至少需要三条 CPU 指令。
- 指令 1:首先,需要把变量 count 从内存加载到 CPU 的寄存器;
- 指令 2:之后,在寄存器中执行 +1 操作;
- 指令 3:最后,将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
- 操作系统做任务切换,可以发生在任何一条 CPU 指令执行完,是的,是 CPU 指令,而不是高级语言里的一条语句。
3、有序性例子
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
- 编译优化带来的有序性问题
- instance = new Singleton();
- 我们以为的 new 操作应该是:
- 分配一块内存 M;
- 在内存 M 上初始化 Singleton 对象;
- 然后 M 的地址赋值给 instance 变量。
- 但是实际上优化后的执行路径却是这样的:
- 分配一块内存 M;
- 将 M 的地址赋值给 instance 变量;
- 最后在内存 M 上初始化 Singleton 对象。
- 双重检查创建单例的异常执行路径
三、解决并发问题的方案
1、java内存模型-解决可见性和有序性问题
- 解决思路
- 那解决可见性、有序性最直接的办法就是禁用缓存和编译优化
- 合理的方案
- 应该是按需禁用缓存以及编译优化
- Java 内存模型
- 规范了 JVM 如何提供按需禁用缓存和编译优化的方法
- 这些方法包括 volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则
- volatile 关键字
- 作用:
- 并不是 Java 语言的特产,古老的 C 语言里也有,它最原始的意义就是禁用 CPU 缓存
- 例子:
- volatile int x = 0
- 告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入。
- Happens-Before 规则
- 作用:
- Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的
- 1. 程序的顺序性规则
- 同一线程内,前一个变量的结果,对后续可见
- 2. volatile 变量规则
- 多线程下,线程1操作volatile的值,对后续线程可见
- 3. 传递性
- 多线程下,A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。v是volitile,根据这个传递性规则,我们得到结果:“x=42”
- 4. 管程中锁的规则
- synchronized 是 Java 里对管程的实现。
- 多线程下,synchronized前面修改的值,对后面进来的值可见
- 5. 线程 start() 规则
- 它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
- 6. 线程 join() 规则
- 它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。
2、互斥锁-解决原子性问题
- 原子性问题的源头是线程切换
- 例子:32位系统,赋值long类型(8个字节64位)问题
- 在 32 位 CPU 上执行写操作会被拆分成两次写操作
- 如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现我们开头提及的诡异 Bug 了。明明已经把变量成功写入内存,重新读出来却不是自己写入的
- 临界区
- 我们把一段需要互斥执行的代码称为临界区
- Java 语言提供的锁技术:synchronized
- 将共享变量value封装起来,提供统一的访问路径(封装临界区)
- 细粒度锁
- 用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁
- 使用锁的正确姿势
- 很简单,只要我们的锁能覆盖所有受保护资源就可以了
- “原子性”的本质是什么?
- 其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。
四、参考
JSR 133 (Java Memory Model) FAQ
FAQJSR-133: JavaTM Memory Model and Thread Specification
Java 并发编程实战