深入讲解并发编程模型之并发三大特性篇

推荐阅读
  • 深入讲解并发编程模型之概念篇
  • 深入讲解并发编程模型之重排序篇
  • 深入讲解并发编程模型之顺序一致性篇

阅读本文之前,建议先阅读 深入讲解并发编程模型之概念篇 了解什么是重排序、什么是内存屏障、什么是 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中如何保证这三大特性,具体的原理是什么。

推荐阅读
  • 深入讲解并发编程模型之概念篇
  • 深入讲解并发编程模型之重排序篇
  • 深入讲解并发编程模型之顺序一致性篇

你可能感兴趣的:(并发,java)