Java中的volatile关键字原理深入解析
作者:外星喵
volatile介绍
Java 语言规范 volatile 关键字定义:Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
volatile通常被比喻成"轻量级的synchronized",也是Java并发编程中比较重要的一个关键字。
和synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。
volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。
private volatile static Singleton singleton;
volatile的原理
每个线程有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值,抹除)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到主存中。
但是就算写回到主存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。
缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。
所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,也会把这个变量的值从主存加载到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。
volatile与可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。
前面的关于volatile的原理中介绍过了,Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。
JVM对 volatile 的描述如下描述中写着,如果字节码中有用VOLATILE修饰的,代表这个变量不能被线程所缓存。
可以通过一下代码进行验证,你会发现该程序不会结束,但是如果你对flag加上了volatile修饰的话,结果就会不一样了:
private static boolean flag = false; public static void main(String[] args) { new Thread(() -> { flag = true; }).start(); while (!aBoolean) { } System.out.println("end"); } private static void run()
volatile与有序性
有序性即程序执行的顺序按照代码的先后顺序执行。
普通的变量仅仅会保证在该方法的执行过程中所依赖的赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add ,这就是有序性问题。
volatile可以禁止指令重排,这就保证了代码的程序会严格按照代码的先后顺序执行。这就保证了有序性。被volatile修饰的变量的操作,会严格按照代码顺序执行,load->add->save 的执行顺序就是:load->add->save 。
重排序
为什么要有重排序呢?
简单来说,就是为了提升执行效率。
为什么能提升执行效率呢?重排序可以提高程序的运行效率,但是必须遵循 as-if-serial 语义。as-if-serial 语义是什么呢?简单来说,就是不管你怎么重排序,你必须保证不管怎么重排序,单线程下程序的执行结果不能被改变(至于多线程就管不到了)。
内存屏障
如何保证CPU不会对这些操作进行重排序呢?
JVM是通过插入内存屏障保证的,JMM 规范中定义的内存屏障分为读(load)屏障和写(Store)屏障,排列组合就有了四种屏障。对于 volatile 操作,JMM 内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障
而在 x86 处理器中,有三种方法可以实现实现 StoreLoad 屏障的效果,分别为:
- mfence 指令:能实现全能型屏障,具备 lfence 和 sfence 的能力。
- cpuid 指令:cpuid 操作码是一个面向 x86 架构的处理器补充指令,它的名称派生自 CPU 识别,作用是允许软件发现处理器的详细信息。
- lock 指令前缀:总线锁。lock 前缀只能加在一些特殊的指令前面。
lock指令
实际上 HotSpot 关于 volatile 的实现就是使用的 lock 指令,只在 volatile 标记的地方加上带 lock 前缀指令操作,并没有参照 JMM 规范的屏障设计。
lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),并提供以下三个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。
volatile与原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。
为了保证原子性,需要通过字节码指令monitorenter和monitorexit,但是volatile和这两个指令之间是没有任何关系的。所以,volatile是不能保证原子性的。
在以下两个场景中可以使用volatile来代替synchronized:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程会修改变量的值。
- 变量不需要与其他状态变量共同参与不变约束。
除以上场景外,都需要使用其他方式来保证原子性,如synchronized或者JUC。
我们来看一下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); } }
以上代码比较简单,就是创建10个线程,然后分别执行1000次j++操作。正常情况下,程序的输出结果应该是10000,但是,多次执行的结果都小于10000。这其实就是volatile无法满足原子性的原因。
对于一个简单的i++操作,一共有三个步骤:load , add ,save 。共享变量就会被多个线程同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值可以会和期望的不一致。
DCL与volatile
为什么双重校验锁实现的单例中,已经使用了synchronized,为什么还需要volatile?
public class Singleton { private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }
因为singleton = new Singleton()不是一个原子操作,大概要经过这几个步骤:
- 分配一块内存空间
- 调用构造器,初始化实例
- singleton指向分配的内存空间
由于cpu重排序问题,导致实际执行步骤可能是这样的:
- 申请一块内存空间
- singleton指向分配的内存空间
- 调用构造器,初始化实例
在singleton指向分配的内存空间之后,singleton就不为空了。
但是在没有调用构造器初始化实例之前,这个对象还处于半初始化状态,在这个状态下,实例的属性都还是默认属性,这个时候如果有另一个线程调用getSingleton()方法时,会拿到这个未初始化的对象,导致出错。 而加 volatile 修饰之后,就会禁止重排序,这样就能保证在对象初始化完了之后才把singleton指向分配的内存空间,杜绝了一些不可控错误的产生。volatile 提供了 happens-before 保证,Happens-before 主要是解决前一个操作的结果必须对后一个操作可见。
到此这篇关于Java中的volatile关键字原理深入解析的文章就介绍到这了,更多相关volatile关键字原理内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!