volatile可见性,指令重排
Posted iblade
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了volatile可见性,指令重排相关的知识,希望对你有一定的参考价值。
volatile的三大特性:
- 共享变量可见性
- 不保证原子性
- 禁止指令重排后顺序性。
CPU高速缓存和可见性问题
程序运行时,数据是保存在内存当中的,但是执行程序这个工作却是由CPU完成的。那么当CPU正在执行着任务呢,突然需要用到某个数据,它就会从内存中去读取这个数据,得到了数据之后再继续向下执行任务。
这是理论上理想的工作方式。然而实际上,CPU的发展是遵循摩尔定律的,每18个月左右集成电路上晶体管的数量就可以翻一倍,因此CPU的速度只会变得越来越快。但是光CPU快没有用呀,因为CPU再快还是要从内存去读取数据,而这个过程是非常缓慢的,所以就大大限制了CPU的发展。为了解决这个问题,CPU厂商引入了高速缓存功能。内存里存储的数据,CPU高速缓存里也可以存一份,这样当频繁需要去访问某个数据时就不需要重复从内存中去获取了,CPU高速缓存里有,那么直接拿缓存中的数据即可,这样就可以大大提升CPU的工作效率。
而当程序要对某个数据进行修改时,也可以先修改高速缓存中的数据,因为这样会非常快,等运算结束之后,再将缓存中的数据写回到内存当中即可。同一变量两份值,就会存在同步的问题(即可见性)
这种工作方式在单线程的场景下是没问题的,准确来讲,在单核多线程的场景下也是没问题的。一个CPU在同一时时其实只能处理一个任务,即使我们开了多个线程,对于CPU而言,它只能先处理这个线程中的一些任务,然后暂停下来转去处理另外一个线程中的任务,以此交替。而多CPU的话,则可以允许在同一时间处理多个任务,这样效率当然就更高了,问题也出现在多核多线程的“同一时间”。
假如,
①Thread1由CPU1执行,C1从内存读取a = 5,存入C1高速缓存;
②Thread2由CPU2执行,C2同样从内存读取a = 5,存入C2高速缓存;
③T2修改了数据a = 7,C2会更新高速缓存中a = 7,然后再将它写回到内存当中a = 7。
④此时,T1再访问数据a,C1发现高速缓存当中有a( = 5)值,直接返回5。此时,T1和T2访问同一个数据a,得到的值却不一样了。
public static void main(String... args) {
new Thread1().start();
new Thread2().start();
}
static class Thread1 extends Thread {
@Override
public void run() {
while (true) {
if (flag) {
flag = false;
System.out.println("Thread1 set flag to false");
}
}
}
}
static class Thread2 extends Thread {
@Override
public void run() {
while (true) {
if (!flag) {
flag = true;
System.out.println("Thread2 set flag to true");
}
}
}
}
理论上来说,这两个线程同时运行,那么就应该一直交替打印,你改我的值,我再给你改回去。
运行结果是打印过程只持续了一小会就停止打印了,但是程序却没有结束,依然显示在运行中。
这怎么可能呢?理论上来说,flag要么为true,要么为false。true的时候Thread1应该打印,false的时候Thread2应该打印,两边都不打印是为什么呢?
因为Thread1和Thread2的CPU高速缓存中各有一份flag值,其中Thread1中缓存的flag值是false,Thread2中缓存的flag值是true,所以两边就都不会打印了。
那么该如何解决呢?volatile。
volatile这个关键字的其中一个重要作用就是解决可见性问题,即保证当一个线程修改了某个变量之后,该变量对于另外一个线程是立即可见的。
至于volatile的工作原理,不深究底层,大概原理就是当一个变量被声明成volatile之后,任何一个线程对它进行修改,都会让所有其他CPU高速缓存中的值过期,这样其他线程就必须去内存中重新获取最新的值,也就解决了可见性的问题。
指令重排
volatile关键字还有另外一个重要的作用,就是禁止指令重排。
// 第一段代码
int a = 10;
int b = 5;
a = 20;
System.out.println(a + b);
// 第二段代码
int a = 10;
a = 20;
int b = 5;
System.out.println(a + b);
这两段代码打印结果没有任何区别,毕竟声明变量b和修改变量a之间的顺序是随意的,它们之间谁也不碍着谁。
也正是因为这个原因,CPU在执行代码时,其实并不一定会严格按照我们编写的顺序去执行,而是可能会考虑一些效率方面的原因,对那些先后顺序无关紧要的代码进行重新排序,这个操作就被称为指令重排。
这么看来,指令重排这个操作没毛病啊。确实,但只限在单线程环境下。
就拿面试常考的单例模式举例:
//懒汉式单例类.在第一次调用的时候实例化自己
public class Singleton {
//私有的构造函数
private Singleton() {
}
//私有的静态变量
private static Singleton single =null;
//暴露的公有静态方法
public static Singleton getInstance() {
if (single == null) { //line1
single=new Singleton(); //line2
}
return single;
}
}
懒汉式,确实是在调用getInstance()方法时,才会初始化实例,实现了懒加载。但是在能否满足在多线程下正常工作呢?我们在这里先分析一下假设有两个线程ThreadA和ThreadB:
ThreadA首先执行到line1,这时mInstance为null,ThreadA将接着执行new Singleton();在这个过程中如果mInstance已经分配了内存地址,但是还没有完成初始化工作(问题就出在这儿,稍后分析),如果ThreadB执行了line1,因为mInstance已经指向了某一内存,所以将跳过new Singleton()直接得到mInstance,但是此时mInstance还没有完成初始化,那么问题就出现了。造成这个问题的原因就是new Singleton()这个操作不是原子操作。至少可以分解成以下上个原子操作:
1.分配内存空间
2.初始化对象
3.将对象指向分配好的地址空间(执行完之后就不再是null了)
其中第2,3步在一些编译器中为了优化单线程中的执行性能是可以重排的。重排之后就是这样的:
1.分配内存空间
3.将对象指向分配好的地址空间(执行完之后就不再是null了)
2.初始化对象
重排之后就有可能出现上边分析的情况:
代码在JVM执行的时候,为了提高性能,编译器和处理器都会对代码编译后的指令进行重排序。分为3种:
1:编译器优化重排:
编译器的优化前提是在保证不改变单线程语义的情况下,对重新安排语句的执行顺序。
2:指令并行重排:
如果代码中某些语句之间不存在数据依赖,处理器可以改变语句对应机器指令的顺序
如:int x = 10;int y = 5;对于这种x y之间没有数据依赖关系的,机器指令就会进行重新排序。但是对于:int x = 10; int y = 5; int z = x+y;这种的,因为z和x y之间存在数据依赖(z=x+y)关系。在这种情况下,机器指令就不会把z排序在xy前面。
3:内存系统的重排序
我们知道处理器和主内存之间还存在一二三级缓存。这些读写缓存的存在,使得程序的加载和存取操作,可能是乱序无章的。
执行顺序:
源码编译器优化重排序(第一次排序) 指令重排序(第二次)内存重排序(第三次) 最终指向的指令。
无论是第一次编译器的重排序还是第二、三次的处理器重排序。这些重排序当在多线程的场景下可能会出现线程可见性的问题。
如在多线程的情况下,单例模式就不安全了。
为了解决这个问题,JVM允许编译器在生成指令顺序的时候,可以插入特定类型的内存屏障来禁止指令重排序。
当一个变量使用volatile修饰的时候,volatile关键字就是内存屏障。当编译器在生成指令顺序的时候,发现了volatile,就直接忽略掉。不再重排序了。
android中的应用
我们都知道,Java的线程是不可以中断的,所以如果想要做取消下载的功能,一般都是通过标记位来实现的,代码如下:
public class DownloadTask {
boolean isCanceled = false;
public void download() {
new Thread(new Runnable() {
@Override
public void run() {
while (!isCanceled) {
byte[] bytes = readBytesFromNetwork();
if (bytes.length == 0) {
break;
}
writeBytesToDisk(bytes);
}
}
}).start();
}
public void cancel() {
isCanceled = true;
}
}
如果是down()和cancel()在两个线程中调用时,存在着这样一种可能,就是我们明明已经将isCanceled变量设置成了true,但是download()方法所使用的CPU高速缓存中记录的isCanceled变量还是false,从而导致下载无法被取消的情况出现。
最安全的做法是volatile boolean isCanceled = false;
参考文献:
郭霖《volatile关键字在Android中到底有什么用?》
《Android设计模式之单例模式》
《Java多线程之线程重排》
以上是关于volatile可见性,指令重排的主要内容,如果未能解决你的问题,请参考以下文章
轻量级的同步机制——volatile语义详解(可见性保证+禁止指令重排)
Java多线程——Volatile关键字保证可见性,有序性,禁止指令重排实现
Java多线程——Volatile关键字保证可见性,有序性,禁止指令重排实现