Java多線程死鎖問題詳解(wait和notify)
一. synchronnized 的特性
1. 互斥性
synchronized
會起到互斥效果, 這里的互斥其實很好理解, 一個線程執(zhí)行到某個對象的 synchronized
中時, 此時就是針對這個對象加鎖了, 而如果此時其他線程如果也想要使用 synchronized
針對同一個對象進(jìn)行加鎖, 就必須等到該對象對象上的鎖釋放掉才行, 這便是互斥的效果了.
2. 可重入性
同一個線程針對同一個對象, 連續(xù)加鎖兩次, 是否會有問題; 如果沒問題, 就是可重入的, 如果有問題, 就是不可重入的.
看下面的代碼, 在Java當(dāng)中是可行的.
class Counter { public int count = 0; synchronized public void add() { synchronized (this) { count++; } } }
這里的鎖對象是this
只要有線程調(diào)用add
, 進(jìn)入add
方法的時候,就會先加鎖(能夠加鎖成功), 緊接著又遇到了代碼塊, 再次嘗試加鎖.
站在this
的視角(鎖對象)它認(rèn)為自己已經(jīng)被另外的線程給占用了, 這里的第二次加鎖是否要阻塞等待呢? 如果這里的第二次獲取鎖成功, 這個鎖就是可重入的, 如果進(jìn)入阻塞等待的狀態(tài), 就是不可重入的, 此時如果進(jìn)入了阻塞等待大的狀態(tài), 可想而知, 我們的程序就 “僵住了” , 這也就是是一種死鎖的情況了.
上面的代碼在Java代碼中是很容易出現(xiàn)的, 為了避免上面所說情況的出現(xiàn), Java中 synchronized
就被設(shè)置成可重入的了.
synchronized
可重入的特性其實就是是在鎖對象里面記錄一下, 當(dāng)前的鎖是哪個線程持有的, 如果再次加鎖的線程和持有線程是同一個, 就可以獲取鎖, 否則就阻塞等待.
二. 死鎖問題
1. 什么是死鎖
死鎖是指兩個或兩個以上的進(jìn)程在執(zhí)行過程中, 由于競爭資源或者由于彼此通信而造成的一種阻塞的現(xiàn)象, 若無外力作用, 它們都將無法推進(jìn)下去; 此時稱系統(tǒng)處于死鎖狀態(tài)或系統(tǒng)產(chǎn)生了死鎖, 這些永遠(yuǎn)在互相等待的進(jìn)程稱為死鎖進(jìn)程; 通俗點說, 死鎖就是兩個或者多個相互競爭資源的線程, 你等我, 我等你, 你不放我也不放, 這就造成了他們之間的互相等待, 導(dǎo)致了 “永久” 阻塞.
一旦程序出現(xiàn)死鎖, 就會導(dǎo)致線程無法繼續(xù)執(zhí)行后續(xù)的工作, 程序勢必會有嚴(yán)重的bug, 而且是死鎖非常隱蔽的, 開發(fā)階段, 不經(jīng)意間, 就會寫出死鎖代碼, 還不容易測試出來, 所以這就需要我們對死鎖問題有一定的認(rèn)識以方便我們以后的調(diào)試和修改.
2. 死鎖的四個必要條件
- 互斥使用: 線程1拿到了鎖, 線程2就得進(jìn)入阻塞狀態(tài)(鎖的基本特性).
- 不可搶占: 線程1拿到鎖之后, 必須是線程1主動釋放, 不可能線程1還沒有釋放, 線程2強行獲取到鎖.
- 請求和保持: 線程1拿到鎖A后, 再去獲取鎖B的時候, A這把鎖仍然保持, 不會因為要獲取鎖B就把A釋放了.
- 循環(huán)等待: 線程1先獲取鎖A再獲取鎖B, 線程2先獲取鎖B再獲取鎖A, 線程1在獲取鎖B的時候等待線程2釋放B,同時線程2在獲取鎖A的時候等待線程1釋放A.
而在Java代碼中, 前三點 synchronized
鎖的基本特性, 我們是無法改變的, 循環(huán)等待是這四個條件里唯一 一個和代碼結(jié)構(gòu)相關(guān)的, 是我們可以控制的.
3. 常見的死鎖場景及解決
3.1 不可重入造成的死鎖
同一個線程針對同一個對象, 連續(xù)加鎖兩次, 如果鎖不是可重入鎖, 就會造成死鎖問題.
最開始介紹synchronized的特性的時候所說, synchronized
具有可重入性, 而在Java中還有一個ReentrantLock
鎖也是可重入鎖, 所以說, 在Java程序中, 不會出現(xiàn)這種死鎖問題.
3.2 循環(huán)等待的場景
哲學(xué)家就餐問題(多個線程多把鎖) 場景
有五位沉默的哲學(xué)家圍坐在一張圓桌旁, 每個哲學(xué)家有兩種狀態(tài).
- 思考人生(相當(dāng)于線程的阻塞狀態(tài))
- 拿起筷子吃面條(相當(dāng)于線程獲取到鎖然后執(zhí)行一些計算)
有五只筷子供他們使用, 哲學(xué)家需要拿到左手和右手邊的兩根筷子之后才能吃飯, 吃完后將筷子放下繼續(xù)思考.
由于操作系統(tǒng)隨機調(diào)度, 這五個哲學(xué)家, 隨時都可能想吃面條, 也隨時可能要思考人生.
假設(shè)出現(xiàn)了極端情況, 同─時刻, 所有的哲學(xué)家同時拿起右手的筷子, 哲學(xué)家們需要再拿起左手的筷子才可以吃面條, 而此時他們發(fā)現(xiàn)沒有筷子可以拿了, 都在等左邊的哲學(xué)家放下筷子, 這里的筷子落實到程序中就相當(dāng)于鎖, 此時就陷入了互相阻塞等待的狀態(tài), 這種場景就是典型的因為循環(huán)等待造成的死鎖問題.
解決方案
我們可以給按筷子編號, 哲學(xué)家們拿筷子時需要遵守一個規(guī)則, 拿筷子需要先拿編號小的, 再拿編號大的, 再來看這個場景, 哲學(xué)家 2, 3, 4, 5
分別拿起了兩手邊編號為 1, 2, 3, 4
編號較小的筷子, 而1
號哲學(xué)家想要拿到編號編號較小的1
號筷子發(fā)現(xiàn)已經(jīng)被拿走了, 此時就空出了5
號筷子, 這樣5
號哲學(xué)家就可以拿起5
號筷子去吃面條了, 等5
號哲學(xué)家放下筷子后, 4
號哲學(xué)家就可以拿起4
號筷子去吃面條了, 以此類推…
對應(yīng)到程序中, 這樣的做法其實就是在給鎖編號, 然后再按照一個規(guī)定好的順序來加鎖, 任意線程加多把鎖的時候, 都讓線程遵守這個順序, 這樣就解決了互相阻塞等待的問題.
兩個線程兩把鎖
兩個線程兩把鎖, t1
, t2
線程先各自針對鎖A
, 鎖B
加鎖, 然后再去獲取對方的鎖, 此時雙方就會陷入僵持狀態(tài), 造成了死鎖問題.
這里可以看一下這里舉出來的現(xiàn)實中的例子來理解這里的場景:
前段時間疫情還沒有放開的時候, 走到哪里都離不開健康碼, 某一天這個健康碼就給給崩了, 手機上的健康碼沒辦法正常打開了, 于是程序員就趕到公司去修復(fù)這個bug, 但是在公司樓下被保安攔住了, 保安要求出示健康碼才能上樓, 程序員說: “健康碼出問題了, 我上樓修復(fù)了才能出示健康碼” ; 保安又說: “你出示了健康碼才能上樓”; 此時場景就陷入了僵持的狀態(tài), 程序員上不了樓, 健康碼也無法修復(fù); 這個場景就可以類比這里的鎖問題.
觀察下面的代碼及執(zhí)行結(jié)果:
這里的代碼是為了構(gòu)造一個死鎖的場景, 代碼中的sleep
是為了確保兩個線程先把第一個鎖拿到, 因為線程是搶占式執(zhí)行的, 如果沒有sleep
的作用, 這里的死鎖場景是不容易構(gòu)造出來的.
public class TestDemo14 { public static void main(String[] args) { Object A = new Object(); Object B = new Object(); Thread t1 = new Thread(() -> { synchronized (A) { System.out.println(Thread.currentThread().getName()+"獲取到了鎖A"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (B) { System.out.println(Thread.currentThread().getName()+"獲取到了鎖B"); } } }, "t1"); Thread t2 = new Thread(() -> { synchronized (B) { System.out.println(Thread.currentThread().getName()+"獲取到了鎖B"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (A) { System.out.println(Thread.currentThread().getName()+"獲取到了鎖A"); } } }, "t2"); t1.start(); t2.start(); } }
執(zhí)行結(jié)果:
看這里的執(zhí)行結(jié)果, t1
線程獲取到了鎖A
但并沒有獲取到鎖B
, t2
線程獲取到了鎖B
但并沒有獲取到鎖A
, 也就是說t1
和t2
兩個線程進(jìn)入了相互阻塞的狀態(tài), 線程無法獲去到兩把鎖, 我們可以使用jconsole
工具來觀察一下這兩個線程的狀態(tài), 分析一下是哪里的代碼造成這里死鎖問題的.
可以發(fā)現(xiàn), t1
線程此時是處于BLOCKED
狀態(tài)的, 表示獲取鎖, 獲取不到的阻塞狀態(tài); 根據(jù)堆棧跟蹤的信息反映在代碼中是在第14
行.
同樣的, t2
線程此時也是處于BLOCKED
阻塞狀態(tài)的; 根據(jù)堆棧跟蹤的信息反映在代碼中是在第27
行.
上面敘述的是兩個線程死鎖問題的代碼場景和具體分析, 那么這里的鎖問題如何解決呢?
其實也不需要特別復(fù)雜的算法, 實際開發(fā)中只需要解單高效的解決問題即可, 復(fù)雜了反而會使程序容易出bug, 可能會引出新的問題, 就比如上面介紹的哲學(xué)家就餐問題通過限制加鎖順序來解決死鎖問題就是一種簡單高效的解決辦法, 而這里也一樣, 也可以通過控制加鎖的順序來解決, 我們讓t1
和t2
兩個線程都按照相同的順序來獲取鎖, 比如這里規(guī)定先獲取鎖A
, 再獲取鎖B
, 這樣按照相同的順序去獲取鎖就避免了循環(huán)等待造成的死鎖問題, 代碼如下:
public class TestDemo14 { public static void main(String[] args) { Object A = new Object(); Object B = new Object(); Thread t1 = new Thread(() -> { synchronized (A) { System.out.println(Thread.currentThread().getName()+"獲取到了鎖A"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (B) { System.out.println(Thread.currentThread().getName()+"獲取到了鎖B"); } } }, "t1"); Thread t2 = new Thread(() -> { synchronized (A) { System.out.println(Thread.currentThread().getName()+"獲取到了鎖B"); try { Thread.sleep(1000); } catch (InterruptedException e) { throw new RuntimeException(e); } synchronized (B) { System.out.println(Thread.currentThread().getName()+"獲取到了鎖A"); } } }, "t2"); t1.start(); t2.start(); } }
最后的執(zhí)行結(jié)果兩個線程都獲取到了A,B
鎖.
三. Object類中提供線程等待的方法
1. 常用方法
除了Thread
類中的能夠?qū)崿F(xiàn)線程等待的方法, 如join
, sleep
, 在Object
類中也提供了相關(guān)線程等待的方法.
方法 | 解釋 |
---|---|
public final void wait() throws InterruptedException | 釋放鎖并使線程進(jìn)入WAITING狀態(tài) |
public final native void wait(long timeout) throws InterruptedException | 相比于上面, 多了一個最長等待時間 |
public final void wait(long timeout, int nanos) throws InterruptedException | 等待的最長時間精度更大 |
public final native void notify(); | 隨機喚醒一個WAITING狀態(tài)的線程, 并加鎖, 搭配wait方法使用 |
public final native void notifyAll(); | 喚醒所有處于WAITING狀態(tài)的線程, 并加鎖(很可能產(chǎn)生鎖競爭), 搭配wait方法使用 |
我們知道由于線程之間的搶占式執(zhí)行和操作系統(tǒng)的隨機調(diào)度會導(dǎo)致線程之間執(zhí)行順序是 “隨機” 的, 但在實際開發(fā)中很多場景下我們是希望可以協(xié)調(diào)多個線程之間的執(zhí)行先后順序的.
雖然線程在內(nèi)核里的調(diào)度是隨機的, 這個我們是沒辦法改變的, 但是我們可以通過一些api讓線程主動阻塞, 主動放棄CPU來給別的線程讓路, 以此來控制線程之間的執(zhí)行順序.
Thread類中的join
和sleep
方法定程度上也能控制線程的執(zhí)行順序, 但通過join和sleep控制并不夠靈活:
- 使用
join
, 則必須要t1徹底執(zhí)行完, t2才能執(zhí)行; 如果是希望t1先干50%的活, 就讓t2開始行動, join就無能為力了. - 使用
sleep
, 指定一個休眠時間的, 但是t1執(zhí)行的這些任務(wù), 到底花了多少時間, 是不好估計的.
而使用wait
和notify
可以更好的解決上述的問題.
下面的代碼t
線程中沒有使用synchronized
進(jìn)行加鎖, 直接調(diào)用了wait
方法, 會產(chǎn)生非法鎖狀態(tài)異常.
public class TestDemo15 { public static void main(String[] args) throws InterruptedException { Thread t = new Thread(() -> { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("執(zhí)行完畢!"); }); t.start(); System.out.println("wait前"); t.wait(); System.out.println("wait后"); } }
執(zhí)行結(jié)果:
之所以這里會拋出這個異常, 是因為wait
方法的執(zhí)行步驟為:
- 先釋放鎖
- 再讓線程阻塞等待
- 最后滿足條件后, 重新嘗試獲取鎖, 并在獲取到鎖后, 繼續(xù)往下執(zhí)行
而上面的代碼都沒有加鎖, 又怎么能釋放鎖鎖呢, 所以會拋出異常, 所以說, wait
操作需要搭配synchronized
來使用.
所以對上面的代碼做出如下修改即可,
synchronized (t) { System.out.println("wait前"); t.wait(); System.out.println("wait后"); }
執(zhí)行結(jié)果:
2. wait和notify的搭配使用
wait
方法常常搭配notify
方法搭配一起使用, notify方法用來喚醒wait等待的線程, wait能夠釋放鎖, 使線程等待, 而notify喚醒線程后能夠獲取鎖, 然后使線程繼續(xù)執(zhí)行, 執(zhí)行流程如下:
在Java中, notify
方法也需要在加鎖前提下使用.
代碼示例:
public class TestDemo16 { public static void main(String[] args) throws InterruptedException { Object object = new Object(); Thread t1 = new Thread(() -> { // 這個線程負(fù)責(zé)進(jìn)行等待 System.out.println("t1: wait 之前"); try { synchronized (object) { object.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("t1: wait 之后"); }); Thread t2 = new Thread(() -> { System.out.println("t2: notify 之前"); synchronized (object) { // notify 務(wù)必要獲取到鎖, 才能進(jìn)行通知 try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } object.notify(); } System.out.println("t2: notify 之后"); }); t1.start(); // 此處寫的 sleep 500 是大概率會讓當(dāng)前的 t1 先執(zhí)行 wait 的. // 極端情況下 (電腦特別卡的時候), 可能線程的調(diào)度時間就超過了 500 ms // 還是可能 t2 先執(zhí)行 notify. Thread.sleep(500); t2.start(); } }
執(zhí)行結(jié)果:
注意事項:
雖然這里wait
是阻塞了, 阻塞在synchronized
代碼塊里, 實際上, 這里的阻塞是釋放了鎖的, 此時其他線程是可以獲取到object
這個對象的鎖的, 這里的阻塞,就處在WAITING
狀態(tài).
代碼中的鎖對象和調(diào)用wait, notify
方法的對象必須是相同的才能夠起到應(yīng)有的效果, notify
只能喚醒在同一個對象上等待的線程.
代碼中要保證先執(zhí)行wait
, 后執(zhí)行notify
才是有意義的.
wait
無參數(shù)版本, 是一個死等的版本, 只要不進(jìn)行notify
, 就會死等下去, 可以采用wait帶參數(shù)版本設(shè)計代碼避免死等可能出現(xiàn)的問題.
3. wait 和 sleep 的區(qū)別
- 相同點
- 都可以使線程暫停一段時間來控制線程之間的執(zhí)行順序.
- wait可以設(shè)置一個最長等待時間, 和sleep一樣都可以提前喚醒.
- 不同點
- wait是Object類中的一個方法, sleep是Thread類中的一個方法.
- wait必須在synchronized修飾的代碼塊或方法中使用, sleep方法可以在任何位置使用.
- wait被調(diào)用后當(dāng)前線程進(jìn)入BLOCK狀態(tài)并釋放鎖,并可以通過notify和notifyAll方法進(jìn)行喚醒;sleep被調(diào)用后當(dāng)前線程進(jìn)入TIMED_WAIT狀態(tài),不涉及鎖相關(guān)的操作.
- 使用sleep只能指定一個固定的休眠時間, 線程中執(zhí)行操作的執(zhí)行時間是無法確定的; 而使用wait在指定操作位置就可以喚醒線程.
- sleep和wait都可以被提前喚醒, interruppt喚醒sleep, 是會報異常的, 這種方式是一個非正常的執(zhí)行邏輯; 而noitify喚醒wait是正常的業(yè)務(wù)執(zhí)行邏輯, 不會有任何異常.
4. 練習(xí): 順序打印ABC
有三個線程, 分別只能打印A, B, C, 實現(xiàn)代碼控制三個線程固定按照ABC的順序打印.
public class TestdDemo17 { public static void main(String[] args) throws InterruptedException { Object locker1 = new Object(); Object locker2 = new Object(); Thread t1 = new Thread(() -> { System.out.println("A"); synchronized (locker1) { locker1.notify(); } }); Thread t2 = new Thread(() -> { synchronized (locker1) { try { locker1.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("B"); synchronized (locker2) { locker2.notify(); } }); Thread t3 = new Thread(() -> { synchronized (locker2) { try { locker2.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("C"); }); t2.start(); t3.start(); Thread.sleep(100); t1.start(); } }
執(zhí)行結(jié)果:
總結(jié)
到此這篇關(guān)于Java多線程死鎖問題的文章就介紹到這了,更多相關(guān)Java多線程死鎖問題內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
淺談MySQL中是如何實現(xiàn)事務(wù)提交和回滾的
本文主要介紹了MySQL中是如何實現(xiàn)事務(wù)提交和回滾的,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2022-02-02SpringBoot中@ConditionalOnProperty的使用及作用詳解
這篇文章主要介紹了SpringBoot中@ConditionalOnProperty的使用及作用詳解,@ConditionalOnProperty通過讀取本地配置文件中的值來判斷 某些 Bean 或者 配置類 是否加入spring 中,需要的朋友可以參考下2024-01-01聊聊Kotlin?中?lateinit?和?lazy?的原理區(qū)別
使用 Kotlin 進(jìn)行開發(fā),對于 latelinit 和 lazy 肯定不陌生。但其原理上的區(qū)別,可能鮮少了解過,借著本篇文章普及下這方面的知識,感興趣的朋友一起看看吧2022-07-07Spring中為bean指定InitMethod和DestroyMethod的執(zhí)行方法
在Spring中,那些組成應(yīng)用程序的主體及由Spring IoC容器所管理的對象,被稱之為bean,接下來通過本文給大家介紹Spring中為bean指定InitMethod和DestroyMethod的執(zhí)行方法,感興趣的朋友一起看看吧2021-11-11