【Java】Java关键字 - volatile 关键字

Posted by 西维蜀黍 on 2019-02-25, Last Modified on 2021-09-21

背景

volatile是Java提供的一种轻量级的同步机制。与 synchronized 块相比(synchronized通常称为重量级锁),volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是 synchronized 的一部分。

锁保证了两种主要特性:互斥(mutual exclusion)可见性(visibility)

  • **互斥(mutual exclusion)**即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。
  • **可见性(visibility)**要更加复杂一些,它必须确保释放锁之前,对共享数据做出的更改,对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值。

Java 内存模型(Java Memory Model)

在说明内存可见性之前,先来简单了解一下 Java 内存模型。

在 Java 虚拟机规范中试图定义一种 **Java 内存模型(Java Memory Model,JMM)**来屏蔽各个硬件平台和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

在 Java 内存模型里,所有变量都是存在主内存(Main memory)中的,而每个线程又包含自己的工作内存(Working memory),所有线程都只能访问自己的工作内存,且工作前后都要将值与主内存中的值进行同步(在执行对变量的操作前,先将值从主内存中读取到自己的工作内存中,在执行对变量的操作后,将新值同步回主内存中)。

以修改一个变量值为例,首先会从主内存中读取变量值,再加载到工作内存中的副本中,然后再传给处理器执行。执行完毕后,再给工作内存中的副本赋值,随后将位于工作内存中这个值同步到主内存中。最终,主内存中的值才得到更新。

volatile 变量

volatile 变量具有 synchronized 的可见性(visibility)和部分的有序性(ordering)特性,但是不具备原子性(atomicity)。换句话说,不同的线程总能够获取到 volatile 变量最新的修改值。

总结来说,被 volatile 修饰的共享变量,就具有了以下两点特性:

  • 保证了不同线程对该变量操作的内存可见性(visibility);
  • 禁止对该共享变量操作本身的指令重排序(instruction reordering)。

可见性(visibility)分析

声明为 volatile 的变量可以保证,当多个线程同时对该变量进行读写时,保持可见性(visibility)

即,当某个线程修改了该变量的值后,新值对于其他线程来说是可以立即感知的(所谓"立即感知",是指这些其他线程一旦在变量值修改操作完成后,读取这个变量的值,一定获取到的是刚刚被修改后的那个最新值),而普通变量不能做到这一点。

原理

本质原理是,当被 volatile 关键字修饰的变量被写操作之后,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到主存中。

但是就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作仍会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议(Cache Coherence Protocol)

缓存一致性协议(Cache Coherence Protocol):每个处理器通过嗅探在总线上传播的数据,来检查自己缓存中是否有值被置为无效了,如果是(这意味着其他的处理器在此前修改了这个共享变量),当处理器要对这个数据进行读取或修改操作时,就强制地重新从主内存中把数据最新的值读到处理器缓存里。

所以,如果一个变量被volatile所修饰的话,在每次数据变化之后,其值都会被强制刷入主存。而其他处理器的缓存由于遵守了缓存一致性协议,(当这些其他处理器需要使用到这个变量时)会从主存中将这个变量最新的值更新到自己的缓存中。这就保证了一个volatile在并发编程中,其值在多个缓存中是可见的。

可见性效果(visibility effects)

当一个字段(field)被声明为 volatile 后,对于这个字段的操作均不会被 Java 编译器重排序。

由于存储在 CPU 的寄存器(registers)或者缓存(caches)中的 volatile 变量被修改后,JVM 会将修改同步到主内存中。因此,对于 volatile 变量的读取,将会返回最后被重改写的那个值。

值得注意的是,从可见性效果(visibility effects)的角度而言,声明一个 volatile 变量不仅仅只是作用于这个变量本身。事实上,线程A 更新一个 volatile 变量,此后线程B 读取这个变量的值。当线程B 读取这个变量的值后,对于线程A 在更新 volatile 变量之前,对其他的所有变量的修改,线程B 都是可见的。

原子性(atomicity)分析

Volatile 关键字不能保证原子性。

举一个例子:

public class VolatileTest {
    public static volatile int race = 0;
    public static void increase() {
        race++;
    }
    private static final int THREADS_COUNT = 20;
    public static void main(String[] args) {
        Thread[] threads = new Thread[THREADS_COUNT];
        for (int i = 0; i < THREADS_COUNT; i++) {
            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        increase();
                    }
                }
            });
            threads[i].start();
        }

        // 等待所有累加线程都结束
        while (Thread.activeCount() > 1)
            Thread.yield();

        System.out.println(race);
    }
}

事实上,这段程序的输出结果是一个小于20000的数字。而且运行它会发现每次运行结果都不一致。

这是因为 volatile 关键字虽然能保证可见性(visibility),但是不能保证原子性(atomicity)。可见性只能保证每次读取 volatile 变量都是获得最新的值,但是 volatile 没办法保证对变量操作的原子性。

分析

我们使用 javap 反编译这段代码:

 public static void increase();
    Code:
       0: getstatic     #2                  // Field race:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field race:I
       8: return

问题就出现在自增运算 race++ 之中,我们用 javap 反编译这段代码后会发现只有一行代码的 increase() 方法在 Class 文件中是由 4 条字节码指令构成的。

