一、基本概念
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
所举的例子就存在可见性的问题。
在Java
中volatile
、synchronized
和final
实现可见性。
1.2.2 原子性
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
再比如a++
,这个操作实际是a=a+1
,是可分割的,所以它不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。
在Java
中synchronized
和在lock
、unlock
中操作或者原子操作类来保证原子性。
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
语言提供了volatile
和synchronized
两个关键字来保证线程之间操作的有序性,volatile
是因为其 本身包含禁止指令重排序 的语义,synchronized
是由 一个变量在同一个时刻只允许一条线程对其进行 lock 操作 这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。
二、volatile 详解
2.1 定义
volatile
的定义如下:Java
编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保 通过排它锁单独地获得这个变量。如果一个字段被声明成volatile
,Java
线程内存模型确保 所有线程看到这个变量的值是一致的。
一旦一个共享变量被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
由于flag
为volatile
变量,因此,可以保证语句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
对象不为空,Thread2
将sInstance
引用返回,此时sInstance
对象并没有初始化完成。 - 第四步:
Thread1
通过运行Singleton
对象的构造函数并将引用返回给它,来完成对该对象的初始化。
通过volatile
就可以禁止第二步和第四步的重排序,也就是使得 初始化对象在设置 sInstance 指向的内存空间之前完成。
2.5.3 volatile bean 模式
volatile bean
模式适用于将JavaBeans
作为“荣誉结构”使用的框架。在volatile bean
模式中,JavaBean
被用作一组具有getter
和/或setter
方法的独立属性的容器。
volatile bean
模式的基本原理是:很多框架为易变数据的持有者提供了容器,但是放入这些容器中的对象必须是线程安全的。
在volatile bean
模式中,JavaBean
的所有数据成员都是volatile
类型的,并且 getter
和setter
方法必须非常普通,除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。
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 的使用