深入理解并发编程之volatile伪共享与Volatile重排序问题
文章目录
一、内存屏障
什么是内存屏障
内存屏障,也称内存栅栏,内存栅障,屏障指令等, 是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作(来源于百度百科)。
内存屏障分为两种:
- 内存读屏障(read memory barrier)仅确保了内存读操作,在指令后插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存中加载数据。也是不会让CPU去进行指令重排。
- 内存写屏障(write memory barrier) 仅保证了内存写操作,在指令前插入Store Barrier,能让写入缓存中最新数据更新写入主内存中,让其他线程可见。强制写入主内存,这种显示调用,不会让CPU去进行指令重排序。
常见的x86/x64,通常使用lock指令前缀加上一个空操作来实现。
volatile与内存屏障的关系
被volatile关键字修饰的变量会存在一个“lock”的前缀,Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。类似于Lock指令。
在具体的执行上,它先对总线和缓存加锁,然后执行后面的指令,在Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。最后释放锁后会把高速缓存中的数据全部刷新回主内存,且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。
看下面代码:
public class Test001 {
private static volatile boolean a = false;
public static void main(String[] args) {
a = true;
}
}
配置:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,* Test001. *
Test001是测试类的名称(网上下载 hsdis-amd64.dll放到 C:\java8\jdk\jre\bin\server ,否则报错)加在启动属性上:
运行代码得到JVM汇编代码解析,可以看到在main里面我们对共享变量a操作的时候加了lock。
然后去掉关键字再执行,全局搜不到lock,找到对应位置也是没有lock的。
volatile关键字修饰的变量不是内存屏障,而是一个类似于内存屏障的功能。
二、基于内存屏障分析volatile防止重排序
什么是重排序
指令重排序是指Java内存模型允许编译器和处理器对指令代码实现重排序提高运行的效率,只会对不存在的数据依赖的指令实现重排序,在单线程的情况下重排序保证最终执行的结果与程序顺序执行结果一致性。
重排序产生的原因
当我们的CPU写入缓存的时候发现缓存区正在被其他cpu占有(说明不是多核处理器情况下才会发生)的情况下,为了能够提高CPU处理的性能可能将后面的读缓存命令优先执行。
重排序需要遵循as-if-serial语义:
as-if-serial:不管怎么重排序(编译器和处理器为了提高并行的效率)单线程程序执行结果不会发生改变的。也就是我们编译器与处理器不会对存在数据依赖的关系操作做重排序。
as-if-serial 单线程程序执行结果不会发生改变的,但是在多核多线程的情况下指令逻辑无法分辨因果关系,可能会存在一个乱序中心问题,导致程序执行结果错误。
举例说明重排序
A=1与A++存在依赖关系,所以不会重排序。而B=Y,C=Z与它们没有依赖关系,A++有写的操作,所以CPU重排序后可能B=Y,C=Z会提前执行,当然在单核CPU单线程重排序是没问题。
再看一段多线程下的代码:
public class ReorderThread {
// 全局共享的变量
private static int a = 0, b = 0;
private static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
while (true) {
//每次开始重置变量的初始值
i++;
a = 0;b = 0;x = 0;y = 0;
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
a++;
x = b;
x = b;x = b;x = b;x = b; //如果试不出来可以多写几个,不影响分析结果。
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b++;
y = a;
}
});
thread1.start();
thread2.start();
//保证子线程线执行完再执行主线程
thread1.join();
thread2.join();
System.out.println("第" + i + "次(" + x + "," + y + ")");
if (x == 0 && y == 0) {
break;
}
}
}
}
分析下上面代码会出现什么情况:
- 场景1:线程1先执行,线程2后执行,输出值x=0,y=1。
- 场景2:线程2先执行,线程1后执行,输出值y=1,x=0。
- 场景3:两个线程同时执行,输出值x=1,y=1,这个情况没在输出结果查到,但是可以想象的到a++,和b++执行完后再赋值。
- 永远不会出现的情况,x=0,y=0,所以我们写了x=0,y=0的时候跳出死循环,巧了,最后出现跳出死循环了,为啥呢,就是因为指令重排序,因为a++和y=b之间没有关联性所以可以重排序,y=b先执行,a++后执行了。同理线程2,然后两个线程同时执行了。现在再看一下场景3的结果为啥找不到,代码重排序了,可能3变成了永远不会出现的场景了。
volatile关键字防止代码重排序
再看代码加volatile关键字:
public class ReorderThread {
// 全局共享的变量
private static int a = 0, b = 0;
private static volatile int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
int i = 0;
while (true) {
i++;
a = 0;
b = 0;
x = 0;
y = 0;
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
a++;
x=b;
x=b;
x = b;
x = b;
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b++;
y=a;
y=a;
y=a;
y=a;
y=a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第" + i + "次(" + x + "," + y + ")");
if (x == 0 && y == 0) {
break;
}
}
}
}
结果一直不会出现x=0,y=0的情况。这就说明了volatile关键字可以防止指令重排序。
再来回头看一下前面的知识,volatile赋值会在代码前面模拟内存屏障,赋值写内存屏障。
内存写屏障(write memory barrier) 仅保证了内存写操作,在指令前插入Store Barrier,能让写入缓存中最新数据更新写入主内存中,让其他线程可见。强制写入主内存,这种显示调用,不会让CPU去进行指令重排序。
看最后一句,强制写入内存不会让CPU去进行指令重排序。总共两行代码,第二行不让重排序第一行也不能动,最后CPU还是按照我们写的顺序执行的。
双重检验锁的单例模式为什么要加volatile
上单例模式代码:
public class Singleton03 {
private static volatile Singleton03 singleton03;
public static Singleton03 getInstance() {
// 第一次检查
if (singleton03 == null) {
//第二次检查
synchronized (Singleton03.class) {
if (singleton03 == null) {
singleton03 = new Singleton03();
}
}
}
return singleton03;
}
public static void main(String[] args) {
Singleton03 instance1 = Singleton03.getInstance();
Singleton03 instance2 = Singleton03.getInstance();
System.out.println(instance1==instance2);
}
}
乍一看没问题,但是当对底层创建对象有研究的话就出现问题了。new对象不是一个原子操作。汇编出来的代码,new操作是三步的。
我们列出这三步可能出现的重排序状况:
再思考一下上面举的volatile关键字重排序例子,当时多个线程的时候第二步和第三步流程存在重排序也有可能先执行我们的,将对象复制给变量,在执行调用构造函数初始化,导致另外一个线程获取到该对象不为空,但是该改造函数没有初始化,所以就报错了,因为另外一个线程拿到的是一个不完整的对象。虽然个现象出现的几率是非常低的,但是一旦出现BUG几乎很难寻找的,需要注意
三、volatile的伪共享问题
什么是伪共享问题
CPU会以缓存行的形式读取主内存中数据,缓存行的大小为2的幂次数字节,我们现在用的64位电脑是2^64也就是64个2进制也就是64位。
举个例子:Java中long类型占用8个字节的,但是缓存行64位,不可能只存一个long的,假设存放了6个(对象头占用了空间所以不是8,具体后面JVM分析)long类型的变量(假设A、B、C…6个变量),由于缓存一致性协议,使用了volatile关键字的变量A改变了,其他线程都会去同步A变量,由于CPU读取数据是以缓存行的方式,所以这6个变量都同步过来了。这样6个变量随便哪个更新都会使其它线程中的缓存行失效,然后重新更新,这样其实JMM内存模型也就没啥意义了,大大影响了性能,而且其中可能C不是 共享变量,但是由于A共享刷新导致的C也共享了。
看下面代码:
public class Test001 {
private static boolean a = false;
private static boolean b = false;
static class Thread001 extends Thread {
@Override
public void run() {
while (true) {
if(a){
System.out.println("此时a是true");
}
if(b){
System.out.println("此时b是true");
}
}
}
}
public static void main(String[] args) {
Thread001 thread001 = new Thread001();
thread001.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
b = true;
a = true;
System.out.println("主线程结束");
}
}
执行的结果是:没有打印b判断里面的语句,说明a和b变量都没有线程一致性
再看下面的代码,a用volatile来修饰了
public class Test001 {
private static volatile boolean a = false;
private static boolean b = false;
static class Thread001 extends Thread {
@Override
public void run() {
while (true) {
if(a){
System.out.println("此时a是true");
}
if(b){
System.out.println("此时b是true");
}
}
}
}
public static void main(String[] args) {
Thread001 thread001 = new Thread001();
thread001.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
b = true;
a = true;
System.out.println("主线程结束");
}
}
看一下执行的结果:a和b都保持一致性了,明明我们只把a用volatile修饰了,b为啥也保持一致性,这就是伪共享问题,因为a和b放在了同一个缓存行中。
怎么避免伪共享问题
- JAVA6的时候是自己手动在下面补充一些没用的数据来填满64位,例如用long的时候,boolean举例写太多了。
public final static class VolatileLong{
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}
- JAVA7的时候对代码做了优化,这些无用变量给优化掉了,所以哟啊写一个单独的类去继承
public final static class VolatileLong extends AbstractPaddingObject {
public volatile long value = 0L;
public long p1, p2, p3, p4, p5, p6;
}
public class AbstractPaddingObject {
public long p1, p2, p3, p4, p5, p6;
}
- JAVA8的时候增加了@Contended注解,只要加了这个注解会自动填充缓冲行到填满
public final static class Test {
public VolatileLong value;
}
@Contended
public class VolatileLong {
ublic volatile long value = 0L;
}
内容来源: 蚂蚁课堂