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

1,779 阅读10分钟

一、基本概念

1.1 内存模型

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

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

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 操作 这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

二、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++;
    }
}

三、参考文献

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