Java 并发 - 关键字 volatile
# Java 并发 - 关键字 volatile
- volatile 关键字的作用是什么?
- volatile 能保证原子性吗?
- 之前 32 位机器上共享的 long 和 double 变量的为什么要用 volatile? 现在 64 位机器上是否也要设置呢?
- i++ 为什么不能保证原子性?
- volatile 是如何实现可见性的? 内存屏障。
- volatile 是如何实现有序性的? happens-before 等
- 说下 volatile 的应用场景?
# 1. 几个基本概念
# 1.1 内存可见性
在Java内存模型(JMM)有一个主内存,每个线程有自己私有的工作内存,工作内存中保存了一些变量在主内存的拷贝。内存可见性,指的是线程之间的可见性,当一个线程修改了共享变量时,另一个线程可以读取到这个修改后的值。
# 1.2 重排序
为优化程序性能,对原有的指令执行顺序进行优化重新排序。重排序可能发生在多个阶段,比如编译重排序、CPU重排序等。
# 1.3 happens-before 规则
是一个给程序员使用的规则,只要程序员在写代码的时候遵循 happens-before 规则,JVM 就能保证指令在多线程之间的顺序性符合程序员的预期。
# 2. volatile 的作用和原理
在 Java 中,volatile 关键字有特殊的内存语义。volatile 主要有以下两个功能:
- 保证变量的内存可见性
- 禁止 volatile 变量与普通变量重排序(JSR133 提出,Java 5 开始才有这个“增强的 volatile 内存语义”)
# 2.1 内存可见性
public class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; // step 1
flag = true; // step 2
}
public void reader() {
if (flag) { // step 3
System.out.println(a); // step 4
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
在这段代码里,我们使用volatile
关键字修饰了一个boolean
类型的变量flag
。
所谓内存可见性,指的是当一个线程对volatile
修饰的变量进行写操作(比如step 2)时,JMM会立即把该线程对应的本地内存中的共享变量的值刷新到主内存;当一个线程对volatile
修饰的变量进行读操作(比如step 3)时,JMM会把立即该线程对应的本地内存置为无效,从主内存中读取共享变量的值。
在这一点上,volatile 与锁具有相同的内存效果,volatile 变量的写和锁的释放具有相同的内存语义,volatile 变量的读和锁的获取具有相同的内存语义。
假设在时间线上,线程 A 先执行方法writer
方法,线程 B 后执行reader
方法。那必然会有下图:
而如果flag
变量没有用volatile
修饰,在 step 2,线程 A 的本地内存里面的变量就不会立即更新到主内存,那随后线程 B 也同样不会去主内存拿最新的值,仍然使用线程 B 本地内存缓存的变量的值a = 0,flag = false
。
实际上,volatile 变量是基于**内存屏障(Memory Barrier)**实现,其中使用到了汇编中的 lock 指令。以下面代码为例:
public class VisibilityTest {
public volatile long sum = 0;
public int add(int a, int b) {
int temp = a + b;
sum += temp;
return temp;
}
public static void main(String[] args) {
TestVolatile test = new TestVolatile();
int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum = test.add(sum, 1);
}
System.out.println("Sum:" + sum);
System.out.println("Test.sum:" + test.sum);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
通过 hsdis 和 jitwatch (使用方式可见 hsdis 和 jitwatch)工具可以得到 add 方法编译后的汇编代码:
0x0000000111cb8f40: sub $0x18,%rsp
0x0000000111cb8f47: mov %rbp,0x10(%rsp) ;*synchronization entry
; - TestVolatile::add@-1 (line 5)
0x0000000111cb8f4c: mov 0x10(%rsi),%r10 ;*putfield sum
; - TestVolatile::add@12 (line 6)
0x0000000111cb8f50: mov %edx,%eax
0x0000000111cb8f52: add %ecx,%eax ;*iadd
; - TestVolatile::add@2 (line 5)
0x0000000111cb8f54: movslq %eax,%r11
0x0000000111cb8f57: add %r10,%r11
0x0000000111cb8f5a: mov %r11,0x10(%rsi)
0x0000000111cb8f5e: lock addl $0x0,(%rsp) ;*putfield sum
; - TestVolatile::add@12 (line 6)
0x0000000111cb8f63: add $0x10,%rsp
0x0000000111cb8f67: pop %rbp
0x0000000111cb8f68: test %eax,-0xfa53f6e(%rip) # 0x0000000102265000
; {poll_return} *** SAFEPOINT POLL ***
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
lock 前缀的指令在多核处理器下会引发两件事情:
- 将当前处理器缓存行的数据写回到系统内存。
- 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。
如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。
为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。
volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。
lock 指令
在 Pentium 和早期的 IA-32 处理器中,lock 前缀会使处理器执行当前指令时产生一个 LOCK# 信号,会对总线进行锁定,其它 CPU 对内存的读写请求都会被阻塞,直到锁释放。 后来的处理器,加锁操作是由高速缓存锁代替总线锁来处理。 因为锁总线的开销比较大,锁总线期间其他 CPU 没法访问内存。 这种场景多缓存的数据一致通过缓存一致性协议(MESI)来保证。
缓存一致性
缓存是分段(line)的,一个段对应一块存储空间,称之为缓存行,它是 CPU 缓存中可分配的最小存储单元,大小 32 字节、64 字节、128 字节不等,这与 CPU 架构有关,通常来说是 64 字节。 LOCK# 因为锁总线效率太低,因此使用了多组缓存。 为了使其行为看起来如同一组缓存那样。因而设计了 缓存一致性协议。 缓存一致性协议有多种,但是日常处理的大多数计算机设备都属于 " 嗅探(snooping)" 协议。 所有内存的传输都发生在一条共享的总线上,而所有的处理器都能看到这条总线。 缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。
# 2.2 禁止重排序
在 JSR-133 之前的旧的 Java 内存模型中,是允许 volatile 变量与普通变量重排序的。那上面的案例中,可能就会被重排序成下列时序来执行:
- 线程 A 写 volatile 变量,step 2,设置 flag 为 true;
- 线程 B 读同一个 volatile,step 3,读取到 flag 为 true;
- 线程 B 读普通变量,step 4,读取到 a = 0;
- 线程 A 修改普通变量,step 1,设置 a = 1;
可见,如果 volatile 变量与普通变量发生了重排序,虽然 volatile 变量能保证内存可见性,也可能导致普通变量读取错误。
所以在旧的内存模型中,volatile 的写-读就不能与锁的释放-获取具有相同的内存语义了。为了提供一种比锁更轻量级的线程间的通信机制,**JSR-133 **专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序。
编译器还好说,JVM 是怎么还能限制处理器的重排序的呢?它是通过内存屏障来实现的。
什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
注意这里的缓存主要指的是 CPU 缓存,如 L1,L2 等
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的 JMM 内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的 volatile 内存语义。这个策略是:
- 在每个 volatile 写操作前插入一个 StoreStore 屏障;
- 在每个 volatile 写操作后插入一个 StoreLoad 屏障;
- 在每个 volatile 读操作后插入一个 LoadLoad屏障;
- 在每个 volatile 读操作后再插入一个 LoadStore屏障。
大概示意图是这个样子:
再逐个解释一下这几个屏障。注:下述 Load 代表读操作,Store 代表写操作
LoadLoad 屏障:对于这样的语句Load1; LoadLoad; Load2,在 Load2 及后续读取操作要读取的数据被访问前,保证 Load1 要读取的数据被读取完毕。 StoreStore 屏障:对于这样的语句 Store1; StoreStore; Store2,在 Store2 及后续写入操作执行前,这个屏障会把 Store1 强制刷新到内存,保证 Store1 的写入操作对其它处理器可见。 LoadStore 屏障:对于这样的语句 Load1; LoadStore; Store2,在Store2 及后续写入操作被刷出前,保证Load1 要读取的数据被读取完毕。 StoreLoad 屏障:对于这样的语句 Store1; StoreLoad; Load2,在 Load2 及后续所有读取操作执行前,保证 Store1 的写入对所有处理器可见。它的开销是四种屏障中最大的(冲刷写缓冲器,清空无效化队列)。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
对于连续多个 volatile 变量读或者连续多个 volatile 变量写,编译器做了一定的优化来提高性能,比如:
第一个 volatile 读;
LoadLoad 屏障;
第二个 volatile 读;
LoadStore 屏障
再介绍一下 volatile 与普通变量的重排序规则:
- 如果第一个操作是 volatile 读,那无论第二个操作是什么,都不能重排序;
- 如果第二个操作是 volatile 写,那无论第一个操作是什么,都不能重排序;
- 如果第一个操作是 volatile 写,第二个操作是 volatile 读,那不能重排序。
举个例子,我们在案例中 step 1,是普通变量的写,step 2 是 volatile 变量的写,那符合第 2 个规则,这两个 steps不能重排序。而 step 3 是 volatile 变量读,step 4 是普通变量读,符合第 1 个规则,同样不能重排序。
但如果是下列情况:第一个操作是普通变量读,第二个操作是 volatile 变量读,那是可以重排序的:
// 声明变量
int a = 0; // 声明普通变量
volatile boolean flag = false; // 声明volatile变量
// 以下两个变量的读操作是可以重排序的
int i = a; // 普通变量读
boolean j = flag; // volatile变量读
2
3
4
5
6
7
# 2.3 保证原子性:单次读/写
volatile 不能保证完全的原子性,只能保证单次的读/写操作具有原子性。先从如下两个问题来理解。
对于原子性,需要强调一点,也是大家容易误解的一点:对 volatile 变量的单次读/写操作可以保证原子性的,如long 和 double 类型变量,但是并不能保证 i++ 这种操作的原子性,因为本质上 i++ 是读、写两次操作。
现在我们就通过下列程序来演示一下这个问题:
public class VolatileTest01 {
volatile int i;
public void addI(){
i++;
}
public static void main(String[] args) throws InterruptedException {
final VolatileTest01 test01 = new VolatileTest01();
for (int n = 0; n < 1000; n++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test01.addI();
}
}).start();
}
Thread.sleep(10000);//等待10秒,保证上面程序执行完成
System.out.println(test01.i);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
大家可能会误认为对变量 i 加上关键字 volatile 后,这段程序就是线程安全的。大家可以尝试运行上面的程序。下面是我本地运行的结果:981 可能每个人运行的结果不相同。不过应该能看出,volatile 是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++ 其实是一个复合操作,包括三步骤:
- 读取 i 的值。
- 对 i 加 1。
- 将 i 的值写回内存。 volatile 是无法保证这三个操作是具有原子性的,我们可以通过 AtomicInteger 或者Synchronized 来保证 +1 操作的原子性。 注:上面几段代码中多处执行了 Thread.sleep() 方法,目的是为了增加并发问题的产生几率,无其他作用。
# 2.4 共享的 long 和 double 变量的为什么要用 volatile?
在 32 位机器上,long 型变量和 double 型变量读写可能出现的诡异 Bug,明明已经把变量成功写入内存,重新读出来却不是自己写入的。
这里我们以 32 位 CPU 上执行 long 型变量的写操作为例来说明这个问题,long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作(写高 32 位和写低 32 位,如下图所示)。
在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。
但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证 CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现我们开头提及的诡异 Bug 了。
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。因此,鼓励大家将共享的 long 和 double 变量设置为 volatile 类型,这样能保证任何情况下对 long 和 double 的单次读/写操作都具有原子性。
目前各种平台下的商用虚拟机都选择把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不把long 和 double 变量专门声明为 volatile多数情况下也是不会错的。
# 3. volatile 的应用场景
从 volatile 的内存语义上来看,volatile 可以保证内存可见性且禁止重排序。
在保证内存可见性这一点上,volatile 有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。但由于volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比 volatile 更强大;在性能上,volatile 更有优势。
使用 volatile 必须具备的条件
- 对变量的写操作不依赖于当前值。
- 不满足举例:number++、count = count + 5
- 满足举例: boolean 变量等
- 该变量没有包含在具有其他变量的不变式中。
- 不满足举例:不变时 low < up
- 只有在状态真正独立于程序内其他内容时才能使用 volatile。
# 模式1:状态标志
也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。
volatile boolean shutdownRequested;
......
public void shutdown() { shutdownRequested = true; }
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}
2
3
4
5
6
7
8
# 模式2:一次性安全发布(one-time safe publication)
缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原始值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
public class BackgroundFloobleLoader {
public volatile Flooble theFlooble;
public void initInBackground() {
// do lots of stuff
theFlooble = new Flooble(); // this is the only write to theFlooble
}
}
public class SomeOtherClass {
public void doWork() {
while (true) {
// do some stuff...
// use the Flooble, but only if it is ready
if (floobleLoader.theFlooble != null)
doSomething(floobleLoader.theFlooble);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 模式3:独立观察(independent observation)
安全使用 volatile 的另一种简单模式是定期 发布 观察结果供程序内部使用。例如,假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。
public class UserManager {
public volatile String lastUser;
public boolean authenticate(String user, String password) {
boolean valid = passwordIsValid(user, password);
if (valid) {
User u = new User();
activeUsers.add(u);
lastUser = user;
}
return valid;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
# 模式4:volatile bean 模式
在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通 —— 除了获取或设置相应的属性外,不能包含任何逻辑。此外,对于对象引用的数据成员,引用的对象必须是有效不可变的。(这将禁止具有数组值的属性,因为当数组引用被声明为 volatile 时,只有引用而不是数组本身具有 volatile 语义)。对于任何 volatile 变量,不变式或约束都不能包含 JavaBean 属性。
@ThreadSafe
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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 模式5:开销较低的读-写锁策略
volatile 的功能还不足以实现计数器。因为 ++x 实际上是三种操作(读、添加、存储)的简单组合,如果多个线程凑巧试图同时对 volatile 计数器执行增量操作,那么它的更新值有可能会丢失。 如果读操作远远超过写操作,可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。 安全的计数器使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。
@ThreadSafe
public class CheesyCounter {
// Employs the cheap read-write lock trick
// All mutative operations MUST be done with the 'this' lock held
@GuardedBy("this") private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}
2
3
4
5
6
7
8
9
10
11
12
# 模式6:双重检查(double-checked)
就是我们上文举的例子。
单例模式的一种实现方式,但很多人会忽略 volatile 关键字,因为没有该关键字,程序也可以很好的运行,只不过代码的稳定性总不是 100%,说不定在未来的某个时刻,隐藏的 bug 就出来了。
class Singleton {
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
syschronized(Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 4. 参考
- https://pdai.tech/md/java/thread/java-thread-x-key-volatile.html
- http://concurrent.redspider.group/article/02/8.html