多线程知识梳理(8) - volatile 关键字

1、基本概念

1.1 内存模型

在程序的执行过程当中,涉及到两个方面:指令的执行和数据的读写。其中指令的执行经过处理器来完成,而数据的读写则要依赖于系统内存,可是处理器的执行速度要远大于内存数据的读写,所以在处理器中加入了高速缓存。在程序的执行过程当中,会 先将数据拷贝处处理器的高速缓存中,待运算结束后再回写到系统内存当中html

在单线程的状况下不会有什么问题,可是若是在多线程状况下就可能会出现异常的状况,如下面这段代码为例,i是放在堆内存的共享变量:java

i = i + 1; //i 的初始值为0。
复制代码

假如线程A和线程B都执行这段代码,那么就可能出现下面两种状况:编程

  • 第一种状况:线程A先执行+1操做,而后将i的值写回到系统内存中;线程B从系统内存中拷贝i的值1到高速缓存中,执行完+1操做再回写到系统内存中,最终的结果是i=2
  • 第二种状况:线程A和线程B首先都将i的值0拷贝到各自处理器的高速缓存当中,线程A首先执行+1操做,以后i的值为1,而后写回到系统内存中;可是对于线程B而言,它并不知道这一过程,在运行该线程的处理器的高速缓存中i的值仍然为0,所以在它执行+1操做后,再将i的值写回到系统内存中,最终的结果是i=1

这种不肯定性就称为 缓存不一致缓存

1.2 并发编程中的三个概念

在并发编程中,有三个关键的概念:可见性、原子性和有序性,只有保证了这三点才能使得程序在多线程状况下得到预期的运行结果。安全

1.2.1 可见性

可见性:是指线程之间的可见性,一个线程修改的状态对另外一个线程是可见的。也就是一个线程修改的结果,另外一个线程立刻就能看到。在1.1所举的例子就存在可见性的问题。多线程

Javavolatilesynchronizedfinal实现可见性。并发

1.2.2 原子性

原子性:即一个操做或者多个操做,要么所有执行而且执行的过程不会被任何因素打断,要么就都不执行。框架

再好比a++,这个操做实际是a=a+1,是可分割的,因此它不是一个原子操做。非原子操做都会存在线程安全问题,须要咱们使用同步技术来让它变成一个原子操做。一个操做是原子操做,那么咱们称它具备原子性。编程语言

Javasynchronized和在lockunlock中操做或者原子操做类来保证原子性。函数

1.2.3 有序性

有序性:即程序执行的顺序按照代码的前后顺序执行。如下面的代码为例:

int i = 0;              
boolean flag = false;
i = 1; //语句1 
flag = true; //语句2
复制代码

在上面的代码中定义了一个整形和Boolean型变量,并经过语句1和语句2对这两个变量赋值,可是JVM在执行这段代码的时候并不保证语句1在语句2以前执行,也就是说可能会发生 指令重排序

指令重排序指的是在 保证程序最终执行结果和代码顺序执行的结果一致的前提 下,改变语句执行的顺序来优化输入代码,提升程序运行效率。

可是这一前提条件在多线程的状况下就有可能出现问题,如下面的代码为例:

//线程1:
context = loadContext(); //语句1
inited = true; //语句2
 
//线程2:
while (!inited) {
    sleep()
}
doSomethingWithConfig(context);
复制代码

对于线程1来讲,语句1和语句2没有依赖关系,所以有可能会发生指令重排序的状况。可是对于线程2来讲,语句2在语句1以前执行,那么就会致使进入doSomethingWithConfig函数的时候context没有初始化。

Java语言提供了volatilesynchronized两个关键字来保证线程之间操做的有序性,volatile是由于其 自己包含禁止指令重排序 的语义,synchronized是由 一个变量在同一个时刻只容许一条线程对其进行 lock 操做 这条规则得到的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

2、volatile 详解

2.1 定义

volatile的定义以下:Java编程语言容许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保 经过排它锁单独地得到这个变量。若是一个字段被声明成volatileJava线程内存模型确保 全部线程看到这个变量的值是一致的

一旦一个共享变量被volatile修饰以后,那么就具有了两层语义:

  • 保证了不一样线程对这个变量进行操做时的可见性,即一个线程修改了某个变量的值,这新值对其余线程来讲是当即可见的。
  • 禁止进行指令重排序。

下面,咱们用两个小结解释一下这两层语义。

2.2 保证可见性

当咱们在X86处理器下经过工具获取JIT编译器生成的汇编指令,来查看对volatile进行写操做时,会发生下面的事情:

//Java 代码
instance = new Singleton(); //instance 是 volatile 变量

//转变成汇编代码
0x01a3de1d: move $0 x 0, 0 x 1104800 (%esi); 
0x01a3de24: lock add1 $ 0 x 0, (%esp);
复制代码

volatile变量修饰的共享变量 进行写操做的时候 会多出两行汇编代码,Lock前缀的指令在多核处理器下引起了两件事情:

  • 将当前处理器 内部缓存 的数据写回到 系统内存
  • 这个写回内存的操做会使在其余处理器里 缓存了该内存地址的数据无效,当这些处理器对这个数据进行修改操做的时候,会从新从系统内存中把数据读处处理器缓存里。

