文章目录
需知:volatile关键字的两个作用
- 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
- 禁止指令重排序优化。
volatile是Java虚拟机提供的轻量级的同步机制
单线程中,不影响最终一致结果的代码,可能发生重排。注意,cpu只考虑了单线程
1.volatile的可见性
关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中。
public class VolatileVisibilityTest {
volatile boolean initFlag = false;
public void save(){
this.initFlag = true;
String threadname = Thread.currentThread().getName();
System.out.println("线程:"+threadname+":修改共享变量initFlag");
}
public void load(){
String threadname = Thread.currentThread().getName();
while (!initFlag){
//线程在此处空跑,等待initFlag状态改变
}
System.out.println("线程:"+threadname+"当前线程嗅探到initFlag的状态的改变");
}
public static void main(String[] args){
VolatileVisibilityTest sample = new VolatileVisibilityTest();
Thread threadA = new Thread(()-> sample.save(),"threadA");
Thread threadB = new Thread(()-> sample.load(),"threadB");
threadB.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
threadA.start();
}
}
线程A改变initFlag属性之后,线程B马上感知到
2.volatile无法保证原子性
public class VolatileVisibility {
public static volatile int i =0;
public static void increase(){
i++;
}
}
在并发场景下,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,因为i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成(非原子),如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败。
volatile适合一写多读的场景,上面的demo是多写就会出问题
解法方法:increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。
3.volatile禁止重排优化
volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。
硬件层的内存屏障,例Intel硬件提供了一系列的内存屏障,主要有:
- lfence,是一种Load Barrier 读屏障
- sfence, 是一种Store Barrier 写屏障
- mfence, 是一种全能型的屏障,具备ifence和sfence的能力
- Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, and XCHG等指令。
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。
4.JVM中提供的四类内存屏障指令
- LoadLoad,例方法中为 【读操作1; XX; 读操作2;】 假设中间(XX)为 LoadLoad那么顺序不能换
- StoreStore,例方法中为 【写操作1; XX; 写操作2;】假设中间(XX)为 StoreStore那么顺序不能换
。。。其他以此类推
上述也可推出Volatile 的 jvm实现细节为:
说明:假设现有变量 volatile int count = 1 ;
对其进行写操作,前面会加 StoreStore,后面加 storeLoad
对其进行读操作,前面会加LoadLoad,后面会加 LoadLoad后加 LoadStore
编译器可以根据具体情况省略不必要的屏障,下面使用demo说明
class VolatileBarrierExample {
int a;
volatile int v1 = 1;
volatile int v2 = 2;
void readAndWrite() {
int i = v1; // 第一个volatile读
int j = v2; // 第二个volatile读
a = i + j; // 普通写
v1 = i + 1; // 第一个volatile写
v2 = j * 2; // 第二个 volatile写
}
}
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化。
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编 译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器通常会在这里插 入一个StoreLoad屏障。
上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模 型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。
5.缓存一致性协议
协议要求:
- 写传播
对任何缓存中的数据的更改都必须传播到对等缓存中的其他副本。
例 核1的高速缓存 L2的 A缓存行中的数据 x,发生了改变,核2的L2中也存了数据x在对应的缓存行中,则L2的x也要发生改变。(所以也导致了伪共享问题,即多线程操作同个缓存行中,不同的变量,但因为传播他们相互影响。解决方案通过将变量存到不同缓存行即可,或者加注解@sun.misc.Contended 注解(java8)注意需要配置jvm参数:-XX:-RestrictContended)
- 事务串行化
对单个内存位置的读/写必须被所有处理器以相同的顺序看到。理论上,一致性可以在加载/存储粒度上执行。然而,在实践中,它通常在缓存块的粒度上执行。
总线仲裁可以把所有处理器对内存的访问以串行化的方式来执行。在任意时间点,最多只能有一个处理器可以访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。
- 一致性机制(列2种常见的)
- 窥探机制(snooping )所有事务都是所有处理器看到的请求/响应,每个请求都必须广播到系统中的所有节点。(系统越大占带宽越多)
- 基于目录的机制(directory-based)使用更少的带宽,因为消息是点对点的,而不是广播的。
Directory-based机制采用的是点对点的方式进行传播(就像打电话一样),每个总线transaction只会发给感兴趣的CPU。何谓感兴趣?就是拥有这个transaction所涉及内存位置对应的cache line。说起来容易,那怎么知道哪些CPU“感兴趣”呢?既然都叫directory-based,靠的就是一个ditectory,所有CPU的cache line的信息,都被记录在这个directory(电话簿)里。在最理想的情况下,所有的共享数据都只被2个CPU共享,那么需要的总线带宽就是 2N ,比起snoop模式少了很多。而在最坏的情况下,所有的共享数据被所有的CPU共享,需要的总线带宽就是 N²,比起snoop模式来说完全没有优势。
不过,在大多数时候,数据共享的情况远不可能那么普遍,所以directory-based模式通常是比snoop模式更节省总线带宽的,尤其是在CPU数目较多的场景(>64)。带宽是节省了,但付出的代价是directory本身的开销,和每次transactions前都要查询directory所造成的延迟(latency)。带宽和延迟之间的矛盾,无处不在。
总线窥探工作原理:当特定数据被多个缓存共享时,处理器修改了共享数据的值,更改必须传播到所有其他具有该数据副本的缓存中。
有两种窥探协议:
Write-invalidate
当处理器写入一个共享缓存块时,其他缓存中的所有共享副本都会通过总线窥探失效。这种方法确保处理器只能读写一个数据的一个副本。其他缓存中的所有其他副本都无效。这是最常用的窥探协议。MSI、MESI、MOSI、MOESI和MESIF协议属于该类型。
Write-update
当处理器写入一个共享缓存块时,其他缓存的所有共享副本都会通过总线窥探更新。这个方法将写数据广播到总线上的所有缓存中。它比write-invalidate协议引起更大的总线流量。这就是为什么这种方法不常见。Dragon和firefly协议属于此类别。
总线锁定: 阻塞其它核,开销大
总线锁定就是使用处理器提供的一个 LOCK#信号,当其中一个处理器在总线上输出此信号时,其它处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
缓存锁定:
缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不会在总线上声言LOCK#信号(总线锁定信号),而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。
缓存锁定不能使用的特殊情况:
1.当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定。
2.有些处理器不支持缓存锁定。
举例其中一种缓存一致性协议,英特尔的MESI:(修改;独占,共享,失效)
当 Cpu1的核1修改了L1级的缓存,Cpu1的其他核中L1对应的缓存行会标为失效,表示需要从主存读取新值。
注:L3级的缓存只是在单Cpu下的多核中共享,若是多Cpu则有各自的L3级别缓存
6.volatile 与 缓存一致性的关系
- volatile: 在java层面只是JVM这款软件的一段代码增强,意图是保证变量的可见性和有序性。
首先从可见性来说,虽然有缓存一致性协议可以保证各个CPU从缓存到主存之间的一致性。但问题是,数据得先到高速缓存才行啊,它可能还在写缓冲区storeBuffer。而且,对于有的CPU架构,还有无效化队列invalidQueue。
而volatile的目的就是告诉cpu,这个变量不需要缓冲区中而是每次都强制刷到缓存
。只要刷到缓存,因为MESI或者其它缓存一致性协议的实现,各CPU缓存一致,所以即可实现可见性。
而有序性则是做了两件事情:
- 禁止编译器进行指令重排序
- 使用内存屏障来避免storeBuffer和invaildQueue造成的指令乱序
所以CPU为了效率,其实只是保证了MESI的‘最终一致性’,而非‘强一致性’。
7.小结volatile的五层实现
java层级
:对变量加 volatile修饰符。
字节码层级
:使用jclass插件可看到对volatile的做了标记 Access flags。
JVM层级
:实现了4种内存屏障(LoadLoad,LoadStore…)
Hostspot层级
:有序性底层实现是通过汇编指令 【lock 某指令】,也可使用sfence等,但支持lock的cpu型号多,类似加了syn,任何cpu都不能再争抢。
有点类似CAS的底层,最终指令为 lock cmpxchg lock保证了原子性
lock实现原理(三种选一): 1.锁缓存行 2.关中断(别人不允许打断) 3.锁总线(例cpu1执行 cmpxchg时,将这条线锁住,其他cpu不能操作 ,当对象比较大,超过缓存行,就使用总线锁)
cpu层级
:例英特尔的MESI,缓存一致性的支持。例X86D sfence原语支持。例总线锁。