前言:
由于CPU,内存,i/o设备之间的速度存在差异,为了提高CPU的使用效率,平衡这三者之间的速度差异,计算机体系结构,操作系统,编译程序都做了相应的优化,主要体现在以下三方面:
1-CPU增加了缓存,以均衡与内存之间的速度差异;---可见性
2-操作系统增加了进程、线程,以分时复用 CPU,进而均衡 CPU与I/O设备之间的速度差异;---原子性
3-编译程序优化指令执行顺序,使得缓存能够更合理的能利用;---有序性
上述优化虽然提高了cpu的使用效率,但也带来了并发编程一些诡异的bug(引起了可见性,原子性,有序性等问题)
源头之一:缓存导致的可见性问题
![](https://img.haomeiwen.com/i12561726/c65cda9a49ff9282.png)
单核时代,所有的线程都在同一颗CPU上运行,操作的是同一块缓存区域,CPU缓存和内存的一致性容易解决。线程A修改了变量V的值,对于线程B来说是可见的。
可见性的定义:一个线程对共享变量的修改,对另一个线程能够立即看见。、
![](https://img.haomeiwen.com/i12561726/494fd5327e6476aa.png)
多核时代,不同的CPU有各自的缓存。当不同的线程运行在不同的CPU上,他们操作的是各自的CPU缓存,例如线程A改变了自己CPU缓存中V的值,对于线程B来说,这个操作是不可见的。
源头之二:线程切换带来的原子性问题
![](https://img.haomeiwen.com/i12561726/c8ad9aec969a271e.png)
由于IO速度太慢,早期的操作系统发明了多进程,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行,这个50毫秒就称为“ 时间片 ”。
Java并发程序是基于多线程的,所以也会涉及到任务切换。任务切换的大多数情况是在时间片结束的时候。
我们现在基本都使用高级语言编程,高级语言里一条语句往往需要多条CPU指令来完成,例如 count +=1,就至少需要3条指令:
指令一:首先,把count变量重内存加载到CPU寄存器
指令二:在寄存器里执行 +1 操作
指令三:将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)
操作系统任务切换,可以发生在任意一条指令结束的时候。
![](https://img.haomeiwen.com/i12561726/7945421605c25d12.png)
原子性:一个或者多个操作在CPU执行的过程中不被中断的特性。
源头之三:编译优化带来的有序性问题
编译器为了优化性能,会改变程序中语句的执行顺序。例如 “a=6 ; b=7”,编译器优化后可能会变成“b=7 ; a=6 ”
在 Java 领域一个经典的案例就是利用双重检查创建单例对象。例如下边的代码:
![](https://img.haomeiwen.com/i12561726/4ae0e1a47b585a66.png)
以上的代码在并发访问的时候会有问题:假设线程A和线程B同时调用getInstance()方法,两个线程都发现instance对象为空,于是两个线程会去争抢Singleton.class这个锁,此时JVM会保证只有一个线程能获取到锁,假设该线程为A,此时线程B就会进入等待状态,然后线程A会创建Singleton对象,此时线程B会被唤醒,它会再次去检验instance对象,此时就会出现问题。原因在于 new对象这个过程,它会被编译器优化成三个步骤:
1.分配一块内存M
2.把内存M的地址值指向变量instance
3.在内存M中初始化变量instance
我们以为的new操作过程:
1.分配一块内存M
2.在内存M中执行初始化变量instance
3.把内存M的地址值指向变量instance
![](https://img.haomeiwen.com/i12561726/14b796cade388131.png)
instance变量用volatile修饰即可解决上述问题。
网友评论