因為我說:volatile 是輕量級的 synchronized,面試官讓我回去等通知!
阿新 • • 發佈:2020-03-18
# 因為我說:volatile 是輕量級的 synchronized,面試官讓我回去等通知!
> volatile 是併發程式設計的重要組成部分,也是面試常被問到的問題之一。不要向小強那樣,因為一句:volatile 是輕量級的 synchronized,而與期望已久的大廠失之交臂。
volatile 有兩大特性:保證記憶體的可見性和禁止指令重排序。那什麼是可見性和指令重排呢?接下來我們一起來看。
## 記憶體可見性
要了解記憶體可見性先要從 Java 記憶體模型(JMM)說起,在 Java 中所有的共享變數都在主記憶體中,每個執行緒都有自己的工作記憶體,為了提高執行緒的執行速度,每個執行緒的工作記憶體都會把主記憶體中的共享變數拷貝一份進行快取,以此來提高執行效率,記憶體佈局如下圖所示:
![記憶體可見性.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1584251030765-4000ef48-1887-4c03-9912-a4e035a1e1bc.png#align=left&display=inline&height=511&name=%E5%86%85%E5%AD%98%E5%8F%AF%E8%A7%81%E6%80%A7.png&originHeight=511&originWidth=777&size=44249&status=done&style=none&width=777)
但這樣就會產生一個新的問題,如果某個執行緒修改了共享變數的值,其他執行緒不知道此值被修改了,就會發生兩個執行緒值不一致的情況,我們用程式碼來演示一下這個問題。
```java
public class VolatileExample {
// 可見性引數
private static boolean flag = false;
public static void main(String[] args) {
new Thread(() -> {
try {
// 暫停 0.5s 執行
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag 被修改成 true");
}).start();
// 一直迴圈檢測 flag=true
while (true) {
if (flag) {
System.out.println("檢測到 flag 變為 true");
break;
}
}
}
}
```
以上程式的執行結果如下:
> flag 被修改成 true
我們會發現永遠等不到 `檢測到 flag 變為 true` 的結果,這是因為非主執行緒更改了 flag=true,但主執行緒一直不知道此值發生了改變,這就是記憶體不可見的問題。
**記憶體的可見性**是指執行緒修改了變數的值之後,其他執行緒能立即知道此值發生了改變。
我們可以使用 volatile 來修飾 flag,就可以保證記憶體的可見性,程式碼如下:
```java
public class VolatileExample {
// 可見性引數
private static volatile boolean flag = false;
public static void main(String[] args) {
new Thread(() -> {
try {
// 暫停 0.5s 執行
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
System.out.println("flag 被修改成 true");
}).start();
// 一直迴圈檢測 flag=true
while (true) {
if (flag) {
System.out.println("檢測到 flag 變為 true");
break;
}
}
}
}
```
以上程式的執行結果如下:
> 檢測到 flag 變為 true
> flag 被修改成 true
## 指令重排
指令重排是指在執行程式時,編譯器和處理器常常會對指令進行重排序,已到達提高程式效能的目的。
比如小強要去圖書館還上次借的書,隨便再借一本新書,而此時室友小王也想讓小強幫他還一本書,未發生指令重排的做法是,小強先把自己的事情辦完,再去辦室友的事,這樣顯然比較浪費時間,還有一種做法是,他先把自己的書和小王的書一起還掉,再給自己借一本新書,這就是指令重排的意義。
但指令重排不能保證指令執行的順序,這就會造成新的問題,如下程式碼所示:
```java
public class VolatileExample {
// 指令重排引數
private static int a = 0, b = 0;
private static int x = 0, y = 0;
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < Integer.MAX_VALUE; i++) {
Thread t1 = new Thread(() -> {
// 有可能發生指令重排,先 x=b 再 a=1
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
// 有可能發生指令重排,先 y=a 再 b=1
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("第 " + i + "次,x=" + x + " | y=" + y);
if (x == 0 && y == 0) {
// 發生了指令重排
break;
}
// 初始化變數
a = 0;
b = 0;
x = 0;
y = 0;
}
}
}
```
以上程式執行結果如下所示:
![指令重排.png](https://cdn.nlark.com/yuque/0/2020/png/92791/1584247494600-97b7b4ea-71d0-4991-9fe4-8a5a096a72da.png#align=left&display=inline&height=336&name=%E6%8C%87%E4%BB%A4%E9%87%8D%E6%8E%92.png&originHeight=336&originWidth=372&size=17559&status=done&style=none&width=372)
可以看出執行到 48526 次時發生了指令重排,y 就變成了非正確值 0,顯然這不是我們想要的結果,這個時候就可以使用 volatile 來禁止指令重排。
以上我們通過程式碼的方式演示了指令重排和記憶體可見性的問題,接下來我們用程式碼來演示一下 volatile 同步方式的問題。
## volatile 非同步方式
首先,我們使用 volatile 修飾一個整數變數,再啟動兩個執行緒分別執行同樣次數的 ++ 和 -- 操作,最後發現執行的結果竟然不是 0,程式碼如下:
```java
public class VolatileExample {
public static volatile int count = 0; // 計數器
public static final int size = 100000; // 迴圈測試次數
public static void main(String[] args) {
// ++ 方式
Thread thread = new Thread(() -> {
for (int i = 1; i <= size; i++) {
count++;
}
});
thread.start();
// -- 方式
for (int i = 1; i <= size; i++) {
count--;
}
// 等所有執行緒執行完成
while (thread.isAlive()) {}
System.out.println(count); // 列印結果
}
}
```
以上程式執行結果如下:
> 1065
可以看出,執行結果並不是我們期望的結果 0,我們把以上程式碼使用 synchronized 改造一下:
```java
public class VolatileExample {
public static int count = 0; // 計數器
public static final int size = 100000; // 迴圈測試次數
public static void main(String[] args) {
// ++ 方式
Thread thread = new Thread(() -> {
for (int i = 1; i <= size; i++) {
synchronized (VolatileExample.class) {
count++;
}
}
});
thread.start();
// -- 方式
for (int i = 1; i <= size; i++) {
synchronized (VolatileExample.class) {
count--;
}
}
// 等所有執行緒執行完成
while (thread.isAlive()) {}
System.out.println(count); // 列印結果
}
}
```
這次執行的結果變成了我們期望的值 0。
這說明 **volatile 只是輕量級的執行緒可見方式,並不是輕量級的同步方式,所以並不能說 volatile 是輕量級的 synchronized**,終於知道為什麼面試官讓我回去等通知了。
## volatile 使用場景
既然 volatile 只能保證執行緒操作的可見方式,那它有什麼用呢?
**volatile 在多讀多寫的情況下雖然一定會有問題,但如果是一寫多讀的話使用 volatile 就不會有任何問題**。volatile 一寫多讀的經典使用示例就是 CopyOnWriteArrayList,CopyOnWriteArrayList 在操作的時候會把全部資料複製出來對寫操作加鎖,修改完之後再使用 setArray 方法把此陣列賦值為更新後的值,使用 volatile 可以使讀執行緒很快的告知到陣列被修改,不會進行指令重排,操作完成後就可以對其他執行緒可見了,核心原始碼如下:
```java
public class CopyOnWriteArrayList
implements List, RandomAccess, Cloneable, java.io.Serializable {
private transient volatile Object[] array;
final void setArray(Object[] a) {
array = a;
}
//...... 忽略其他程式碼
}
```
## 總結
本文我們通過程式碼的方式演示了 volatile 的兩大特性,記憶體可見性和禁止指令重排,使用 ++ 和 -- 的方式演示了 volatile 並非輕量級的同步方式,以及 volatile 一寫多讀的經典使用案例 CopyOnWriteArrayList。
>更多 **Java 原創文章**,請關注我微信公眾號 **「Java中文社群」**