文章目錄
- 前言
- 內存可見性
- 認識內存可見性
- 原因:
- volatile
- wait-notify
- 應用場景
- 配合使用
- 總結
- 練習題
前言
在初階內容中我們提到,線程安全問題有五大條件,分別是:線程在cpu上的調度是隨機的,多個線程同時修改同一個變量,修改操作不是原子的,內存可見性,指令重排序。上一篇博客中重點講了前三個,這次我們來聚焦一下內存可見性問題,指令重排序我們將會在單例模式部分詳細講解。
內存可見性
認識內存可見性
package Thread11_16;
import java.util.List;
import java.util.Scanner;
public class demo17 {
public static int flag = 1;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag == 1){
}
System.out.println("t1執行完畢");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("請輸入flag的值:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
我們來看上述代碼的邏輯:t1線程的主要任務是一個循環,條件是flag的值,t2線程主要作用是可以修改flag的值。因為flag剛開始定義時值為1,所以可以推斷:在程序開始時,t1線程陷入死循環,直到t2線程將flag的值修改,t1線程打印結果,線程結束。
但是,實際結果真的是如此嗎???
t1線程結束的標誌是輸出“t1執行完畢”,然而不僅這個語句沒有被打印,前台線程也沒有全部結束,進程仍然在運行。很明顯,程序因該是遇到bug了。
通過可視化工具可以得出:首先t2線程結束了,t1線程阻塞在第十行代碼。也就是説,t2線程對flag值的修改並沒有被t1線程讀取到。
原因:
其實,我們平時自己寫的程序,他們在編譯器裏面實際的運行狀況如順序與我們所預期的不一樣。因為研究JDK的大佬,他們設計的解釋器實際上考慮了對我們所寫的編程的優化:在不改變原有的代碼的邏輯的前提下,對代碼進行調整,使得代碼的執行效率變高。
然而,在多線程中,不改變原有的代碼邏輯可能會被編譯器誤判:可能有些優化確實改變了邏輯,但是編譯器沒有反應過來,產生了錯誤。
拿這個循環來説,其實從指令級別的角度來説,這個循環由兩個指令組成,真實的循環場景應該如下:
while(true){
load
cmp
}
其中,load指令是從內存中讀取數據放入寄存器,cmp指令是將寄存器中的值與“1”作比較。我們知道,從內存中讀取數據的耗時與寄存器處理數據的耗時的差別很大,甚至能達到幾千倍。短時間內,這個循環執行了很多次,JVM在執行過程中感受到:load反覆執行的結果是一樣的。既然每次讀取load的值都是一樣的,不如把讀內存中的值優化成讀寄存器中的值。後續load操作都是讀寄存器中的值。但是線程t2在修改時修改的是內存中flag的值,t1卻不再重新從內存中讀取數據了,所以感知不到flag的改變,因而陷入死循環。
只需要稍稍調整一下原代碼:
眾所周知,計算機的運算速度是很快的。我們在代碼中加入了sleep(1)的語句,雖然只是短短的暫停了一毫秒,對人類來説,甚至感知不到一毫秒的存在。但是對於計算機來説,一毫秒如隔三秋,一毫秒足夠計算機執行load+cmp不知道多少遍了。此時優化load對於整個代碼來言微不足道,所以不會再改變代碼邏輯了。
然而,依靠sleep來解決內存可見性問題太勉強了,使用了sleep會大大降低代碼的執行效率,實際場景中,我們可以使用volatile關鍵字進行解決。
volatile
在 Java 併發編程中,volatile是一個關鍵字,核心作用是保證共享變量的 “可見性” 和 “有序性”,但不保證原子性。
(1)保證可見性
當變量被volatile修飾後:
寫操作:線程修改該變量時,會直接把新值同步到主內存,而不是隻存在 CPU 緩存裏;
讀操作:線程讀取該變量時,會直接從主內存讀取,而不是從自己的 CPU 緩存裏讀舊值。
對應之前的flag例子:如果flag被volatile修飾,JVM 就不會把 “讀 flag” 優化成讀寄存器,t1 線程每次都會從主內存讀最新的flag值,t2 修改後 t1 能立刻感知到。
(2)禁止指令重排序(保證有序性)
編譯器 / CPU 會對普通指令做重排序優化(提升性能),但volatile會禁止這種優化:保證volatile變量的相關指令,執行順序和代碼編寫順序一致。
侷限性:不保證原子性
volatile無法保證 “讀 - 改 - 寫” 這類複合操作的原子性。比如i++(實際是讀i→i+1→寫回i三步),如果多個線程同時執行i++,即使i是volatile修飾的,也會出現線程安全問題(因為三步操作可能被其他線程打斷)。這種場景需要用synchronized、Lock或原子類(如AtomicInteger)。
結合之前的 flag 例子
如果把flag定義為volatile static int flag = 0;,就能解決之前的可見性問題:t2 修改flag後會立刻同步到主內存,t1 每次循環都會從主內存讀最新的flag,從而感知到更新並結束循環。
wait-notify
眾所周知,操作系統對於線程是隨機調度的,但是在實際開發中,我們肯定是希望可以協調線程的執行順序。我們可以讓後執行的線程阻塞等待,等先執行的線程執行好了再通知它,讓他繼續執行。
我們之前學習到join的作用也是等待另一個線程執行結束再執行。但是這兩種執行的邏輯是不一樣的。join是等另一個線程完全運行結束了才能運行,但是wait不一樣,wait是另一個線程通知他他就可以繼續運行,與另一個線程是否運行完沒關係。這裏的notify的作用就是通知作用。
應用場景
線程餓死:
規定:當一個線程同時擁有cpu資源和鎖時這個線程才可以運行。
此時,一個線程拿到了鎖,進入cpu運行任務,但是,需要的cpu資源沒有被釋放,所以沒有運行的條件,任務沒有執行。完成了臨界區任務後,此時線程會把鎖釋放掉。這一時刻,其他競爭這個鎖的線程處於阻塞狀態,這個剛剛釋放鎖的線程處於就緒態,雖然其他線程接下來會進入就緒態,但是慢第一個線程一步。根據線程的隨即調度原理,下一個拿到鎖的,其實大概率還是這個線程。因此,這一個線程反覆拿到鎖釋放鎖,陷入無盡循環,而其他的線程遲遲拿不到鎖,任務被耽擱,最終“餓死”。
首先,wait和notify都是Object提供的方法,任何一個對象都可以使用。因為首先wait和notify都需要提前加鎖才可以使用,並且只有加了同一個鎖對象的前提下,wait和notify才能配合使用。所以我們可以使用鎖對象來調用wait和notify。此外,我們在使用wait時需要拋Interrupted異常,也就意味着他跟sleep一樣,中斷時會被強行喚醒,所以實際應用中一般用while循環二次檢測wait(這個後面再講)。
wait在使用時,首先進行的第一個微操作就是釋放鎖,這也是為什麼他能解決線程餓死的原因:自己阻塞時,將鎖釋放掉,讓其他能夠運行的線程拿到鎖先運行再説。當阻塞結束後,線程會重新加鎖。所以使用wait時需要先加鎖(sychronized)。這也是wait和sleep的一大不同,sleep是“抱着鎖睡覺”,阻塞時不釋放鎖,所以沒辦法解決線程餓死問題。
配合使用
package Thread11_16;
import java.util.Scanner;
public class demo19 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(()->{
System.out.println("wait之前");
synchronized (locker){
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("wait之後");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("輸入一個數字,通知t1退出阻塞");
int flag = scanner.nextInt();
synchronized (locker){
locker.notify();
}
});
t1.start();
t2.start();
}
}
我們這裏的輸入操作,其實相當於一個阻塞,因為計算機也不知道用户什麼時候會輸入,但是用户的輸入肯定是要優先處理的操作,所以只好一直等着,也就是阻塞。在此處的用處是,用户一直不輸入,後續的通知操作就沒法完成,相當於讓用户決定什麼時候通知t1。
我們上面提到,wait操作涉及先釋放鎖,阻塞完了再加上鎖的內容,所以需要先加鎖,才可以執行wait。但是notify並不涉及到釋放鎖,按理説不需要刻意加鎖。但是在java中,給notify加鎖是強制要求的。
需要注意的是,此處的鎖對象必須一致,才會有阻塞通知的機制,如果不一樣,這兩個線程將毫無關聯。wait操作必須在notify之前才有用!
如果我們加入一段代碼讓線程1先阻塞十秒鐘,保證我是先notify再wait阻塞。可以看到:
首先,在沒有wait的時候我notify,並沒有產生任何的異常和報錯,並不會有什麼副作用。其次,我們的wait在十秒後阻塞時,沒法被通知喚醒,一直處於阻塞之中。
多個線程wait阻塞,一次notify時是隨即喚醒的。
package Thread11_16;
import java.util.Scanner;
public class demo20 {
public static void main(String[] args) {
Object locker = new Object();
Thread t1 = new Thread(()->{
System.out.println("wait1之前");
synchronized (locker){
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("wait1之後");
});
Thread t2 = new Thread(()->{
System.out.println("wait2之前");
synchronized (locker){
try {
locker.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
System.out.println("wait2之後");
});
Thread t3 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("輸入一個數字,通知線程退出阻塞");
int flag = scanner.nextInt();
synchronized (locker){
locker.notify();
}
});
t1.start();
t2.start();
t3.start();
}
}
不過在實際業務中,一般這些wait都是幹同樣工作的,喚醒的先後順序也沒有那麼重要。notifyAll–喚醒所有被wait阻塞的線程。
不過雖然是同時喚醒t1,t2,但是喚醒之後還要重新加鎖,所以還會涉及到隨機調度問題。wait可以傳入時間參數規定阻塞時間
wait 和 join 類似,都提供兩種版本:
“死等” 版本(無超時):如locker.wait(),會一直等待直到被 notify 喚醒;
“超時時間” 版本:如locker.wait(10000),最多等待指定時長(例中 10 秒),超時未被 notify 則自動結束等待。
當 wait 引入超時時間後,和 sleep 直觀上很像:
兩者都有 “等待時間”;
兩者都能被提前喚醒(wait 靠 notify,sleep 靠 Interrupt)。
關鍵差異:
使用前提:wait 必須搭配鎖(先通過 synchronized 加鎖,才能調用 wait);sleep 無需加鎖即可使用。
鎖的處理(synchronized 內部使用時):wait 會釋放當前持有的鎖;sleep 不會釋放鎖(會 “抱着鎖睡”,導致其他線程無法獲取該鎖)。
所以説在實際開發中很少用到sleep,因為sleep阻塞純純在浪費時間。
總結
這部分內容圍繞 Java 併發中的內存可見性與 wait-notify 機制展開:前者是多線程下共享變量修改後其他線程可能無法感知的問題,源於 JVM 的緩存優化,可通過 volatile 關鍵字保證變量的主內存讀寫可見性與有序性,但無法保障原子性;後者是協調線程執行順序的工具,需搭配同一鎖對象並在 synchronized 內使用,wait 會釋放鎖以解決線程餓死問題,notify 隨機喚醒一個 wait 線程、notifyAll 喚醒全部,它無需像 join 那樣等線程結束,也區別於 sleep(需鎖且釋放鎖),同時 wait 支持死等和超時等待兩種版本。
練習題
package Thread11_16;
public class demo21 {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Object locker3 = new Object();
Thread a = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (locker1){
try {
locker1.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.print("A");
}
synchronized (locker2){
locker2.notify();
}
}
});
Thread b = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (locker2){
try {
locker2.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.print("B");
}
synchronized (locker3){
locker3.notify();
}
}
});
Thread c = new Thread(()->{
for (int i = 0; i < 10; i++) {
synchronized (locker3){
try {
locker3.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("C");
}
synchronized (locker1){
locker1.notify();
}
}
});
a.start();
b.start();
c.start();
Thread.sleep(1000);
synchronized (locker1){
locker1.notify();
}
}
}
A只能喚醒B,B只能喚醒C,C只能喚醒A,構成循環。
線程一開始要先阻塞,防止隨機調度導致第一次輸出順序有問題。
循環需要提供動力,在主線程先把線程A喚醒一次。
喚醒之前先加一個sleep是為了防止還沒阻塞就先通知了。
另一種思路:
其實也可以先通知再阻塞:
package Thread11_16;
public class demo22 {
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Object locker3 = new Object();
Thread a = new Thread(()->{
for (int i = 0; i < 10; i++) {
System.out.print("A");
synchronized (locker2){
locker2.notify();
}
synchronized (locker1){
try {
locker1.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
Thread b = new Thread(()->{
for (int i = 0; i < 10; i++) {
System.out.print("B");
synchronized (locker3){
locker3.notify();
}
synchronized (locker2){
try {
locker2.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
Thread c = new Thread(()->{
for (int i = 0; i < 10; i++) {
System.out.println("C");
synchronized (locker1){
locker1.notify();
}
synchronized (locker3){
try {
locker3.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
});
a.start();
Thread.sleep(1000);
b.start();
Thread.sleep(1000);
c.start();
}
}
只是這個代碼最後執行完了進程不會結束,因為最後c處於阻塞狀態,不過我們設置一個sleep時間到時候再喚醒一下c就OK了。