推荐阅读
- 深入讲解并发编程模型之概念篇
- 深入讲解并发编程模型之重排序篇
- 深入讲解并发编程模型之顺序一致性篇
阅读本文之前,建议先阅读 深入讲解并发编程模型之概念篇 了解什么是重排序、什么是内存屏障、什么是 happens-before。不然下面的内容阅读起来有点费劲。
可见性
一个线程的操作结果对其它线程可见成为可见性
- volatile:保证对变量的写操作的可见性
- synchronized:对变量的读写(或者)代码块的执行加锁,执行完毕,操作结果写回内存,保证操作的可见性
volatite如何保证可见性
在Java中主要是使用了volatite修饰的变量,那么就可以保证可见效。工作原理如下:
lock前缀指令和MESI协议综合使用
对于volatile修饰的变量,执行写操作的话,JVM会发送一条lock前缀指令给CPU,CPU在计算完之后会立即将这个值写回主内存,同时因为有MESI缓存一致性协议,所以各个CPU都会对总线进行嗅探自己本地缓存中的数据是否被修改了。如果发现某个缓存的值被修改了,那么CPU就会将自己本地缓存的数据过期掉,然后这个CPU上执行的线程在读取那个变量的时候,就会从主内存重新加载最新的数据了。
使用lock前缀指令和MESI协议综合使用保证了可见性。
synchronized如何保证可见性
synchronized主要对变量读写,或者代码块的执行进行加锁,在未释放锁之前,其它现场无法操作synchronized修饰的变量或者代码块。并且,在释放锁之前会讲修改的变量值写到内存中,其它线程进来时读取到的就是新的值了。
原子性
原子性表示一步操作执行过程中不允许其他操作的出现,直到该操作的完成。
在Java中,对基本数据类型变量的赋值操作是原子性操作。但是对于复合操作是不具有原子性的,比如:
int a = 0; // 具有原子性
a++; // 不具有原子性,这个是复合操作,先读取a的值,再进行+1操作,然后把+1结果写给a
int b = a; // 这个也不具有原子性,先读取a,然后把b值设为a
在Java的JMM模型中,定义了八种原子操作:
- lock(锁定):作用于内存中的变量,将变量标识为某个线程的独占状态
- unlock(解锁):作用于内存中的变量,将变量从某个线程的独占状态中释放出来,供其它现场获取独占状态
- read(读取):从内存中读取变量到线程的工作内存中,供load操作使用
- load(载入):作用于线程工作内存,将read从内存读取的变量,保存到工作内存的变量副本
- use(使用):作用于工作内存中的变量,当虚拟机执行到需要变量的字节码时,就会需要该动作
- assign(赋值):作用于工作内存中的变量,当虚拟机执行变量的赋值字节码时,将执行该操作,将值赋值给工作内存中的变量
- store(存储):作用与工作内存中的变量,将工作内存的变量传递给内存
- write(写入):作用于内存的变量,将store步骤中传递过来的变量,写入到内存中
有序性
程序执行的顺序按照代码的先后顺序执行代码的执行步骤有序
- valotile:通过禁止指令重排序保证有序性
- synchronized:通过加锁互斥其它线程的执行保证可见性
在Java中,处理器和编译器会对指令进行重排序的。但是这个重排序只是对单个线程内程序执行结果没有影响,在多线程环境下可能就有影响了。
int a = 10; // 1
int b = 12; // 2
a = a + 1; // 3
b = b * 2; // 4
实际上,在单线程环境中,程序1和2执行的顺序对程序结果没有影响,程序3和4执行顺序对程序执行结果没有影响,它们是可以在编译器或者处理的优化下做指令重排的,但是程序3不会在程序1之前执行,因为这会影响程序执行结果。具体关于指令重排序,推荐阅读 [深入讲解并发编程模型之重排序篇
](http://www.funcodingman.cn/po... 。
boolean flag = true;
flag = false; // 0
int a = 0;
//线程1执行 1、2 代码
a = 1 // 1
flag = true; // 2
//线程2执行 3、4、5、6 代码
while(!flag){ // 3
a = a + 1; // 4
} // 5
System.out.println(a); // 6
此时,如果有两个现场执行该段代码,按照我们编写的代码逻辑思路是,先执行1、2,再执行3、4、5、6、7。但是在多线程环境中,如果指令进行了重排序,导致2先在0之前执行,那么就会导致预期输出a是2,那么实际是1。
所以,在Java中,我们需要通过valotite、synchronized对程序进行保护,防止指令重排序让程序输出不是预期的结果。
保证有序性的重要原则
在Java中,编译器和处理器要想对指令进行重排序,如果程序符合下面的原则,就不会发生重排序,这是JMM强制要求的。
happens-before 四大原则
- 程序次序规则:一个线程内(不适用多线程),按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 监视器锁规则:对一个监视器的解锁操作先行发生于后面对同一个锁的占有锁操作
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作。也就是程序代码如果是先写再读,那么就不能重排序先读再写。
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
如果程序不满足这四大原则的话,原则上是可以任意重排序的。
volatite 如何保证有序性
内存屏障
LoadLoad内存屏障
Load对应JMM中的加载数据的意思。
语法格式:
// load1表示加载指令1,load2表示加载指令2
load1: LoadLoad :load2
LoadLoad屏障:load1;LoadLoad;load2,确保load1数据的装载先于load2后所有装载指令,也就是说,load1对应的代码和load2对应的代码,是不能指令重排的
StoreStore内存屏障
Store对应JMM中存储数据在线程本地工作内存的意思
语法格式:
store1;StoreStore;store2
StoreStore屏障:store1;StoreStore;store2,确保store1的数据一定刷回主存,对其他cpu可见,先于store2以及后续指令
LoadStore内存屏障
语法格式:
load1;LoadStore;store2
LoadStore屏障:load1;LoadStore;store2,确保load1指令的数据装载,先于store2以及后续指令
StoreLoad内存屏障
语法格式:
store1;LoadStore;load2
StoreLoad屏障:store1;StoreLoad;load2,确保store1指令的数据一定刷回主存,对其他cpu可见,先于load2以及后续指令的数据装载
那么volatile修饰的变量,如何在内存屏障中体现的呢?
看一段代码:
volatile a = 1;
a = 2; // store操作
int b = a // load操作
对于volatile修改变量的读写操作,都会加入内存屏障
- 每个volatile读操作前面,加LoadLoad屏障,禁止上面的普通读和voaltile读重排
- 每个volatile读操作后面,加LoadStore屏障,禁止下面的普通写和volatile读重排
- 每个volatile写操作前面,加StoreStore屏障,禁止上面的普通写和它重排
- 每个volatile写操作后面,加StoreLoad屏障,禁止跟下面的volatile读/写重排。
所以,上面代码的伪指令代码:
volatile a = 1; // 声明一个a变量,值为1
StoreStore; // 禁止上面的a = 1和a=2重排
a = 2;
StoreLoad; // 确保a的值刷回主内存,对所有CPU可见,下面的读操作才会执行
int b = a;
总结
这里和大家详细分析了并发三大特性问题,分别是可见效、原子性和有序性,以及在Java中如何保证这三大特性,具体的原理是什么。
推荐阅读
- 深入讲解并发编程模型之概念篇
- 深入讲解并发编程模型之重排序篇
- 深入讲解并发编程模型之顺序一致性篇