上一篇中,我們瞭解了Synchronized關鍵字,知道了它的基本使用方法,它的同步特性,知道了它與Java記憶體模型的關係,也明白了Synchronized可以保證“原子性”,“可見性”,“有序性”。今天我們來看看另外一個關鍵字Volatile,這也是極其重要的關鍵字之一。毫不誇張的說,面試的時候談到Synchronized,必定會談到Volatile。
一個小栗子
public class Main {
private static boolean isStop = false;
public static void main(String[] args) {
new Thread(() -> {
while (true) {
if (isStop) {
System.out.println("結束");
return;
}
}
}).start();
try {
TimeUnit.SECONDS.sleep(3);
isStop = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
首先定義了一個全域性變數:isStop=false。然後在main方法裡面開了一個執行緒,裡面是一個死迴圈,當isStop=true,列印出一句話,結束迴圈。主執行緒睡了三秒鐘,把isStop改為true。
按道理來說,3秒鐘後,會列印出一句話,並且結束迴圈。但是,出人意料的事情發生了,等了很久,這句話遲遲沒有出現,也沒有結束迴圈。
這是為什麼?這又和記憶體模型有關了,由此可見,記憶體模型是多麼重要,不光是Synchronized,還是這次的Volatile都和記憶體模型有關。
問題分析
我們再來看看記憶體模型:
執行緒的共享資料是存放在主記憶體的,每個執行緒都有自己的本地記憶體,本地記憶體是執行緒獨享的。當一個執行緒需要共享資料,是先去本地記憶體中查詢,如果找到的話,就不會再去主記憶體中找了,需要修改共享資料的話,是先把主記憶體的共享資料複製一份到本地記憶體,然後在本地記憶體中修改,再把資料複製到主記憶體。
如果把這個搞明白了,就很容易理解為什麼會產生上面的情況了:
isStop是共享資料,放在了主記憶體,子執行緒需要這個資料,就把資料複製到自己的本地記憶體,此時isStop=false,以後直接讀取本地記憶體就可以。主執行緒修改了isStop,子執行緒是無感知的,還是去本地記憶體中取資料,得到的isStop還是false,所以就造成了上面的情況。
Volatile與可見性
如何解決這個問題呢,只需要給isStop加一個Volatile關鍵字:
public class Main {
private static volatile boolean isStop = false;
public static void main(String[] args) {
new Thread(() -> {
while (true) {
if (isStop) {
System.out.println("結束");
return;
}
}
}).start();
try {
TimeUnit.SECONDS.sleep(3);
isStop = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
執行,問題完美解決。
Volatile的作用:
當一個變數加了volatile關鍵字後,執行緒修改這個變數後,強制立即重新整理回主記憶體。
如果其他執行緒的本地記憶體中有這個變數的副本,會強制把這個變數過期,下次就不能讀取這個副本了,那麼就只能去主記憶體取,拿到的資料就是最新的。
正是由於這兩個原因,所以Volatile可以保證“可見性”。
Volatile與有序性
指令重排的基本概念就不再闡述了,上兩節內容已經介紹了指令重排的基本概念。
指令重排遵守的happens-before規則,其中有一條規則,就是Volatile規則:
被Volatile標記的不允許指令重排。
所以,Volatile可以保證“有序性”。
那內部是如何禁止指令重排的呢?在指令中插入記憶體屏障。
記憶體屏障有四種型別,如下所示:
在生成指令序列的時候,會根據具體情況插入不同的記憶體屏障。
總結下,Volatile可以保證“可見性”,“有序性”。
Volatile與單例模式
public class Main {
private static Main main;
private Main() {
}
public static Main getInstance() {
if (main != null) {
synchronized (Main.class) {
if (main != null) {
main = new Main();
}
}
}
return main;
}
}
這裡比較經典的單例模式,看上去沒什麼問題,執行緒安全,效能也不錯,又是懶載入,這個單例模式還有一個響噹噹的名字:DCL。
但是實際上,還是有點問題的,問題就出在
main = new Main();
這又和記憶體模型有關係了。執行這個建立物件會有3個步驟:
- 分配記憶體
- 執行構造方法
- 指向地址
說明建立物件不是原子性操作,但是真正引起問題的是指令重排。先執行2,還是先執行3,在單執行緒中是無所謂的,但是在多執行緒中就不一樣了。如果執行緒A先執行3,還沒來得及執行2,此時,有一個執行緒B進來了,發現main不為空了,直接返回main,然後使用返回出來的main,但是此時main還不是完整的,因為執行緒A還沒有來得及執行構造方法。
所以單例模式得在定義變數的時候,加上Volatile,即:
public class Main {
private volatile static Main main;
private Main() {
}
public static Main getInstance() {
if (main == null) {
synchronized (Main.class) {
if (main == null) {
main = new Main();
}
}
}
return main;
}
}
這樣就可以避免上面所述的問題了。
好了,這篇文章到這裡主要內容就結束了,總結全文:Volatile可以保證“有序性”,“可見性”,但是無法保證“原子性”。
題外話
嘿嘿,既然上面說的是主要內容結束了,就代表還有其他內容。
我們把文章開頭的例子再次拿出來:
public class Main {
private static boolean isStop = false;
public static void main(String[] args) {
new Thread(() -> {
while (true) {
if (isStop) {
System.out.println("結束");
return;
}
}
}).start();
try {
TimeUnit.SECONDS.sleep(3);
isStop = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
如果既想讓子執行緒結束,又不想加Volatile關鍵字怎麼辦?這真的可以做到嗎?當然可以。
public class Main {
private static boolean isStop = false;
public static void main(String[] args) {
new Thread(() -> {
while (true) {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (isStop) {
System.out.println("結束");
return;
}
}
}).start();
try {
TimeUnit.SECONDS.sleep(3);
isStop = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在這裡,我讓子執行緒也睡了一秒,執行程式,發現子執行緒停止了。
public class Main {
private static boolean isStop = false;
public static void main(String[] args) {
new Thread(() -> {
while (true) {
System.out.println("Hello");
if (isStop) {
System.out.println("結束");
return;
}
}
}).start();
try {
TimeUnit.SECONDS.sleep(3);
isStop = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
我把上面的讓子執行緒睡一秒鐘的程式碼替換成 System.out.println,竟然也成功讓子執行緒停止了。
public class Main {
private static boolean isStop = false;
public static void main(String[] args) {
new Thread(() -> {
while (true) {
Random random=new Random();
random.nextInt(150);
if (isStop) {
System.out.println("結束");
return;
}
}
}).start();
try {
TimeUnit.SECONDS.sleep(3);
isStop = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
這樣也可以。
為什麼呢?
因為JVM會盡力保證記憶體的可見性,即使這個變數沒有加入Volatile關鍵字,主要CPU有時間,都會盡力保證拿到最新的資料。但是第一個例子中,CPU不停的在做著死迴圈,死迴圈內部就是判斷isStop,沒有時間去做其他的事情,但是隻要給它一點機會,就像上面的 睡一秒鐘,列印出一句話,生成一個隨機數,這些操作都是比較耗時的,CPU就可能可以去拿到最新的資料了。不過和Volatile不同的是 Volatile是強制記憶體“可見性”,而這裡是可能可以。