Volatile关键字

简述:volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”,可见性的意思是一个线程修改一个共享变量时,另一个线程可以读到这个修改的值,如果volatile使用恰当的话,它比synchronized的使用成本更低,因为它不会引起线程的上下文切换和调度。

在了解volatile关键字在java中的使用之前,我们需要先连接几个概念

java内存模型

java内存模型规定所有的变量都存放在主内存当中,每个线程在执行的时候,会从主内存当中拷贝一份到自己的工作内存当中,线程对变量的读取,操作都是在工作内存当中执行的,不同线程之间也不能相互访问其他线程的工作内存,那么线程之间的变量传递需要通过主内存来实现共享。那么什么时候把修改过得变量更新到主内存当中去,就是多线程场景下需要解决的问题,否则将会造成数据的不一致。

而volatile关键字就是为了解决数据一致性的问题,通俗来说就是线程A对变量的修改,会直接刷新到主内存,线程B当中,在对变量进行读取的时候,发现变量是volatile关键字修饰的变量,直接放弃从工作内存当中读取,而是从主内存中读取

从上面的一段分析来看,volatile关键字是可以保证变量的一致性的,我们看下下面的这段代码

public class Test {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

Q:上述代码每次的执行结果都是1000吗?

A:不一定

虽然我们对变量使用了volatile关键字修饰,也保证了每次变量发生变化时,都会刷新到主内存,并且通知其他线程,你的工作内存当中这个缓存变量失效了,你要从主内存中获取最新的,那为什么还是会发生这个数据小于1000的情况呢?原因就是i++这个操作不具有原子性。

我们假设线程A在正在执行i++这个操作,由于这个操作不是原子性的,如果线程A在执行i++这个操作过程中发生了阻塞,而i这个变量还没刷新到主内存中去,这个时候线程B也刚好要执行i++这个操作,那么线程B从主内存拿到的数据,就不是线程A中i++之后的数据,而且i++之前i的值,因此就会造成最终结果小于1000的这种情况。

那么什么是原子性操作?

java当中原子性操作就是,要么执行成功,要么执行失败,不会存在执行过程中被中断,在java内存模型当中,只有读取元素,赋值(将指定的数值赋值如i=4)操作是原子性操作,其他的操作基本上都不是原子性操作,如果想要实现大面积的原子性操作,建议是使用synchronized关键字或者lock加锁,这样就能保证同一段代码,在某一个时刻只有一个线程在访问。

内存可见性

普通变量:对于读操作会先从工作内存当中读取,如果工作内存当中没有,会从主内存当中拷贝一份到工作内存,然后再进行读取,对于写操作,会直接操作工作内存当中的副本,什么时候写入到主内存中是不确定的,这种情况下,其他线程就无法获取到这个变量的最新值。

volatile变量:
在读操作时,JMM会把工作内存当中的变量设置为无效,要求线程直接从主内存当中读取;写操作时,会把修改过的变量更新到主内存中去,其他线程就会拿到主内存当中地最新值。

关于指令重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,指令重排序分为以下三种

1、编译器优化的重排序,编译器在不改变单线程语义的前提下,可以重新安排语句的执行顺序。

2、指令级并行的重排序,如果数据之间不存在依赖性,处理器可以改变语句对应机器的执行顺序

3、内存系统的重排序,由于处理器使用缓存和读/写缓存,这使得加载和存储操作看上去可能是乱序在执行。

java 源代码到最终执行指令序列,会分别经理以下三种重排序

源代码 —–》编译器优化重排序—-》指令级并行重排序—–》内存系统重排序——》最终执行的重排序

关于指令重排序的一个例子

public class RecordExample {
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;  // 1
        flag = true; // 2
    }
    public void reader() {
        if(flag){  //3
            int i = a * a; //4
        }
    }
}

flag 是一个变量,用来标记a 是否已经被写入,假设有两个线程A和B,A线程执行writer()方法,然后B线程执行reader()方法,线程B在执行4的时候,能否看到线程A在操作1 对变量的写入呢?

不一定,由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作进行重排序,同样,操作3和操作4也没有数据依赖关系(只有控制依赖关系,编译器和处理器会猜测执行和克服控制相关性对并行度的影响),编译器和处理器也可以对这两个操作进行重排序。

下面我们来分析下不同场景下的操作结果

情景一:线程A 先执行操作2,然后线程B 判断flag的值,为true,然后执行操作4,i的值等于0,然后线程A 执行操作2,这种情况下,我们的程序就出了问题

