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)爭資源或者由于彼此通信而造成的一種阻塞的現(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)爭資源的線程, 你等我, 我等你, 你不放我也不放, 這就造成了他們之間的互相等待, 導(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é)家就餐問題通過限制加鎖順序來解決死鎖問題就是一種簡單高效的解決辦法, 而這里也一樣, 也可以通過控制加鎖的順序來解決, 我們讓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è)最長等待時(shí)間 |
| public final void wait(long timeout, int nanos) throws InterruptedException | 等待的最長時(shí)間精度更大 |
| public final native void notify(); | 隨機(jī)喚醒一個(gè)WAITING狀態(tài)的線程, 并加鎖, 搭配wait方法使用 |
| public final native void notifyAll(); | 喚醒所有處于WAITING狀態(tài)的線程, 并加鎖(很可能產(chǎn)生鎖競(jì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è)最長等待時(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-02
SpringBoot中@ConditionalOnProperty的使用及作用詳解
這篇文章主要介紹了SpringBoot中@ConditionalOnProperty的使用及作用詳解,@ConditionalOnProperty通過讀取本地配置文件中的值來判斷 某些 Bean 或者 配置類 是否加入spring 中,需要的朋友可以參考下2024-01-01
Spring 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-07
java如何實(shí)現(xiàn)獲取客戶端ip地址的示例代碼
本文主要介紹了java如何實(shí)現(xiàn)獲取客戶端ip地址,主要包括java獲取客戶端ip地址工具類使用實(shí)例、應(yīng)用技巧,文中通過示例代碼介紹的非常詳細(xì),感興趣的小伙伴們可以參考一下2022-04-04
Spring中為bean指定InitMethod和DestroyMethod的執(zhí)行方法
在Spring中,那些組成應(yīng)用程序的主體及由Spring IoC容器所管理的對(duì)象,被稱之為bean,接下來通過本文給大家介紹Spring中為bean指定InitMethod和DestroyMethod的執(zhí)行方法,感興趣的朋友一起看看吧2021-11-11

