第一部分:并发理论基础01->可见性,原子性,有序性
1.计算机硬件的速度差异
cpu 》 内存 》 磁盘
木桶理论,(水桶能装多少水,取决于最短的木板)
程序整体的性能取决于最慢的操作——读写 I/O 设备,也就是说单方面提高 CPU 性能是无效的。
计算机做了什么?
1.cpu增加了缓存,来均衡与内存的差异
2.操作系统增加了进程,线程,分时复用CPU,均衡CPU与IO设备的速度差异
3.编译器优化指定执行顺序,使缓存更加合理利用
2.cpu缓存带来了可见性问题
- 单核cpu
所有现场都在一个cpu上执行,cpu缓存与内存数据一致性容易解决。所有的线程操作的是同一个cpu缓存,一个线程对缓存的写,对另一个线程来说一定是可见的
2个线程操作的是同一个cpu核里的缓存,A更新了变量v的值,b之后再访问v,得到是是v的最新值
一个线程对共享变量的修改,另一个线程能够立刻看到,我们成为可见性
- 多核cpu
每个核都有自己的缓存,cpu缓存与内存的数据一致性不容易解决
2个线程在不同的cpu核上执行,操作的是不同核上对应的cpu缓存
3.代码验证多核cpu下的可见性问题
伪代码
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;
}
}
实际是10000到20000之间的随机数
假设线程A和B同时执行,第一次count=0都读取到各自的cpu缓存里,执行完count+=1后,各自cpu缓存里都是1,同时写入内存中的话,发现内存中是1,而不是2
之后各自cpu核就都有了count,两个线程基于count做计算,导致最后count值小于20000
这就是缓存可见性问题
4.线程切换带来原子性问题
IO太慢,操作系统发明了多进程
操作系统允许某个进程执行一小段时间,50毫秒,过了50毫秒就会重新选一个进程来执行,50毫秒就是时间片
进程进行io操作时,例如读取文件,这个时候进程可以把自己标记为“休眠状态”并出让cpu使用权,待文件读取进内存中,操作系统会将休眠的进程唤醒,就可以获取cpu使用权了
io操作时,释放cpu使用权是为了让cpu这段时间内可以做别的事情,这样cpu的使用率就上来了。
如果另一个进程也读取文件,读文件操作就会排队,磁盘驱动在完成第一个进程的读操作后,发现排队任务,就会立刻启动下一个读操作,io使用率也上来了
进程任务切换,切换内存映射地址,成本高,线程,共享的是一个内存空间,线程任务切换成本就很低了。
任务切换指的也就是线程切换
count += 1,至少要3条cpu指令
1:把变量count从内存加载到cpu寄存器
2:寄存器中执行+1操作
3:结果写入内存(缓存机制导致写入的是cpu缓存而不是内存)
操作系统的线程切换,可以发生任意一条cpu指令执行完,cpu指令而非高级语言中的一条语句。
count = 0,线程A在指令1执行完做线程切换,线程A,B按照下图序列执行,发现两个线程都执行了count += 1操作,但是结果不是2
下意识里认为count += 1,是不可分割的整体,像一个原子一样,线程切换可以发生在count += 1之前,也可以发生在count+= 1之后,但就不可能发生在中间
一个或多个操作在cpu执行的时候,不被中断,不被线程切换的特性成为原子性。
cpu能保证原子操作是cpu指令级别,而不是高级语言的操作符,这就很违背我们的直觉
5.编译优化带来有序性问题
编译器为了优化性能,会改变程序中语句的先后顺序
a=6;b=7 编译器优化后可能就是b=7;a=6,编译器调整了语句的执行顺序,但是不影响程序的最终结果
伪代码,双重检查单利对象
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
A,B 这2个线程同时调用getInstace()方法,只有一个能获取到锁,并执行赋值操作。
但是还是有问题,new Singleton()是有点问题的
问题在哪?你认为的不一定是你认为的
你认为的new 对象
1.分配内存M
2.内存M上初始化Singleton对象
3.然后M的地址赋值给instance变量
实际上优化后的执行
1.分配内存M
2.将M的地址赋值给instance变量
3.最后在内存M上初始化Singletion对象
优化后带来的问题是什么?A执行getInstace方法,执行完指令2(将M的地址赋值给instance变量),刚好cpu进行了线程切换,切换到B线程
此时B线程也执行getInstace方法,那么B线程在判断instace != null,直接返回instance,但是此时的instace是没有初始化Singletion对象的内存块
是无法使用的instance变量,可能就会触发空指针异常了
6.总结
可见性,原子性,有序性,理解这3大类,并发bug就可以理解,并诊断了
缓存导致可见性问题
线程切换带来原子性问题
编译优化带来有序性问题