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

47次阅读

共计 3312 个字符,预计需要花费 9 分钟才能阅读完成。

推荐阅读

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

阅读本文之前,建议先阅读 深入讲解并发编程模型之概念篇 了解什么是重排序、什么是内存屏障、什么是 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 四大原则

  • 程序次序规则 :<span style=”color:red”> 一个线程内 </span>(不适用多线程),按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 监视器锁规则 :对一个监视器的解锁操作先行发生于后面对同一个锁的占有锁操作
  • 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 中如何保证这三大特性,具体的原理是什么。

推荐阅读

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

正文完
 0