概念

volatile是Java中的关键字,用来修饰会被不同线程访问和修改的变量。volatile可以说是java虚拟机提供的最轻量级的同步机制。

当一个变量被定义成volatile之后,它具备2个特性

第一项是保证此变量对所有线程的可见性

这里的可见性是指当一个线程修改了这个变量的值,新值对于其它线程来说是可以立即得知的,二普通变量并不能做到这一点,普通变量的值在线程间传递是均需要通过主内存来完成。比如,线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对主内存进行读取,新变量值才会对线程B可见。

用下面这段代码验证可见性

public class Test {
    static class Data{
        int number =0;
        //volatile int number =0;加上volatile
        public void add()
        {
            this.number = number +1;
        }
    }

    public static void main(String[] args) {
        Data date=new Data();
        
        //线程A
        Thread A=new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(30);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                date.add();
                System.out.println("线程A运行");
            }
        });
        A.start();
        
        //主线程
        while (date.number==0){

        }
    }
}

输出如下,程序一直在死循环中

线程A运行

解释:

线程A对number的值进行了加一操作,那么下面这段代码本应该结束循环,程序结束。但是由于主线程并不知道线程A修改了number,所以一直在循序中。

 while (date.number==0){}

给变量number加上volatile后,主线程知道线程A修改了number变成了1,所以结束while循环。

但是这并不能保证volatile变量是线程安全的。

由于java里面运算操作符并不是原子操作,这导致volatile变量的运算在并发下一样是不安全的。

我们可以通过一段代码演示:

public class Test {
    static volatile int number =0;
    public static void add(){
        number++;
    }

    public static void main(String[] args) {
        Thread[] threads=new Thread[20];//创建20个线程
        for (int i=0;i<20;i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    //每个线程对number进行10000次自增
                    for (int i = 0; i < 10000; i++) {
                        add();
                    }
                }
            });
            threads[i].start();
        }
        //等待所有累加线程都结束
        while (Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println(number);
    }
}

这段代码发起了20个线程,每个线程对number进行10000次自加操作,如果这段代码能够正确并发的话,最后输出的结果应该是200000.但是每次运行完这段代码得到的结果都不一样,都是一个小于200000的值。这是为什么呢?

问题就出在“number++”之中,通过javap反编译这段代码后得到这样一段代码

发现只有一行代码的“number++”是有4条字节码指令构成,说明运算操作符并不是原子操作(原子操作:即操作是不可分割的,一步完成

 Code:
      stack=6, locals=3, args_size=1
      0: getstatic     #7   //加载               // Field number:I
      3:iconst_1      
      4: iadd        //加运算
      5: putstatic     #7    //返回              // Field number:I

从字节码层面就很容易分析出失败的原因了:当getstatic指令把number的值取得栈顶时,volatile保证了number的值在此时时正确的,但是在执行iconst_1、iadd这些指令时,其它线程可能已经把number的值改变了,而操作栈顶的值就变成了过期数据,所以putstatic指令执行后就把较小的值返回了内存。

实事求是的说,用字节码来分析并发问题也并不完全严谨,因为即使编译出来只有一条字节码指令,也并不意味着执行这条指令就是原子操作。一条字节码指令在解释执行时,解释器要运行许多行代码才能实现它的语义。但是这里“number++”一步操作出现4条字节码指令已经能解释说明问题。

所以volatile变量只能保证可见性,在不符合以下条件的运算场景下,我们仍然需要加锁来保证原子性(如使用:synchronize或原子类等)

  • 运算结果不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值(通俗说:在多个线程中,只有一个线程对变量的值进行改变,而其他线程只进行读操作)
  • 变量不需要与其他的变量共同参与不变约束

使用volatile变量的第二个语义是禁止指令重排序优化

普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作顺序与程序代码中的执行顺序一致,在同一线程方法执行中是无法感知到这一点的(引用来自《深入理解java虚拟机》)

上面一段话可能不好理解,下面用一段代码举例

这是一个双锁检测单例代码

public class Singleton {
    static Singleton singleton;

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

因为有指令重排序的存在,双锁检测也不一定是线程安全的。因为instance = new Singleton(); 初始化对象的过程其实并不是一个原子的操作,它分为5个步骤

  • 为对象分配内存(为对象分配内存的任务实际上便等同于把一块确定大小的内存从java堆中划分出来)
  • 内存分配完成之后必须将分配到的内存空间都初始化为零值Null(这部操作保证了对象的实例字段在java代码中可以不赋初值就直接使用)
  • 对对象进行必要设置(例如这个对象时那个类的实例,对象的GC分代年龄等)
  • 执行new指令,调用构造方法对对象初始化
  • 将 instance 对象指向分配的内存空间

步骤 4 和 5不存在数据依赖关系,如果虚拟机存在指令重排序优化,则步骤 4和 5的顺序是无法确定的。如果A线程率先进入同步代码块并先执行了 5 而没有执行 4,此时因为 instance 已经非 null。这时候线程 B 在第一次检查的时候,会发现 instance 已经是 非null 了,就将其返回使用,但是此时 instance 实际上还未初始化,自然就会出错。所以我们可以用volatile限制实例对象的指令重排序

volatile变量读操作的性能消耗与普通变量几乎没什么差别,但是写操作可能会慢些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。不过即便如此,大多数场景下volatile总体开销还是要比锁低,我们在volatile与锁中选择的唯一判断依据仅仅是volatile的语义能否满足使用场景需求。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