Java 多线程 :Volatile

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

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

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

计算机系统内存模型

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

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

Java 内存模型

Volatile 的官方定义

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

内存不可见的含义

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

可见性的定义:可见性是指当一个线程修改了共享变量的值,其余线程可以当即获得这个修改。架构

在上面的情景中,不一样线程的对主内存变量副本的操做不可以即时的反馈到主内存区,其余线程的工做内存更是没法感知,内存不可见。并发

如何保证内存可见

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

语言 代码片断
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高级特性与最佳实践
相关文章
相关标签/搜索