2.3 禁止指令重排序

volatile关键字禁止指令重排序有两层意思:

  • 当程序执行到volatile变量的读操做或者写操做时,在其前面的操做的更改确定所有已经进行,且结果已经对后面的操做可见;在其后面的操做确定尚未进行;
  • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

如下面的例子为例:

//flag 为 volatile 变量

x = 2; //语句1
y = 0; //语句2
flag = true;  //语句3
x = 4; //语句4
y = -1; //语句5
复制代码

因为flagvolatile变量,所以,能够保证语句1/2在语句3以前执行,语句4/5在其以后执行,可是并不保证语句1/2之间或者语句4/5之间的顺序。

对于1.2.3举的有关Context问题,咱们就能够经过将inited变量声明为volatile,这样就会保证loadContext()inited赋值语句之间的顺序不被改变,避免出现inited=true可是Context没有初始化的状况出现。

2.4 性能问题

volatile相对于synchronized的优点主要缘由是两点:简易和性能。若是从读写两方便来考虑:

  • volatile读操做开销很是低,几乎和非volatile读操做同样
  • volatile写操做的开销要比非volatile写操做多不少,由于要保证可见性须要实现 内存界定,即使如此,volatile的总开销仍然要比锁获取低。volatile操做不会像锁同样 形成阻塞

以上两个条件代表,能够被写入volatile变量的这些有效值 独立于任何程序的状态,包括变量的当前状态。大多数的编程情形都会与这两个条件的其中之一冲突,使得volatile不能像synchronized那样广泛适用于实现线程安全。

所以,在可以安全使用volatile的状况下,volatile能够提供一些优于锁的可伸缩特性。若是读操做的次数要远远超过写操做,与锁相比,volatile变量一般可以减小同步的性能开销。

2.5 应用场景

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

  • 对变量的 写操做不依赖于当前值。例如x++这样的增量操做,它其实是一个由读取、修改、写入操做序列组成的组合操做,必须以原子方式执行,而volatile不能提供必须的原子特性。
  • 该变量 没有包含在其它变量的不变式中

避免滥用volatile最重要的准则就是:只有在 状态真正独立于程序内其它内容时 才能使用volatile,下面,咱们总结一些volatile的应用场景。

2.5.1 状态标志

volatile来修饰一个Boolean状态标志,用于指示发生了某一次的重要事件,例如完成初始化或者请求停机。

volatile boolean shutdownRequested;
 
...
 
public void shutdown() { shutdownRequested = true; }
 
public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}
复制代码

2.5.2 一次性安全发布

在解释 一次性安全发布 的含义以前,让咱们先来看一下 单例写法 当中著名的 双重检查锁定问题

//使用 volatile 修饰。
    private volatile static Singleton sInstance;

    public static Singleton getInstance() {
        if (sInstance == null) { //(0)
            synchronized (Singleton.class) { //(1) 
                if (sInstance == null) {  //(2) 
                    sInstance = new Singleton(); //(3) 
                }
            }
        }
        return sInstance;
    }
复制代码

假如 没有使用volatile来修饰sInstance变量,那么有可能会发生下面的场景:

  • 第一步:Thread1进入getInstance()方法,因为sInstance为空,Thread1进入synchronized代码块。
  • 第二步:Thread1前进到(3)处,在构造函数执行以前使sInstance对象成为非空,并设置sInstance指向的内存空间。
  • 第三步:Thread2执行,它在入口(0)处检查实例是否为空,因为sInstance对象不为空,Thread2sInstance引用返回,此时sInstance对象并无初始化完成
  • 第四步:Thread1经过运行Singleton对象的构造函数并将引用返回给它,来完成对该对象的初始化。

经过volatile就能够禁止第二步和第四步的重排序,也就是使得 初始化对象在设置 sInstance 指向的内存空间以前完成

2.5.3 volatile bean 模式

volatile bean模式适用于将JavaBeans做为“荣誉结构”使用的框架。在volatile bean模式中,JavaBean被用做一组具备getter和/或setter方法的独立属性的容器。

volatile bean模式的基本原理是:不少框架为易变数据的持有者提供了容器,可是放入这些容器中的对象必须是线程安全的。

volatile bean模式中,JavaBean的全部数据成员都是volatile类型的,而且 gettersetter方法必须很是普通,除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。

public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;
 
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }
    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }
    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }
    public void setAge(int age) { 
        this.age = age;
    }
}
复制代码

2.5.4 开销较低的读/写锁策略

若是读操做远远超过写操做,您能够结合使用内部锁和volatile变量来减小公共代码路径的开销。下面的代码中使用synchronized确保增量操做是原子的,并使用volatile保证当前结果的可见性。若是更新不频繁的话,该方法可实现更好的性能,由于读路径的开销仅仅涉及volatile读操做,这一般要优于一个无竞争的锁获取的开销。

public class CheesyCounter {
    private volatile int value;
    public int getValue() { return value; }
    public synchronized int increment() {
        return value++;
    }
}
复制代码

3、参考文献

(1) Java 并发编程:volatile 关键字解析
(2) Java 中 volatile 关键字详解
(3) 正确使用 volatile 变量
(4) volatile 的使用

相关文章
相关标签/搜索