介绍
volatile可见性
volatile是java里面的一个用于在多线程环境下保证内存可见性的一个关键字。
volatile的主要功能是禁止重排序和保证线程可见性,synchronized即能保证线程可见性还能保证重排序,但是不能禁止重排序,因为是原子性的所以因此没有重排序也不影响结果。
保证可见性实验:
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(flag){
}
System.out.println("线程结束");
}).start();
TimeUnit.SECONDS.sleep(2);
flag = false;
System.out.println("main线程修改了flag:"+flag);
}
// 结果:main线程修改了flag:false
以上这段代码预期的是自定义线程启动后,在2秒钟后,终止循环,打印线程结束。但是结果却是发现自定义的线程陷入了死循环无法终止。这就是内存不可见导致的。
接下来将flag
加上volatile关键字后再测试:
private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while(flag){
}
System.out.println("线程结束");
}).start();
TimeUnit.SECONDS.sleep(2);
flag = false;
System.out.println("main线程修改了flag:"+flag);
}
// 结果:
// 线程结束
// main线程修改了flag:false
可见加了volatile和预期值一样。
那么什么是内存可见性呢?
内存可见性是指的在多核CPU下,每个CPU为了提高运行性能都有一个独立位于主内存和CPU控制器之间的高速缓存,这个高速缓存是CPU私有的。线程执行又是基于CPU执行的,因此当一个线程修改了高速缓存中的值后,对于其它线程是无法感知的。
CPU内存结构:
如上面的例子,flag变量有三份,一份在主内存中,一份在main线程高速缓存中,还有一份在自定义线程的高速缓存中。因为自定义线程在运行时候无法感知到main线程的修改,因此会陷入死循环。但是加了volatile后,main线程修改后,会将修改的值刷入主内存,将自定义线程中的flag变量置为无效,随后自定义线程读取flag变量就会去主内存读取最新的flag,从而结束循环。
volatile读的内存语义:对volatile变量的读,会将本地内存变量置为无效,然后读取主内存。
volatile写的内存语义:对volatile变量的写,会将本地变量即时刷新到主内存,保证内存可见性。
volatile禁止重排序
上面介绍了volatile的主要功能,内存可见性,volatile还有个功能就是禁止重排序。
什么是重排序?
重排序是指的编译器或者指令器为了优化程序性能而对指令序列进行重新排列的一种手段。重排序是发生在保证不改变程序结果的情况下发生的。
重排序在多线程下的问题?
重排序在单线程中并不会有问题,并且还能提高执行性能。
如:在单线程情况下无论是先执行步骤1还是步骤2,都不会影响结果,但是步骤0和2顺序不能重排,这涉及到数据依赖性问题。
boolean flag = false; // 0 int a = 1; // 1 flag = true;// 2 return flag;
既然单线程没有任何问题,那么在多线程会导致什么问题呢?
class ReOrderdemo{ boolean flag = false; int a = 0; public void in(){ a = 1;// 1 flag = true; // 2 } public void out(){ if(flag) // 3 a --; // 4 } }
在上面这段代码中,A线程执行in,然后b线程执行out,则假如A和B同时执行方法,则变量a会在flag=true的时候被置为0。但是如果存在重排序,将1和2进行重排,此时a变量就可能出现为-1或者1的可能性。反正最终重排序步骤1,2导致了a变量不能顺利的变为0。
为了实现volatile的内存语义以及禁止 重排序功能,编译器在生成字节码的时候,会通过插入内存屏障的方式来实现。
什么是内存屏障?
内存屏障简单理解就是一个指令,该指令能够对编译器或者处理器的指令排序做出一定限制,从而禁止重排序。
内存屏障类型:
volatile在编译过程中,JMM将对volatile的读和写操作分别插入相应的内存屏障来保证禁止重排序功能。
- 在每个volatile写之前,插入StoreStore屏障,保证volatile写之前前面的所有变量都已经写完了,并刷新到了主内存。
- 在每个volatile写之后,插入StoreLoad屏障,保证当前volatile写操作即时刷新到主内存,让后面的读操作能够及时读取到最新的数据。
- 在每个volatile读之后,插入LoadLoad屏障,保证后面普通读和当前读重排序。
- 在每个volatile读之后,插入LoadStore屏障,保证当前volatile读操作一定发生在后面普通变量写之前。
原理
volatile的能够实现上面的原理是因为在volatile修饰的变量操作的时候,会执行一个lock指令,lock addl ,该指令就相当于一个内存屏障。这个指令是一种总线加锁的手段。
什么是总线加锁?
在硬件层面,CPU就像人的大脑,而CPU与其他硬件之间传递指令就是依靠总线来传递,总线加锁就是阻塞CPU指令与内存交互,从而达到独占的目的。
而一昧的对总线加锁会非常影响性能,毕竟我只对一个变量的读写而已,如果因为一个变量读写导致其它指令也无法执行,就造成很大的性能损耗。因此又提出了一种基于MESI(内存一致性协议)的解决方案。
MESI是四个状态的的缩写,分别对应:
- Modifyed(M):已被修改状态,处于该状态的变量是一个脏变量,已经被修改了,如果其它CPU要读取变量,需要将该变量写回主内存,从而变为S状态。
- Exclusive(E):独占状态,当前状态变量只有当前处理器缓存行存在,当被其它处理器访问后就会变为S状态。
- Shared(S):共享状态,当前状态变量和其它处理器缓存和主内存一致,可以使用可以抛弃。
- Invalid(I):无效状态,当前状态变量是无效的,如其它处理器存在M状态的变量,则该处理器的缓存行变量就是I状态。
有了MESI协议,就不用每次对volatile读写的时候进行完全总线加锁,对于E和S状态的变量是可以安全使用的,对于其它状态的变量只需要使用前或者使用后执行一些同步操作即可。
注意:MESI协议是基于缓存行操作的,因此如果两个变量进入了同一个缓存行则会大概率造成缓存失效问题,其中一个变量失效,导致整个缓存行失效,即线程里面的变量被置为I状态,这也是挺影响性能的(伪共享问题)。
比如:
class A{ int a; } class B{ int b; }
一个缓存行通常为64字节,基于这两个类生成的对象都为16字节,那么很大概率会存入同一缓存行。
为了解决这个问题,只需要将一个对象大小达到缓存行大小即可,让一个缓存行装不了两个变量。JDK8提供了
@sun.misc.Contended
注解来自动对齐填充,防止两个变量在同一个缓存行。在JDK7还提供了AbstractPaddingObject这么一个抽象来,来定义一些变量从而让一个对象膨胀到>=64字节的情况。包括像优秀的高并发框架Disruptor 也有这类方式的实现RingBufferPad 。
另外在提一点,volatile在一定32位机器上处理64位数据,也就是long、double这种类型的,因为在JSR-133增前后,不会发生错乱。虽然这种变量是2次执行,但是因为有禁止重排序的特性,对于这种变量一条指令也具备原子性。
以上内容部分参考《Java并发编程的艺术》