淺談并發(fā)編程中的死鎖問題
Java并發(fā)編程中的死鎖在單體Java進程中也算是比較棘手的問題,所以本文將針對死鎖問題的本質和一些規(guī)避手段進行詳細的介紹,希望對你有幫助。

一、死鎖問題的本質
關于死鎖,哲學家進餐就是一個非常典型的案例,假設有5個哲學家和5根筷子,每個哲學家必須拿到一雙筷子后才能進餐,完成進餐后放下筷子進入思考。我們試想這樣一種極端情況,每個哲學家左手都拿一根筷子,都在等待其他人放下一根筷子進餐,由此各自都在等待且不放下彼此手里筷子的過程,就造成了死鎖:

這很好的解釋了死鎖的概念,本質來說造成死鎖的四大原因有:
- 互斥:一個資源只能被一個線程獲取
- 請求與保持:線程拿到資源后,在沒有獲取到想要的資源前,不會釋放已有資源
- 不可剝奪:資源被其他線程持有時,其他線程無法剝奪資源使用權
- 循環(huán)等待條件:若干線程獲取資源時,雙方按照相反的方向獲取,頭尾相接構成環(huán)路
而這個條件同理換到哲學家進餐問題上就是:
- 每根筷子只能被一個哲學家獲取
- 哲學家拿到筷子之后,除非拿到一雙筷子完成進餐,否則不會放下筷子
- 哲學家持有筷子期間,其他人不可剝奪
- 5個哲學家左手都拿著筷子,彼此都在等待其他哲學家手里筷子,造成阻塞環(huán)路
JVM面對死鎖問題沒有像數(shù)據(jù)庫那樣強大(默認超時釋放資源),一旦線程陷入死鎖,就可能不可再用了,進而造成:
- 線程僵死導致整個java進程業(yè)務流程阻塞
- 死鎖線程不可處理新的任務,造成服務吞吐量下降,進而權限癱瘓
除非顯示的將系統(tǒng)完全中止重啟,并希望不再發(fā)生類似的事情。
二、死鎖的危害
1. 饑餓問題
死鎖問題會導致大量線程僵持活躍在cpu中長時間執(zhí)行,使得CPU時鐘周期被長期霸占,例如:
- Java中線程優(yōu)先級使用不當且因為各種原因進入死鎖,導致其他低優(yōu)先級的線程長時間得不到時間執(zhí)行時間片而執(zhí)行超時。
- 持有鎖的線程遲遲未能結束(因為活躍性問題等原因進入無限循環(huán)或者本身就是一個大循環(huán)),導致其他線程長時間等待。
中java線程的函數(shù)中雖然定義了setPriorit用于設置線程的優(yōu)先級,但這只是作為操作系統(tǒng)調度的參考,手動設置java線程優(yōu)先級的作用是微乎其微的,對于問題1出現(xiàn)的概率也不高,同時筆者也建議非必要的情況下不要去調整線程的優(yōu)先級。

2. 糟糕的響應
對于計算密集型的后臺任務,利用使用并發(fā)容器在后臺頻繁寫入一些熱點數(shù)據(jù),這就可能導致并發(fā)讀操作因為這些寫操作而阻塞,導致等待時間變長。為了保證直觀的GUI應用的響應,我們建議可以適當調低后臺任務的線程優(yōu)先級,異或者采用分段鎖等方式分散并發(fā)壓力。
3. 活鎖問題
活鎖是另一種形式的活躍性問題,該問題盡管不會阻塞線程,但可能也會出現(xiàn)線程不能繼續(xù)執(zhí)行后續(xù)工作,例如當前線程將處理失敗的任務每次失敗都提交到隊列首部,不斷重試執(zhí)行這個失敗任務,造成后面的任務無法執(zhí)行,這就是典型的線程饑餓。

解決辦法即隨機分配,例如redis raft選舉主觀下線后各個節(jié)點隨機一段時間發(fā)起拉票,從而降低平票的概率,保證盡可能早的選舉出leader,同樣的我們的隨機策略也可以將失敗的任務隨機采用隨機性重試機制,在指定時間后將任務存入隊列中重試。
void sentinelTimer(void) {
// 前置檢查事件定期任務是否因為系統(tǒng)負載過大或者各種原因導致時鐘回撥,或者處理過長,進入tilt模式,該模式哨兵只會定期發(fā)送和接收命令
sentinelCheckTiltCondition();
//監(jiān)聽的master節(jié)點作為參數(shù)傳入,進行逐個通信處理
sentinelHandleDictOfRedisInstances(sentinel.masters);
//......
//隨機調整執(zhí)行頻率避免同時執(zhí)行,確保提高選舉一次性成功的概率
server.hz = REDIS_DEFAULT_HZ + rand() % REDIS_DEFAULT_HZ;
}三、詳解不同的死鎖案例與解決方案
1. 鎖順序造成的死鎖
基于上述的基本概念,我們引出第一個死鎖的案例,造成該死鎖的原因是兩個線程獲取鎖的方向是相反的,各自拿到第一把鎖之后,都在等待對方的第二把鎖,出現(xiàn)了加鎖依賴性問題,出現(xiàn)阻塞死鎖。
假設線程1執(zhí)行l(wèi)eftRight對應先拿leftLock再獲取rightLock,線程2反之,對應的出現(xiàn)這樣一個造成死鎖的流程:
- 線程0上leftLock
- 與此同時,線程1上rightLock
- 線程0嘗試獲取rightLock,發(fā)現(xiàn)被線程1持有,陷入阻塞等待
- 線程1嘗試獲取leftLock,發(fā)現(xiàn)被線程0持有,陷入阻塞等待
- 彼此僵持構成死鎖

