并发编程之线程通信(JMM)
Java并发的两个关键问题:线程之间的通信和同步。
一、Java线程通信(JMM)
1.两种线程之间的通信机制
1)共享内存:线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。例:Java的并发。
2)消息传递:线程之间没有公共状态,线程之间通过发送消息来显式进行通信。例:wait()和notify()。
2.java线程通信(由JMM控制)
Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。
JMM定义了线程工作内存和主内存之间线程通信(数据共享)的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。
cpu缓存模型:参考:全面理解Java内存模型(JMM)及volatile关键字
Java内存模型的抽象示意图:主内存(共享变量)、工作内存(读写共享变量)
3.线程间通信的步骤:
1)首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
二、Java内存模型(JMM)
1.JMM三大特性:
可见性:一个线程修改了共享变量的值,其他线程能够立即知道。(synchronizied、volatile(内存屏障)、final)有序性:指程序代码执行过程的先后顺序。(synchronizied、volatile)
原子性:一个操作不能被打断,要么全部执行,要么全不执行。(synchronizied(monitorenter/monitorexit)、lock/unlock
、原子封装类型java.util.concurrent.atomic.*
)
2. 重排序
2.1 重排序类型:
1)处理器重排序与内存屏障指令
2)数据依赖性
3)as-if-serial规则
3. 先行发生原则(happens-before):前一个操作的结果对后续操作时可见的。
4.JMM的8大原子操作
lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态。
unlock(解锁): 作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
read(读取): 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用。
load(载入): 作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本。
use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用。
write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
三、内存屏障(CPU指令)
1.四种基本内存屏障:Load:读,Store:写。
2.1 按照可见性保障来划分
内存屏障可分为:加载屏障(Load Barrier)和存储屏障(Store Barrier)。
加载屏障:StoreLoad
屏障可充当加载屏障,作用是使用load 原子操作,刷新处理器缓存,即清空无效化队列,使处理器在读取共享变量时,先从主内存或其他处理器的高速缓存 中读取相应变量,更新到自己的缓存中
存储屏障:StoreLoad
屏障可充当存储屏障,作用是使用 store 原子操作,冲刷处理器缓存,即将写缓冲器内容写入高速缓存中,使处理器对共享变量的更新写入高速缓存或者主 内存中
这两个屏障一起保证了数据在多处理器之间是可见的。
2.2 按照有序性保障来划分
内存屏障分为:获取屏障(Acquire Barrier)和释放屏障(Release Barrier)。
获取屏障:相当于LoadLoad屏障
与LoadStore屏障
的组合。在读操作后插入,禁止该读操作与其后的任何读写操作发生重排序;
释放屏障:相当于LoadStore屏障
与StoreStore屏障
的组合。在一个写操作之前插入,禁止该写操作与其前面的任何读写操作发生重排序。
这两个屏障一起保证了临界区中的任何读写操作不可能被重排序到临界区之外。
四、Synchronized 底层原理(保证有序性,可见性,原子性与线程安全)
synchronized:在软件层面依赖JVM,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步。
代码块同步是使用monitorenter
和 monitorexit
两个指令实现的。
synchronized编译成字节码后,是通过monitorenter
和 monitorexit
两个指令实现的,具体过程如下:
可以发现,synchronized底层通过获取屏障和释放屏障的配对使用保证有序性,加载屏障和存储屏障的配对使用保正可见性。最后又通过锁的排他性保障了原子性与线程安全。
五、Volatile可见性底层实现原理
volatile实现了jmm的一部分(可见性和有序性)。
1.与 synchronized 类似,volatile 也是通过内存屏障来保证有序性与可见性,过程如下:
读操作:
写操作:
经过对比,可以发现 volatile 少了两个指令 monitorenter 与 monitorexit 用来保证原子性与线程安全。
2.可见性(通过读取前加的Load屏障,赋值后加的Store屏障来实现)
volatile修饰的变量,在每个读操作(load操作)之前都加上Load屏障,强制从主内存读取最新的数据。每次在assign赋值后面,加上Store屏障,强制将数据刷新到主内存。
以volatile int = 0;线程A、B进行i++的操作来画图讲解一下:
如上图所示:
(1)线程A读取 i 的值遇到Load屏障,需要强制从主存读取得到 i = 0; 然后传递给工作线程执行++操作。
(2)cpu执行 i++ 操作得到 i = 1,执行assign指令进行赋值;然后遇到Store屏障,需要强制刷新回主内存,此时得到主内存i = 1。
(3)然后线程B执行读取 i 遇到Load屏障,强制从主内存读取,得到最新的值 i = 1,然后传给工作线程执行++操作,得到 i = 2,同样在赋值后遇到Store屏障立即将数据刷新回主内存。
其实说白了就是通过一个屏障让volatile的变量每次读都读主存,每次修改后立即刷到主存里面。
3. 有序性(禁止重排序)
2.数组与对象实例中的 volatile
针对的是引用,其含义是对象获数组的地址具有可见性,但是数组或对象内部的成员改变不具备可见性。这一点跟变量中 final 数组/对象 的用法是类似的,限定是引用地址。
1.volatile缓存可见性实现原理(lock前缀指令)
2.lock和unlock,锁粒度小,效率高。
六、MESI缓存一致性协议(总线嗅探机制)
MESI是硬件层面,解决了cpu与缓存之间的数据一致性问题。
1.怎么解决缓存一致性问题?
1.1 总线锁
1.2 MESI缓存一致性协议
MESI:(M:已修改 E:独占 S:共享 I: 失效)
MESI的四个字母就是标记缓存行的四种状态 M E S I
状态 | 独占 | 与主存一致 | 备注 |
---|---|---|---|
Modified(被修改) | ✔ | ✘ | 缓存行中的内存需要在未来的某个时间点(允许其它CPU 读取请主存中相应内存之前)写回(write back )主存 |
Exclusive(独占的) | ✔ | ✔ | 该状态可以在任何时刻当有其它CPU 读取该内存时变成共享状态(shared ) |
Shared(共享的) | ✘ | ✔ | 当有一个CPU 修改该缓存行中,其它CPU 中该缓存行可以被作废(变成无效状态(Invalid ))。 |
Invalid(无效的) | ✘ | ✘ | 该缓存是无效的(可能有其它CPU 修改了该缓存行)。 |
2.MESI似乎已经保证了线程之间的可见性,那么在实现了mesi协议的cpu上,volatile关键字其实是不是没用的?
答案是:还是有用的,就算在实现了mesi的cpu上,volatile一样不可或缺。除了禁止指令重排序的作用外,由于MESI只是保证了L1-3 的cache之间的可见性,但是cpu和L1之间
还有像storebuffer之类的缓存,而volatile规范保证了对它修饰的变量的写指令会使得当前cpu所有缓存写到被mesi保证可见性的L1-3cache中。(具体的实现,以X86体系为例,
volatile会被JVM生成带lock前缀的指令)。