volatile 是併發編程的重要組成部分,也是面試常被問到的問題之一。不要向小強那樣,由於一句:volatile 是輕量級的 synchronized,而與指望已久的大廠失之交臂。java
volatile 有兩大特性:保證內存的可見性和禁止指令重排序。那什麼是可見性和指令重排呢?接下來咱們一塊兒來看。面試
要了解內存可見性先要從 Java 內存模型(JMM)提及,在 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,但主線程一直不知道此值發生了改變,這就是內存不可見的問題。dom
內存的可見性是指線程修改了變量的值以後,其餘線程能當即知道此值發生了改變。佈局
咱們能夠使用 volatile 來修飾 flag,就能夠保證內存的可見性,代碼以下:性能
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
指令重排是指在執行程序時,編譯器和處理器經常會對指令進行重排序,已到達提升程序性能的目的。 好比小強要去圖書館還上次借的書,隨便再借一本新書,而此時室友小王也想讓小強幫他還一本書,未發生指令重排的作法是,小強先把本身的事情辦完,再去辦室友的事,這樣顯然比較浪費時間,還有一種作法是,他先把本身的書和小王的書一塊兒還掉,再給本身借一本新書,這就是指令重排的意義。
但指令重排不能保證指令執行的順序,這就會形成新的問題,以下代碼所示:
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;
}
}
}
複製代碼
以上程序執行結果以下所示:
以上咱們經過代碼的方式演示了指令重排和內存可見性的問題,接下來咱們用代碼來演示一下 volatile 同步方式的問題。
首先,咱們使用 volatile 修飾一個整數變量,再啓動兩個線程分別執行一樣次數的 ++ 和 -- 操做,最後發現執行的結果居然不是 0,代碼以下:
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 改造一下:
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 一寫多讀的經典使用示例就是 CopyOnWriteArrayList,CopyOnWriteArrayList 在操做的時候會把所有數據複製出來對寫操做加鎖,修改完以後再使用 setArray 方法把此數組賦值爲更新後的值,使用 volatile 能夠使讀線程很快的告知到數組被修改,不會進行指令重排,操做完成後就能夠對其餘線程可見了,核心源碼以下:
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
private transient volatile Object[] array;
final void setArray(Object[] a) {
array = a;
}
//...... 忽略其餘代碼
}
複製代碼
本文咱們經過代碼的方式演示了 volatile 的兩大特性,內存可見性和禁止指令重排,使用 ++ 和 -- 的方式演示了 volatile 並不是輕量級的同步方式,以及 volatile 一寫多讀的經典使用案例 CopyOnWriteArrayList。
關注下面二維碼,訂閱更多精彩內容。