从字节码层面上很容易就分析出并发失败的原因了:当 getstatic 指令把 race 的值取到操作栈顶时,volatile 关键字保证了 race 的值在此时是正确的,但是在执行 iconst_1、iadd 这些指令的时候,其他线程可能已经把 race的值加大了,而在操作栈顶的值就变成了过期的数据,所以 putstatic 指令执行后就可能把较小的 race 值同步回主内存之中。

volatile的使用限制

由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性。

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

有序性(ordering)分析

Volatile关键字只能保证部分的有序性。

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

普通的变量仅仅会保证在该方法的执行过程中,所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。 因为在一个线程的方法执行过程中无法感知到这点, 这也就是 Java 内存模型中描述的所谓的“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics) 。

例子1

//假设以下代码在线程A中执行
//模拟读取配置信息,当读取完成后
//将initialized设置为true通知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(flieName);
processConfigOptions(configText,configOptions);
initialized = true;

//假设以下代码在线程B中执行
//等待initialized为true,代表线程A已经把配置信息初始化完成
while(!initialized){
    sleep();
}
//使用线程A中初始化好的配置信息
doSomethingWithConfig();

在上面的例子中,如果定义 initialized 变量时没有使用 volatile 修饰, 就可能会由于指令重排序的优化,导致线程 A 中最后一句代码 initialized=true 被提前执行,这样线程 B 中使用配置信息的代码就可能出现错误。而使用 volatile 关键字则可以避免此类情况的发生。

例子2

  • 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
  • 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把在 volatile 变量后面的语句放到其前面执行。
//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

由于 flag 变量为 volatile 变量,那么在进行指令重排序的过程的时候,不会将语句3 放到语句1、语句2 前面,也不会讲语句3 放到语句4 、语句5 后面。但是要注意语句1 和语句2 的顺序、语句4 和语句5 的顺序是不作任何保证的。

并且 volatile 关键字能保证,执行到语句3 时,语句1 和语句2 必定是执行完毕了的,且语句1 和语句2 的执行结果对语句3、语句4、语句5 是可见的。

volatile 关键字的使用场景

volatile 变量可用于提供线程安全,但是只能应用于非常有限的某些场景:多个变量之间或者某个变量的当前值与修改后的值之间没有约束

因此,单独使用 volatile 无法实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类(例如 “start <=end”)。


要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

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

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(x++)看上去类似一个单独操作,实际上它是一个由读取-修改-写入操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。实现正确的操作需要使 x 的值在操作期间保持不变,而 volatile 变量无法实现这点。(然而,如果将值调整为只从单个线程写入,那么可以忽略第一个条件。

模式 #1 - 状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

很多应用程序包含了一种控制结构,形式为 “在还没有准备好停止程序时,再执行一些工作”,如下所示:

将 volatile 变量作为状态标志使用

volatile boolean shutdownRequested;
 
public void shutdown() { shutdownRequested = true; }
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

上面的这种场景就很适合使用 volatile 变量来控制并发,即当 shutdown() 方法调用时,能保证所有线程中执行的 doWork() 方法都立即停下来。

例如常见的促销活动“秒杀”,可以用 volatile 来修饰“是否售罄”字段,从而保证在并发下,能正确的处理商品是否售罄。

然而,使用 synchronized 块编写循环要比使用上面基于 volatile 状态标志的实现麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

这种类型的状态标记的一个公共特性是:通常只有一种状态转换;shutdownRequested 标志从 false 转换为 true,然后程序停止。

模式 #2 - 单例模式double check

为什么要加volatile关键字,就是为了保证instance的初始化完成之后才会被使用,以免报错。如果不使用,可能会出现,线程A先new了一个对象 分配了内存地址,但是初始化对象的工作没有完成。此时线程B进来,instance不为空。线程B持有instance然后使用的时候报错。

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;
    }
}

性能考虑

使用 volatile 变量的主要原因是其简易性:在某些情形下,使用 volatile 变量要比使用相应的锁简单得多。使用 volatile 变量次要原因是其性能:某些情况下,volatile 变量同步机制的性能要优于锁。

由于 volatile 变量中并不存在任何锁,因而访问这个变量并不会造成正在执行的线程进入阻塞态。因此,volatile 变量是一种比 synchronized 块更轻量级的同步机制。一句话概括,锁可以保证可见性和原子性,而 volatile 变量只能保证可见性。

在目前大多数的处理器架构上,volatile 读操作开销非常低 —— 几乎和非 volatile 读操作一样。而 volatile 写操作的开销要比非 volatile 写操作多很多,因为要保证可见性需要实现内存界定(Memory Fence),即便如此,volatile 的总开销仍然要比锁获取低。

volatile 操作不会像锁一样造成阻塞,因此,在能够安全使用 volatile 的情况下,volatile 可以提供一些优于锁的可伸缩特性。如果读操作的次数要远远超过写操作,与锁相比,volatile 变量通常能够减少同步的性能开销。

volatile 的实现机制

我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个lock前缀指令

lock 前缀指令实际上相当于一个内存屏障(Memory Barriers),内存屏障会提供3个功能:

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

Reference