情景二:线程B 先执行 a a, 然后把计算的结果存放到一个名为重排序缓冲的硬件缓冲中,当操作3的判断为true,再把计算结果写入到变量i当中。这个过程的执行顺序是: 线程B先计算 aa 的值,然后线程A 执行writer方法,将a = 1,复制,然后修改flag的值,线程B 判断flag=true ,将之前计算好的数据,赋给i ,可以看出对操作3 和操作4进行了重排序

这时候我们再来看volatile关键字的作用,他会禁止对变量的重排序,这里其实是有两层意思

1、当程序在对volatile变量进行读或者写时,那么它前面的代码一定是执行完了,其结果对后面的操作是可见的

2、在进行指令优化时,不能对volatile变量的访问放在后面执行,也不能对volatile变量后面的变量访问放在前面执行

//x、y为非volatile变量
//flag为volatile变量

x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

以上执行顺序,语句1、2一定会在语句3前面执行,语句4、5一定会在语句3前面执行,而语句1、2的执行顺序是不确定的

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

对于以上代码,如果inited没有volatile修饰的话它的执行顺序可能是这样的

语句2的执行是在语句1 前面,这个时候线程2 拿到inited的值是true, 而loadContext并没有执行结束,但是如果将inited 加上volatile关键字,语句1 一定是在语句2前面执行,线程2 在判断时,loadContext已经执行结束了

volatile的最佳实践

1、修改boolean类型的变量,来作为信号灯

public class ServerHandler {
    private volatile isopen;
    public void run() {
        if (isopen) {
           //促销逻辑
        } else {
          //正常逻辑
        }
    }
    public void setIsopen(boolean isopen) {
        this.isopen = isopen
    }
}

2、单例模式情况下,doubleCheck

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

对于Singleton这个变量为什么使用volatile修饰,因为new Singleton这个操作并不是原子的,它实际上执行了以下几个操作

1、给instance 分配内存

2、调用Singleton的init方法,实现参数的构建操作

3、将instance执向分配的内存

对于上面的操作,其执行顺序有可能是1-2-3,也有可能是1-3-2,如果是1-3-2,instance的值不为null,但是init方法还没有执行完,其他的线程在调用时,发现不为null,直接返回,那就就会抛出异常。

volatile的内存语义

1、线程A写入一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。

2、线程B读一个volatile变量,实质上是线程B接收到了某个线程发出的(在写这个volatile变量之前对共享变量所做的修改的)消息

3、线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

volatile修饰的共享变量在进行汇编时,会多出来一个lock前缀,lock前缀在多核处理器下会引发两件事

1、当前处理器缓存行地数据写入到系统内存

2、这个写会内存的操作会使其他CPU里缓存了改地址的数据无效

如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据写会到主内存中。在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是否过期,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存设置成无效。

volatile内存语义的实现

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是JMM采取保守策略

1、在每个volatile写操作的前面插入一个StoreStore屏障。

2、在每个volatile写操作的后面插入一个StoreLoad屏障。

3、在每个volatile读操作的后面插入一个LoadLoad屏障。

4、在每个volatile读操作的后面插入一个LoadStore屏障。

针对上面屏障的作用我们分别来记录下,如果代码流程是这样子的

普通读

|

普通写

|

StoreStore屏障(禁止上面的普通写和volatile写重排序)

|

volatile写

|

StoreLoad屏障 (防止volatile写与下面的可能有的volatile读/写重排序)

总结来说就是:StoreStore屏障保证volatile写之前,前面的普通读写已经对任意处理器可见,storeLoad屏障是避免当前volatile写与后面可能有的volatile读/写指令重排序。因为编译器不能判断,在执行完volatile之后是否需要插入一个StoreLoad屏障,为了实现volatile的内存语义,JMM采取了保守策略:在每个volatile写后面,或者在volatile读前面插入StoreLoad屏障,由于比较常见的模式是:一个线程写volatile变量,多个线程读同一个volatile变量。因此选择在写入之后,插入一个StoreLoad屏障,来提升效率。

在保守策略下:volatile读插入内存屏障后生成的指令序列图是

volatile读

|

LoadLoad屏障(禁止下面所有的普通读操作和上面的volatile读重排序)

|

LoadStore屏障(禁止下面所有的普通写操作和上面的volatile读重排序)

|

普通读

|

普通写

在实际执行过程中,只要不改变volatile写-读的内存语义,编译器可以根据实际情况省略掉不必要的屏障。

参考:Java并发编程:volatile关键字解析

java volatile关键字解惑