Java

Java volatile 使用与原理

2019-04-15  本文已影响0人  Alex90

volatile 关键字

Java 提供了一种稍弱的同步机制(相比于 synchronized),即 volatile 变量,用来确保将变量的更新操作通知到其他线程。

在访问 volatile 变量时不会执行加锁操作,不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。

volatile 的作用

volatile 修饰的变量具有两层语义:

使用注意

volatile 变量不保证原子性。如自增操作,不可以随意使用 volatile,可以使用 AtomicXXX 类(java.util.concurrent.atomic)或锁来代替。

public class Example {

    public static volatile int count = 0;
 
    private static void add() {
        count++;
    }
}

如果并发执行上面的 add() 方法,count 的最终结果很可能不是期望值。

执行 conut++ 时需要三个步骤:第一步是取出当前内存 count 值,这时 count 值是最新的,接下来两步操作,分别是 +1 和重新写回主存。假设有两个线程同时在执行 count++,都执行了第一步,取到最新值(取到的值相同),然后分别执行了 +1,并写回主存,这样实际上只进行了一次 +1 操作。

volatile 实现方式

加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令。(《深入理解Java虚拟机》)

lock 前缀相当于一个内存屏障,内存屏障会提供3个功能:

  1. 确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,会导致其他CPU中对应的缓存行无效。

volatile 写

在对变量进行写操作时,会在写操作后加一条store指令,防止重排序,并刷新到内存:

|  ...
|  普通读
|  普通写
|  StoreStore屏障   -> 禁止上面的普通写和下面的volatile写重排序
|  volatile写
|  StoreLoad屏障    -> 防止上面的volatile写和下面可能有的volatile写/读重排序
|  ...
V

volatile 读

在对变量进行读操作时,会在读操作前加一条load指令,从内存中读取共享变量:

|  ...
|  volatile读
|  LoadLoad屏障    -> 禁止下面的普通读和上面的volatile读重排序
|  LoadStore屏障   -> 禁止下面的写操作和上面的volatile读重排序
|  普通读
|  普通写
|  ...
V

内存屏障

简单介绍以下内存屏障的种类:

LoadLoad 屏障

序列:Load1,Loadload,Load2

确保 Load1 所要读入的数据能够在被 Load2 和后续的 load 指令访问前读入。

通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明 Loadload 屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。

StoreStore 屏障

序列:Store1,StoreStore,Store2

确保 Store1 的数据在 Store2 以及后续 Store 指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。

通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么需要使用 StoreStore 屏障。

LoadStore 屏障

序列: Load1,LoadStore,Store2

确保 Load1 的数据在 Store2 和后续 Store 指令被刷新之前读取。

在等待 Store 指令可以越过 loads 指令的乱序处理器上需要使用 LoadStore 屏障。

StoreLoad 屏障

序列: Store1,StoreLoad,Load2

确保 Store1 的数据在被 Load2 和后续的 Load 指令读取之前对其他处理器可见。

StoreLoad 屏障可以防止一个后续的 load 指令不正确的使用了 Store1 的数据,而不是另一个处理器在相同内存位置写入一个新数据。正因为如此,处理器为了在屏障前读取同样内存位置存过的数据,必须使用一个 StoreLoad 屏障将存储指令和后续的加载指令分开。

单例模式中的 volatile

一段常见的单例模式代码:


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

为什么要加 volatile?

在执行 instance=new Singleton(); 时,并不是原子语句,实际是包括了三个步骤:

  1. 为对象分配内存
  2. 初始化实例对象
  3. instance 引用指向分配的内存空间

然而这个三个步骤并不能保证按序执行,处理器会进行指令重排序优化,可能出现的情况:为对象分配内存后,还没有初始化实例对象,就已经将引用指向了内存空间。

所以在另一个线程 instance==null 判断时,不会进入代码段,而直接使用会造成错误。

volatile 的一个作用就是防止指令重排序。

这里推荐另外一种懒汉单例模式模式,使用静态内部类

public class Singleton{
    
    private Singleton(){
    }
    
    public static  Singleton getInstance() {
        return InstanceHolder.instance;
    }
    
    static class InstanceHolder {
        private static Singleton instance = new Singleton();
    }
}

静态内部类只有在调用的时候(InstanceHolder.instance)才会初始化,虚拟机会保证一个类的类构造器<clinit>()在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器<clinit>(),其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。

其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出后,其他线程在唤醒之后不会再次进入/执行<clinit>()方法,因为在同一个类加载器下,一个类型只会被初始化一次。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是隐藏的。

volatile 适用场景

基于 volatile 的可见性和不支持原子性的特性,通常来说,使用 volatile 必须具备以下2个条件:

  1. 对变量的写操作不依赖于当前值
  2. 该变量没有包含在具有其他变量的不变式中

因此,volatile 适用于状态标记量:

volatile boolean flag = false;
 
while(!flag) {
    doSomething();
}
 
public void setFlag() {
    flag = true;
}

References:

https://www.cnblogs.com/dolphin0520/p/3920373.html

https://www.cnblogs.com/zhengbin/p/5654805.html

https://blog.csdn.net/weixin_40459875/article/details/80290875

http://ifeve.com/jmm-cookbook-mb/

《深入理解Java虚拟机》

上一篇下一篇

猜你喜欢

热点阅读