Java 多线程 :Volatile

386 阅读8分钟

在多线程并发编程中,锁的运用很常见。synchronized 的几种运用方式,相信大部分 Java 程序员已经很熟悉。而 volatile 作为轻量级的 synchronized,不会像锁一样造成阻塞,因此,在能够安全使用 volatile 的情况下,volatile 可以提供一些优于锁的可伸缩特性。如果读操作的次数要远远超过写操作,与锁相比,volatile 变量通常能够减少同步的性能开销。

在现代计算机系统中,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓存的内存读写了。

下面是计算机系统中处理器、高速缓存、主内存间的交互关系:

计算机系统内存模型

基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性。

下面是Java中线程、主内存、工作内存交互关系:

Java 内存模型

Volatile 的官方定义

Java 语言规范第三版中对 volatile 的定义如下: java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了 volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,java线程内存模型确保所有线程看到这个变量的值是一致的。

内存不可见的含义

在 JVM 中,对于多线程应用,如果多个线程同时使用某个没有 volatile 修饰的变量时,每个线程会从主内存拷贝目标变量到当前线程的工作内存中,然后在各自的工作内存进行具体的操作。

可见性的定义:可见性是指当一个线程修改了共享变量的值,其他线程能够立即得到这个修改。

在上面的情景中,不同线程的对主内存变量副本的操作不能够即时的反馈到主内存区,其他线程的工作内存更是无法感知,内存不可见。

如何保证内存可见

volatile 如何实现内存可见的呢? 在x86处理器下通过工具获取JIT编译器生成的汇编指令:

语言 代码片段
Java instance = new Singleton();
//instance 是 volatile 修饰变量
汇编 0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);

有 volatile 变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发了两件事情。

  • 将当前处理器缓存行的数据回写到系统内存。
  • 这个回写内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。

也就是说,处理器为了提高处理速度,不直接和内存通讯,而是先将内存数据拷贝到缓存后再操作(同上图)。如果变量声明了 volatile,那么处理器读取操作会直接和内存进行通讯,将变量所在缓存行的数据直接写入系统内存或者直接读取系统内存数据。但是如果其他处理器缓存的数据仍然是旧的数据,那么再执行计算操作就是无意义的。所以这里就存在缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检测自身缓存是否过期,如果检测到自己缓存行对应的数据被修改,那么会将当前处理器缓存行设置为无效状态。当处理器需要该数据进行操作时,会强制从系统内存重新加载到当前处理器缓存中。

缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据。

具体的专有名词及细节可以看文末的 reference(本节内容摘录自文末的参考文章).

保证对 64 位变量读写的原子性

JVM 可以保证对 32位 数据读写的原子性,但是对于 long 和 double 这样 64位 的数据的读写,会将其分为 高32位 和 低32位 分两次读写。所以对于 long 或 double 的读写并不是原子性的,这样在并发程序中共享 long 或 double 变量就可能会出现问题,于是 JVM 提供了 volatile 关键字来解决这个问题:

使用 volatile 修饰的 long 或 double 变量,JVM 可以保证对其读写的原子性。

但是,此处的 “写” 仅指对 64位 的变量进行直接赋值。

指令重新排序对 volatile 的影响

如果一个操作不是原子操作,那么 JVM 便可能会对该操作涉及的指令进行 重排序优化。重排序即在不改变程序语义的前提下,通过调整指令的执行顺序,尽可能达到提高运行效率的目的。

int a = 1;
int b = 2;

a++;
b++;

可能会被重新排序为:

int a = 1;
a++;

int b = 2;
b++;

这样看是没什么影响的。

但当一个变量是 volatile 修饰时,指令重排序就可能会出现问题。

public class Counter {
    private int numA;
    private int numB
    private volatile int numC;

    public void update(int numA, int numB, int numC){
        this.numA  = numA;
        this.numB = numB;
        this.numC   = numC;
    }
}

当 update 方法调用时,numA,numB,numC 的新值都会直接写入系统内存。但是如果重新排序成这样:

public void update(int numA, int numB, int numC){
    this.numC  = numC;
    this.numA   = numA;
    this.numB = numB;
}

修改 numC 变量时,A和B的值仍会写入主内存,但这一次是在A和B的新值写入之前发生的。因此,其他线程无法正确地看到A和B的新值。重新排序的指令的语义已经改变。

为了解决指令重新排序这个难题,Java volatile 关键字除了提供可见性保证之外,还提供“happens-before”保证:

  • 如果读取/写入其他变量的操作最初就发生在写入 volatile 修饰变量之前,那么指令重新排序时,不允许这个操作被排到被 volatile 修饰的变量写入之后;注意,对于其他变量的操作最初发生在写入 volatile 修饰变量之后的,那么重新排序是仍然有可能排到 volatile 修饰变量写入之前。

  • 如果读取/写入其他变量的操作最初就发生在写入 volatile 修饰变量之后,那么指令重新排序时,不允许这个操作被排到被 volatile 修饰的变量写入之前;注意,对于其他变量的操作最初发生在写入 volatile 修饰变量之前的,那么重新排序是仍然有可能排到 volatile 修饰变量写入之后。

上述的“happens-before”保证正在被实施。

必须保证操作原子性

对 volatile 修饰的变量操作时,即使每次都是从系统内存读取,都是直接写入系统内存,仍然会存在问题。

当多个线程同时写入一个 volatile 变量时,例如 i++ 操作。对于 i++ 这个语句,事实上涉及了 读取-修改-写入 三个操作:

  • 读取变量到栈中某个位置
  • 对栈中该位置的值进行自增
  • 将自增后的值写回到变量对应的存储位置

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

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

合适的使用场景

读取和写入一个 volatile 变量会直接和系统内存通信,对比与处理器缓存通信的消耗要大得多。访问 volatile 变量还防止指令重新排序,这是一种正常的性能增强技术。所以只有在真正需要变量强制可见性时才应该使用。

具体的几种场景可以参考正确使用 Volatile 变量

参考资料:

  1. www.infoq.com/cn/articles…
  2. www.ibm.com/developerwo…
  3. tutorials.jenkov.com/java-concur…
  4. 深入理解Java虚拟机 - JVM高级特性与最佳实践