并发BUG的源头
前言
机器的存储设备常见的一般是CPU、内存、I/O设备,计算速度也是由高到低,它们之间的差异可能高达几十倍,但是有些程序必须要访问内存或者I/O设备,根据木桶定律,程序的响应速度取决于计算速度最慢的存储设备即I/O设备。
为了平衡这些差异CPU、操作系统、编译程序做出如下处理。
- CPU增加缓存以平衡内存和CPU之间的速度差异。
- 操作系统增加进程、线程、以分时复用,进而均衡CPU和I/O设备的速度差异。
- 编译程序优化指令执行次序,更加合理的利用缓存。
缓存的可见性问题
单核时代
单核时代所有的线程共享一个CPU缓存,一个线程修改完数据对另外一个线程是一定可见的。
多核时代
对于多核时代,多线程运行在不同的CPU上,每个CPU不再共享缓存,那么线程A在CPU1上修改的数据对于线程2在CPU2上是不可见的。
程序验证
两个线程同时对count=0做1w次累加操作,并且将结果合并。
package com.example.demo.visibility;
public class TestVisibility {
public static void main(String[] args) {
System.out.println(Test.calc());
}
}
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();
Thread t1 = new Thread(test::add10k);
Thread t2 = new Thread(test::add10k);
// 不能启动t1后再去创建t2线程,循环时间短创建t2过程中add10k就已经执行完毕
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
return test.count;
}
}
跑出的结果按照预期应该是20000但是,无论执行多少次最后的结果都是小于20000的,因为启动线程有一个先后顺序也就是说存在时间差,如果用户执行的一亿次,那么count值应该是接近一个亿而不是两个亿原因是长期执行那么两个线程相当于并行。
线程切换的原子性问题
线程切换
指的是,当前操作系统允许某个进程执行50毫秒(时间片),当过了50毫秒后系统会选择其它的线程执行,这个过程称之线程切换。
线程切换的作用
当某个时间片内一个进程正在进行IO读操作,那么可以让当前进程睡眠,同时让出CPU使用权,等到IO操作读取到内存,那么操作系统再次将其唤醒,唤醒后又可以有机会获取时间片。这样一来可以充分利用CPU的资源,在其空闲时可以做其它的事。
切换带来的问题
JAVA是一种高级语言,一条语句一般由多个CPU指令完成,如count+=1;就是由如下三条语句构成
- 指令1:将count值从内存加载到CPU寄存器。
- 指令2:在寄存器中将count值+1.
- 指令3:将+1后的值写回到内存中。
CPU线程切换可能发生在任意一个指令执行完毕后,因为CPU能保证指令执行的原子性,所以多个指令之间执行可能会线程切换从而导致并发问题。
如图预想最后的结果应该为2,但是由于线程1发生线程切换导致线程一的结果会将线程2的结果覆盖,所以会产生错误。
编译优化带来的有序性
定义
有序性一般指程序按照编写的顺序执行,但是编译器能够在不改变程序运行结果的前提下将程序代码进行一些顺序调整来达到优化的目的。
经典案例-单例双重检测
public class Singleton {
private static Singleton singleton = null;
private Singleton(){
}
public static Singleton getSingleton(){
if (singleton ==null){
synchronized (Singleton.class){
if (singleton ==null){
singleton = new Singleton();
}
}
}
return singleton;
}
}
上诉代码看上去是没什么问题,但是有一个点容易忽略的就是,new Singleton()时分为了如下三个步骤
- 创建一片内存空间存放新对象。
- 在内存上给Singleton对象赋值。
- 将指针singleton指向新开辟的内存空间。
如果编译器在优化时将步骤2和步骤3调换位置,就有可能导致空指针,如图所示
线程1由于指令重排,在执行(将指针singleton指向新开辟的内存空间)时线程切换还未执行(在内存上给Singleton对象赋值)后线程2去执行发现singleton不再为空之间返回,这时候之间引用对象singleton就会触发空指针。