對應上述事例,我們給出下面這段代碼:
private static final Object leftLock = new Object();
private static final Object rightLock = new Object();
/**
* 線程0先上左鎖 再上右鎖
*/
public static void leftRight() {
synchronized (leftLock) {
synchronized (rightLock) {
Console.log("線程0上鎖成功");
}
}
}
/**
* 線程1先上右鎖再上左鎖
*/
public static void rightLeft() {
synchronized (rightLock) {
synchronized (leftLock) {
Console.log("線程1上鎖成功");
}
}
}對應的筆者給出下面這段測試用例:
Thread t1 = new Thread(() -> leftRight());
Thread t2 = new Thread(() -> rightLeft());
t1.start();
t2.start();啟動后發(fā)現(xiàn)兩個線程僵持著,筆者基于jstack -b pid定位到了這兩個線程的死鎖代碼段,也就是我們上文的兩個函數(shù)對應的第二次上鎖的位置:

因為造成該問題的原因上因為兩者順序相反造成死鎖環(huán)路,所以解決的方式也很簡單,讓線程0和線程1保持一樣的上鎖順序,即讓二者從相同的方向競爭獲取兩把鎖:

2. 隱蔽的動態(tài)函數(shù)死鎖
我們再來看看這個案例,該函數(shù)的邏輯比較簡單,即直接將from賬戶的錢扣減,并加到to賬戶身上,從而完成一次轉賬操作,為了保證并發(fā)安全,該函數(shù)中執(zhí)行轉賬操作時會以轉賬方和收款方實例作為鎖的對象:
public static void transfer(Account from, Account to, int amount) {
synchronized (from) {
synchronized (to) {
//轉賬用戶扣除轉賬額度
from.setMoney(from.getMoney() - amount);
//接收方增加額度
to.setMoney(to.getMoney() + amount);
}
}
}咋一看這段代碼沒有問題,但我們還是試想一下這樣一個場景:
- 小明打算給小王轉賬100,觸發(fā)transfer調用
- 與此同時,小王也打算給小明轉賬200,觸發(fā)transfer調用
- 小明的轉賬函數(shù)先對自己上鎖,然后嘗試鎖住小王實例
- 小王的轉賬函數(shù)先對自己的實例上鎖,然后嘗試鎖住小明的實例
是不是很熟悉?兩個函數(shù)調用在小明和小王之間僵持阻塞,再一次構成環(huán)路死鎖:

