正文
一、缓存一致性
CPU处理数据的过程是先将需要的数据存储到CPU高速缓存中,在CPU运算是从高速缓存中获取数据,然后运算完成之后,再把高速缓存中的数据同步到主内存中。由于每个线程可能会运行在不同的CPU内核中,每个CPU都有自己的高速缓冲区,同一份数据可能会被缓存到多个CPU内核中,如果一个线程修改了主存中的数据,那么运行在其他CPU的线程可能不能立即读取到新的数据,而产生可见性的问题。为了解决内存可见性问题,CPU主要提供了两种解决方法,总线锁和缓存锁。
总线锁
当不同的CPU内核访问同一个缓存行(缓存行是高速缓存操作的基本单位,在Intel的CPU一般为64字节)时,只允许一个CPU内核进行读取。如当CPU对a进行访问时,会在总线上发送一个LOCK#信号,CPU内核2要想对b进行访问,只能等CPU内核1访问完。当某一个CPU访问主存时,总线锁把CPU和主存的通信给锁住了,其他CPU不能操作其他主存地址的数据,使得效率低下,开销较大。
缓存锁
由于总线锁粒度太大了,后来引入缓存锁。缓存锁降低了锁的粒度,为了达到数据访问一直需要CPU在访问高速缓存时遵守一些协议,如MSI、MESI、MOSI等。
缓存一致性就是当某CPU对高速缓存中的数据进行操作之后,通知其他CPU放弃存储在它们内部的缓存数据,或者从内存中重新读取。在多CPU的系统中,为了保证各个CPU的高速缓存中数据一致性,每个CPU通过嗅探在总线上传播的数据来检查自己的高速缓存中的值是否过期,当CPU发现自己缓存行对应的主存地址被修改时,就会将当前CPU的缓存行设置成无效状态,当CPU对这个数据执行操作时,就会重新从系统主内存中把数据读到CPU的高速缓存中。
CPU对高速缓存的副本如何与主存内容保持一致主要有两种写入模式
1、Write-Through(直写模式):在数据更新时,同时写入低一级的高速缓存和主存。这种模式优点是操作简单,因为所有的数据都会更新到主存,其他CPU读取内存时读取到的都是最新数据。缺点是写入速度较慢。
2、Write-Black(回写模式):数据的更新并不是立即反映到主存,而是只写入高速缓存。只在数据被替换出高速缓存或者变成共享状态时,发现数据有变动,才会将最新的数据更新到主存。这种方式的优点是写入速度快,占用总线少,大多数CPU的高速缓存采用这种模式。
MESI协议
目前主流的缓存一致性协议是MESI写入失效协议。在MESI协议中,每个缓存行有四种状态,即M(Modified)、E(Exclusive)、S(Shared)、I(Invalid)。
Modified(被修改):处于Modified状态的缓存行数据只在本CPU中有缓存,并且数据与主内存中的数据不一致,被修改过。
独享的(Exclusive):处于Exclusive状态的缓存行数据只在本CPU中有缓存,并且数据与主内存中的数据一致,没有被修改过。
共享的(Shared):处于shared状态的缓存行数据在多个CPU中都有缓存,并且与主内存数据一致。
无效的(Invalid):该缓存行的数据是无效的,可能被其他CPU修改了该缓存行。
四种状态转化
初始状态:初始时缓存行没有加载任何数据,所以状态处于I状态。
本地写(Local Write):如果CPU写数据到处于I状态的缓存行,则状态由I——> M。
本地读(Local Read):如果CPU读取处于I状态的缓存行显然是没有数据的。此时如果其他CPU缓存中没有此数据,那么他读取到的数据就是独占状态E;如果其他CPU的缓存中也有该数据,则将缓存行状态变为共享状态S。
远程读(Remote Read):如果A CPU想要读取B CPU中缓存的数据,那么B CPU需要通过主存控制器(Memory Controller)发送给A CPU,A CPU 在接收到数据之后,将相应的缓存行的数据设置为共享状态 S。在设置之前,主存需要从总线上得到这份数据并保存。
远程写(Remote Write):A CPU 得到B CPU的数据之后,在写(修改)该缓存行的数据时,会发送一个RFO(Request For Owner)请求,说明他需要拥有这行数据的权限,通知其他已经缓存了该缓存行的数据的CPU将缓存行状态变为无效I状态。
二、volatile的原理
在正常情况下,系统操作并不会校验共享变量的缓存一致性,只有当共享变量用volatile关键字修饰时,该变量所在缓存行才被要求进行缓存一致性校验。
对以下代码进行汇编
package com.xiaojie.cas; /* * * @JIT汇编指令 * @author yan * @date 2021/12/28 10:48 * @return */ public class TestVolatile extends Thread { private static volatile boolean flag = true; @Override public void run() { while (flag) { } if (!flag) { System.out.println("线程结束,。。。。"); } } public static void main(String[] args) throws InterruptedException { TestVolatile testVolatile = new TestVolatile(); testVolatile.start(); try { Thread.sleep(1000); } catch (Exception e) { } //设置标识为false flag = false; System.out.println("主线程" + Thread.currentThread().getName() + "运行结束"); } }
在JVM启动参数添加:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*TestVolatile.*
注意:1、在jre/bin/server添加下面的插件
输出汇编代码操作指令
可看到在volatile修饰的变量在汇编指令中多了一个lock addl。该lock前缀指令有三个作用
1、将当前CPU的缓存行数据立即写回内存。
2、lock前缀指令会让在其他CPU缓存了该内存地址的数据无效。
3、lock前缀指令禁止指令重排序。
三、volatile实现可见性
由图可知,当main线程修改了flag=false,数据由共享状态S变成了修改状态M,而把数据写回到主内存需要通过总线传播数据,而每个CPU通过嗅探在总线上传播的数据来检测自己的数据是否过期,当CPU1发现flag被修改成false之后,就会将自己读取到的flag设置为无效,会强制重新从内存中读取数据,然后读取到flag修改后的值。
四、Volatile禁止重排序
As-if-Serial&Happen-Before
As-if-Serial规则:在单核CPU下,无论如何重排,都必须保证代码运行正确。为了遵守As-if-Serial规则,编译器和CPU不会对存在依赖关系的变量进行重排序,因为这种重排序会改变执行结果。As-if-Serial规则只能保障单核指令重排之后的执行结果正确,不能保障多内核以及跨CPU指令重排序之后的执行结果正确。
Happen-Before规则:主要有以下几个方面
程序顺序执行规则,在同一个线程中,有依赖关系的操作按照先后顺序,前一个操作必须先行发生于后一个操作。
volatile变量规则,volatile修饰的变量,写操作必须先行发生对volatile修饰变量的读操作。
传递性规则,如果A操作先行发生于B操作,B操作先行发生于C操作,那么A先行发生于C操作。
监视锁规则,对一个监视锁的解锁操作先行发生于后续对这个监视锁的加锁操作。
start规则,对线程的start操作先行于这个线程内部的其他任何操作。
join规则,如果线程A执行了B.join()操作并成功返回,那么线程B中的任意操作优先发生于线程A ,也就是只有B线程执行完了,或者异常退出了,才会执行A线程。
volatile的内存屏障
内存屏障又称为内存栅栏(Memory Fence),是一系列的CPU指令,它的主要作用是保证特定操作的顺序执行,保障并发执行的有序性。在编译器和CPU都进行指令的重排优化时,可以通过在指令间插入一个内存屏障指令,告诉编译器和CPU禁止在内屏障指令前或者指令后重排序。
在java中volatile有两层语义,第一就是上面说的可见性,即一个线程修改了某个volatile修饰的变量,该值对其他线程立即可见。再一个就是就是确保有序性。volatile保证有序性是通过内存屏障来实现的。JVM编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。有以下集中插入策略
1、在每个volatile读操作后面插入一个LoadLoad屏障。
2、在每个volatile读操作后面插入一个LoadStore屏障。
3、在每个volatile写操作之前插入一个StoreStore屏障。
4、在每个volatile写操作之后插入一个StoreLoad屏障。
volatile的写操作
在写操作之前插入SS屏障,在写操作之后插入SL屏障。
volatile读操作
在每个volatile读操作之后插入LL和LS屏障禁止后面的普通读、普通写和前面的volatile读发生重排序。
五、volatile不能保证原子性
以 i++为例
package com.xiaojie.myvola; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * @author xiaojie * @version 1.0 * @description: volatile不具有原子性 * @date 2021/12/28 23:21 */ public class IncrementDemo { private static volatile int count=0; public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(10); CountDownLatch countDownLatch=new CountDownLatch(10); for (int i=0;i<10;i++){ executorService.execute(new Runnable() { @Override public void run() { for (int i=0;i<1000;i++){ count++; } } }); countDownLatch.countDown(); } try{ Thread.sleep(100); //确保线程池任务执行完之后关闭线程池 }catch(Exception e){ } System.out.println("最后的值为count:"+count); executorService.shutdown(); } }
计算结果总是<=10000。
JMM对volatile有特殊约束:(1)使用volatile修饰的变量其read、load、use都是连续出现的,所以每次使用变量的时候,如果发现自己和主内存的值不一致,都会从主存中重新读取数据,以保证每次获取到的都是最新的数据。(2)其对同一变量的assign、store、Write操作也是连续出现的,所以每次对数据修改都能及时同步到主存中。
大白话的意思就是,例如两个线程同时获取到count的值都是0,线程A 先操作++操作时候,变成1,并立即刷回到主内存,线程B发现数据变了,于是重新从内存中读取数据,那么线程B的这次++操作就白做了,没有累加上,所以最后结果小于10000。
总结:
1、使用volatile修饰的变量在变量值发生改变时,会立即同步到主内存,并使其他线程的变量副本失效。
2、被volatile修饰的变量在硬件层面上(CPU)会通过指令加入内存屏障禁止重排序。
3、volatile在多个CPU中,多线程并发的情况下并不能保证原子性。
参考:
《JAVA高并发核心编程(卷2):多线程、锁、JMM、JUC、高并发设计》-尼恩编著