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