對應的代碼如下代碼所示,兩個不同的賬戶按照相反的方向給對方轉賬,由此出現(xiàn)因為時序問題,造成動態(tài)調用死鎖:
Account from = new Account();
Account to = new Account();
//兩個線程按照相反的方向給對方轉賬
new Thread(()->transfer(from, to, 100)).start();
new Thread(()->transfer(to, from, 200)).start();對于這種問題的解決思路,永遠是要保證讓并發(fā)線程競爭鎖的順序,因為鎖是由外部傳參進來的,隨機性比較大,所以正確的排序鎖的方式筆者這樣一個思路:
- 比對轉賬雙方實例的hashCode,那個小先嘗試上那把鎖
- 因為hashcode存在沖突碰撞的情況,所以在hashCode一樣的情況下,則采用加時賽機制,我們建議線程同一去爭搶加時鎖(tile breaking lock)然后再嘗試獲取from和to兩把鎖,由此避免環(huán)路死鎖:
對應代碼示例如下,讀者可結合注釋理解:
//加時鎖
private static final Object tieLock = new Object();
public static void transfer(Account from, Account to, int amount) {
if (from.hashCode() > to.hashCode()) {//如果from大先從小的to開始上鎖
synchronized (to) {
synchronized (from) {
doTransfer(from, to, amount);
}
}
} else if (from.hashCode() < to.hashCode()) {//from小于to按照正常順序執(zhí)行
synchronized (from) {
synchronized (to) {
doTransfer(from, to, amount);
}
}
} else { //hash值一樣則上加時鎖
synchronized (tieLock) {
synchronized (from) {
synchronized (to) {
doTransfer(from, to, amount);
}
}
}
}
}3. 嵌套包含死鎖和開放調用
協(xié)作對象死鎖問題相較于上述問題來說更加隱蔽,這兩個鎖并不一定是在一個方法上獲取的,同樣是持有當前方法鎖的情況下,嘗試獲取外部入?yún)⒌膮f(xié)作對象的實例鎖,即多線程并發(fā)爭搶當前方法的實例鎖,再到函數(shù)內部調用入?yún)ο蟮姆椒▽嵗i。
舉個例子,服務A和服務B彼此會互相調用,服務A希望調用完服務B之后完成一次調用次數(shù)累計,服務B同理,因為要保證計數(shù)統(tǒng)計操作的原子性,彼此的函數(shù)都在方法上加了synchronized關鍵字如下代碼所示:
private static class AService {
private int count;
public synchronized void aFunc(BService bService) {//先上自己的實例鎖
bService.func(this);//再調用外部對象的方法,嘗試上入?yún)嵗i
count++;//完成方法計數(shù)統(tǒng)計
}
public synchronized void func(BService bService) {
}
}
private static class BService {
private int count;
public synchronized void bFunc(AService aService) {
aService.func(this);
count++;
}
public synchronized void func(AService aService) {
}
}我們事項這樣一個情況:
- 線程0調用aService的aFunc,先獲取到aService的實例鎖
- 線程1調用bService的bFunc,先獲取到bService的實例鎖
- 線程0嘗試獲取入?yún)Service的實例鎖,阻塞等待線程1釋放
- 線程1同理
還是熟悉的環(huán)路,只不過這個涉及多個對象之間的函數(shù)調用更加的隱蔽:

其實仔細審查上述代碼之間的調用鏈,從邏輯分析的角度來看,它不僅僅是一個環(huán)路,更像是兩把實例鎖之間對于彼此使用權的爭搶,從調用鏈路來看,無論是a服務還是b服務,從調用的那一刻起就已經決定整個函數(shù)的調用必須是持有一把鎖,嘗試把另一把鎖包在當前實例鎖的維度中,也就是這種帶有包含關系的鎖競爭,最終將平行為度的鎖資源競爭變成了各自持有一把鎖情況下爭搶包含鎖的死鎖問題:

所以解決這種嵌套包含鎖的問題,就必須打破鎖之間的嵌套包哈關系,以本文為例,我們只需將方法鎖的關鍵字移動到僅僅需要保證互斥關系的count變量上,將包含關系變?yōu)椴⑿嘘P系:
private static class AService {
private int count;
public void aFunc(BService bService) {//先上自己的實例鎖
bService.func(this);//再調用外部對象的方法,嘗試上入?yún)嵗i
synchronized (this) {
count++;//完成方法計數(shù)統(tǒng)計
}
}
public synchronized void func(BService bService) {
}
}
private static class BService {
private int count;
public void bFunc(AService aService) {
aService.func(this);
synchronized (this) {
count++;//完成方法計數(shù)統(tǒng)計
}
}
public synchronized void func(AService aService) {
}
}代碼改造完成后,從整個服務調用鏈路來看:
- 線程0 執(zhí)行aFunc嘗試調用func
- 線程1執(zhí)行bFunc嘗試調用func
- 線程0調用b服務的func成功,拿到b服務的鎖
- 線程1嘗試調用a服務的func成功,完成后準備上自己的b服務實例鎖,發(fā)現(xiàn)被線程0持有,阻塞等待
- 線程0完成b服務調用返回,上自己的實例鎖完成服務調用,釋放所有鎖
- 線程1完成所有調用

需要補充一點,開放調用雖然解決了協(xié)作對象間請求與保持的死鎖條件,但這會使得原本原子操作變成非原子操作,所以使用時還是需要結合業(yè)務場景綜合考量一下。
四、死鎖的診斷
1. 排查方法論
以筆者個人經驗,對于死鎖的排查方式大體遵循如下步驟:
- 通過監(jiān)控工具定位阻塞的代碼段,例如筆者上文所使用的jstack指令
- 基于代碼段定位阻塞的鎖以及所有涉及該鎖調用的函數(shù),梳理出調用鏈
- 基于該調用鏈,推理出發(fā)生死鎖的情況并復現(xiàn)問題
- 基于上述幾種鎖的情況和解決步驟進行修復
2. 工具推薦
本文是基于JVM的jstack工具查看最下方的線程堆棧信息,實際上類似于arthas等這種監(jiān)控工具會更方便,感興趣的讀者可以參考筆者這篇文章:《簡明的 Arthas 使用教程》。





























