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语义详解(可见性保证+禁止指令重排)

volatile可见性,指令重排

Java多线程——Volatile关键字保证可见性,有序性,禁止指令重排实现

Java多线程——Volatile关键字保证可见性,有序性,禁止指令重排实现

Java多线程——Volatile关键字保证可见性,有序性,禁止指令重排实现

Java多线程——Volatile关键字保证可见性,有序性,禁止指令重排实现