Java volatile 使用与原理

volatile 关键字

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

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

volatile 的作用

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

  • 线程可见性
    对普通变量进行读写的时候,每个线程先从内存拷贝变量到 CPU 缓存中,如果计算机有多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程可以拷贝到不同的CPU缓存中。
    volatile 变量是进行读写时,JVM 保证了每次读变量都从内存中读,跳过 CPU 缓存。写变量时会刷新到内存,新值对其他线程来说是立即可见的。

  • 禁止指令重排序
    一般来说,处理器为了提高程序运行效率,会对输入代码进行优化,不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是会保证程序最终执行结果和代码顺序执行的结果是一致的。
    这种重排序,对某些代码会有影响。JMM(Java 内存模型,Java memory mode)具备一些先天的“有序性”,不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从 happens-before 原则推导出来,那么就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

使用注意

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

内存屏障(Memory barrier)

为什么会有内存屏障

  • 每个 CPU 都有自己的缓存来提高性能,避免每次都向内存取。这样的弊端:不能实时的和内存发生信息交换,分在不同 CPU 执行的不同线程对同一个变量的缓存值不同。
  • 用内存屏障可以解决上述问题,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,Java 通过屏蔽这些差异,统一由 JVM 来生成内存屏障的指令。

内存屏障是什么

  • 硬件层的内存屏障分为两种:Load BarrierStore Barrier,即读屏障和写屏障。
  • 内存屏障主要作用:阻止屏障两侧的指令重排序;

  • 对于Load Barrier来说,在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;

  • 对于Store Barrier来说,在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

内存屏障的种类

LoadLoad 屏障

序列:Load1;Loadload;Load2

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

StoreStore 屏障

序列:Store1;StoreStore;Store2

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

LoadStore 屏障

序列: Load1;LoadStore;Store2

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

StoreLoad 屏障

序列: Store1;StoreLoad;Load2

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

happens-before 原则

程序次序规则:在一个线程内一段代码的执行结果是有序的。

管程锁定规则:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果(管程是一种通用的同步原语,synchronized 就是管程的实现)

volatile 变量规则:如果一个线程先去写一个 volatile 变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。

线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程 join() 规则。

线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过 Thread.interrupted() 检测到是否发生中断。

传递性规则:happens-before 原则具有传递性。

对象终结规则:一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before 它的 finalize() 方法。

单例模式中的 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>() 方法,因为在同一个类加载器下,一个类型只会被初始化一次。

volatile 适用场景

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

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

最常见的 volatile 适用于状态标记量:

volatile boolean flag = false;

while(!flag) {
    doSomething();
}

public void setFlag() {
    flag = true;
}

《深入理解Java虚拟机》

Tags:,

Add a Comment

电子邮件地址不会被公开。 必填项已用*标注