-
Notifications
You must be signed in to change notification settings - Fork 2.3k
Expand file tree
/
Copy pathvolatile.md
More file actions
334 lines (259 loc) · 15.8 KB
/
volatile.md
File metadata and controls
334 lines (259 loc) · 15.8 KB
1
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
---
title: Java volatile关键字解析
shortTitle: volatile关键字
description: 在 Java 中,volatile 是一种特殊的修饰符,主要用于处理多线程编程中的可见性和有序性问题。
category:
- Java核心
tag:
- Java并发编程
head:
- - meta
- name: keywords
content: Java,并发编程,多线程,Thread,volatile
---
“三妹啊,这节我们来学习 Java 中的 volatile 关键字吧,以及容易遇到的坑。”看着三妹好学的样子,我倍感欣慰。
“好呀,哥。”三妹愉快的答应了。
> 这是我们在《[二哥的 Java 进阶之路基础篇](https://javabetter.cn/overview/)》中常见的对话模式,老读者应该对这种模式不陌生。
在讲[并发编程带来了哪些问题的时候](https://javabetter.cn/thread/thread-bring-some-problem.html),我们提到了可见性和原子性,那我现在可以直接告诉大家了:volatile 可以保证可见性,但不保证原子性:
- 当写一个 volatile 变量时,[JMM](https://javabetter.cn/thread/jmm.html) 会把该线程在本地内存中的变量强制刷新到主内存中去;
- 这个写操作会导致其他线程中的 volatile 变量缓存无效。
## volatile 会禁止指令重排
在讲 [JMM](https://javabetter.cn/thread/jmm.html) 的时候,我们提到了指令重排,相信大家都还有印象,我们来回顾一下重排序需要遵守的规则:
- 重排序不会对存在数据依赖关系的操作进行重排序。比如:`a=1;b=a;` 这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
- 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如:`a=1;b=2;c=a+b` 这三个操作,第一步 (a=1) 和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3。
使用 volatile 关键字修饰共享变量可以禁止这种重排序。怎么做到的呢?
当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:
- 写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
- 读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。
换句话说:
- 当程序执行到 volatile 变量的读操作或者写操作时,在其前面操作的更改肯定已经全部进行,且结果对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将 volatile 变量的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
“也就是说,执行到 volatile 变量时,其前面的所有语句都必须执行完,后面所有得语句都未执行。且前面语句的结果对 volatile 变量及其后面语句可见。”我瞅了了三妹一眼继续说。
先看下面未使用 volatile 的代码:
```java
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a * a; //4
System.out.println(i);
}
}
}
```
因为重排序影响,所以最终的输出可能是 0,重排序请参考[上一篇 JMM 的介绍](https://javabetter.cn/thread/jmm.html),如果引入 volatile,我们再看一下代码:
```java
class ReorderExample {
int a = 0;
boolean volatile flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a * a; //4
System.out.println(i);
}
}
}
```
这时候,volatile 会禁止指令重排序,这个过程建立在 happens before 关系([上一篇介绍过了](https://javabetter.cn/thread/jmm.html))的基础上:
1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
2. 根据 volatile 规则,2 happens before 3。
3. 根据 happens before 的传递性规则,1 happens before 4。
上述 happens before 关系的图形化表现形式如下:

在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系:
- 黑色箭头表示程序顺序规则;
- 橙色箭头表示 volatile 规则;
- 蓝色箭头表示组合这些规则后提供的 happens before 保证。
这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之前所有可见的共享变量,在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见。
## volatile 不适用的场景
下面是变量自加的示例:
```java
public class volatileTest {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final volatileTest test = new volatileTest();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println("inc output:" + test.inc);
}
}
```
测试输出:
```
inc output:8182
```
“为什么呀?二哥?” 看到这个结果,三妹疑惑地问。
“因为 inc++不是一个原子性操作([前面讲过](https://javabetter.cn/thread/thread-bring-some-problem.html)),由读取、加、赋值 3 步组成,所以结果并不能达到 10000。”我耐心地回答。
“哦,你这样说我就理解了。”三妹点点头。
怎么解决呢?
01、采用 [synchronized](https://javabetter.cn/thread/synchronized-1.html)(下一篇会讲,戳链接直达),把 `inc++` 拎出来单独加 synchronized 关键字:
```java
public class volatileTest1 {
public int inc = 0;
public synchronized void increase() {
inc++;
}
public static void main(String[] args) {
final volatileTest1 test = new volatileTest1();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println("add synchronized, inc output:" + test.inc);
}
}
```
02、采用 [Lock](https://javabetter.cn/thread/suo.html),通过重入锁 [ReentrantLock](https://javabetter.cn/thread/reentrantLock.html) 对 `inc++` 加锁(后面都会细讲,戳链接直达):
```java
public class volatileTest2 {
public int inc = 0;
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
inc++;
lock.unlock();
}
public static void main(String[] args) {
final volatileTest2 test = new volatileTest2();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println("add lock, inc output:" + test.inc);
}
}
```
03、采用原子类 [AtomicInteger](https://javabetter.cn/thread/atomic.html)(后面也会细讲,戳链接直达)来实现:
```java
public class volatileTest3 {
public AtomicInteger inc = new AtomicInteger();
public void increase() {
inc.getAndIncrement();
}
public static void main(String[] args) {
final volatileTest3 test = new volatileTest3();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<100;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println("add AtomicInteger, inc output:" + test.inc);
}
}
```
三者输出都是 1000,如下:
```
add synchronized, inc output:1000
add lock, inc output:1000
add AtomicInteger, inc output:1000
```
## volatile 实现单例模式的双重锁
下面是一个使用"双重检查锁定"(double-checked locking)实现的单例模式(Singleton Pattern)的例子。
```java
public class Penguin {
private static volatile Penguin m_penguin = null;
// 一个成员变量 money
private int money = 10000;
// 避免通过 new 初始化对象,构造方法应为 private
private Penguin() {}
public void beating() {
System.out.println("打豆豆" + money);
}
public static Penguin getInstance() {
if (m_penguin == null) {
synchronized (Penguin.class) {
if (m_penguin == null) {
m_penguin = new Penguin();
}
}
}
return m_penguin;
}
}
```
在这个例子中,Penguin 类只能被实例化一次。来看代码解释:
- 声明了一个类型为 Penguin 的 volatile 变量 m_penguin,它是类的静态变量,用来存储 Penguin 类的唯一实例。
- `Penguin()` 构造方法被声明为 private,这样就阻止了外部代码使用 new 来创建 Penguin 实例,保证了只能通过 `getInstance()` 方法获取实例。
- `getInstance()` 方法是获取 Penguin 类唯一实例的公共静态方法。
- 第一次 `if (null == m_penguin)` 检查是否已经存在 Penguin 实例。如果不存在,才进入同步代码块。
- `synchronized(penguin.class)` 对类的 Class 对象加锁,确保在多线程环境下,同时只能有一个线程进入同步代码块。在同步代码块中,再次执行 `if (null == m_penguin)` 检查实例是否已经存在,如果不存在,则创建新的实例。这就是所谓的“双重检查锁定”,一共两次。
- 最后返回 m_penguin,也就是 Penguin 的唯一实例。
其中,使用 volatile 关键字是为了防止 `m_penguin = new Penguin()` 这一步被指令重排序。因为实际上,`new Penguin()` 这一行代码分为三个子步骤:
- 步骤 1:为 Penguin 对象分配足够的内存空间,伪代码 `memory = allocate()`。
- 步骤 2:调用 Penguin 的构造方法,初始化对象的成员变量,伪代码 `ctorInstanc(memory)`。
- 步骤 3:将内存地址赋值给 m_penguin 变量,使其指向新创建的对象,伪代码 `instance = memory`。
如果不使用 volatile 关键字,JVM 可能会对这三个子步骤进行指令重排。
- 为 Penguin 对象分配内存
- 将对象赋值给引用 m_penguin
- 调用构造方法初始化成员变量
这种重排序会导致 m_penguin 引用在对象完全初始化之前就被其他线程访问到。具体来说,如果一个线程执行到步骤 2 并设置了 m_penguin 的引用,但尚未完成对象的初始化,这时另一个线程可能会看到一个“半初始化”的 Penguin 对象。
假如此时有两个线程 A 和 B,要执行 `getInstance()` 方法:
```java
public static Penguin getInstance() {
if (m_penguin == null) {
synchronized (Penguin.class) {
if (m_penguin == null) {
m_penguin = new Penguin();
}
}
}
return m_penguin;
}
```
- 线程 A 执行到 `if (m_penguin == null)`,判断为 true,进入同步块。
- 线程 B 执行到 `if (m_penguin == null)`,判断为 true,进入同步块。
如果线程 A 执行 `m_penguin = new Penguin()` 时发生指令重排序:
- 线程 A 分配内存并设置引用,但尚未调用构造方法完成初始化。
- 线程 B 此时判断 `m_penguin != null`,直接返回这个“半初始化”的对象。
这样就会导致线程 B 拿到一个不完整的 Penguin 对象,可能会出现空指针异常或者其他问题。
于是,我们可以为 m_penguin 变量添加 volatile 关键字,来禁止指令重排序,确保对象的初始化完成后再将其赋值给 m_penguin。
## 小结
“好了,三妹,我们来总结一下。”我舒了一口气说。
volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层 volatile 是采用“内存屏障”来实现的。
观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码就能发现,加入 volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供 3 个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。
最后,我们学习了 volatile 不适用的场景,以及解决的方法,并解释了双重检查锁定实现的单例模式为何需要使用 volatile。
> 编辑:沉默王二,编辑前的内容主要来自于二哥的[技术派](https://paicoding.com/)团队成员楼仔,原文链接戳:[volatile](https://paicoding.com/column/4/2)。
---
GitHub 上标星 10000+ 的开源知识库《[二哥的 Java 进阶之路](https://github.com/itwanger/toBeBetterJavaer)》第二份 PDF 《[并发编程小册](https://javabetter.cn/thread/)》终于来了!包括线程的基本概念和使用方法、Java的内存模型、sychronized、volatile、CAS、AQS、ReentrantLock、线程池、并发容器、ThreadLocal、生产者消费者模型等面试和开发必须掌握的内容,共计 15 万余字,200+张手绘图,可以说是通俗易懂、风趣幽默……详情戳:[太赞了,二哥的并发编程进阶之路.pdf](https://javabetter.cn/thread/)
[加入二哥的编程星球](https://javabetter.cn/thread/),在星球的第二个置顶帖「[知识图谱](https://javabetter.cn/thread/)」里就可以获取 PDF 版本。
