聊聊死鎖
1、簡介
在遇到線程安全問題的時候,我們會使用加鎖機制來確保線程安全,但如果過度地使用加鎖,則可能導致鎖順序死鎖(Lock-Ordering Deadlock)。或者有的場景我們使用線程池和信號量來限制資源的使用,但這些被限制的行為可能會導致資源死鎖(Resource DeadLock)。這是來自Java并發必讀佳作 Java Concurrency in Practice 關于活躍性危險中的描述。
我們知道Java應用程序不像數據庫服務器,能夠檢測一組事務中死鎖的發生,進而選擇一個事務去執行;在Java程序中如果遇到死鎖將會是一個非常嚴重的問題,它輕則導致程序響應時間變長,系統吞吐量變小;重則導致應用中的某一個功能直接失去響應能力無法提供服務,這些后果都是不堪設想的。因此我們應該及時發現和規避這些問題。
2、死鎖產生的條件
死鎖的產生有四個必要的條件
互斥使用,即當資源被一個線程占用時,別的線程不能使用
不可搶占,資源請求者不能強制從資源占有者手中搶奪資源,資源只能由占有者主動釋放
請求和保持,當資源請求者在請求其他資源的同時保持對原因資源的占有
循環等待,多個線程存在環路的鎖依賴關系而永遠等待下去,例如T1占有T2的資源,T2占有T3的資源,T3占有T1的資源,這種情況可能會形成一個等待環路
對于死鎖產生的四個條件只要能破壞其中一條即可讓死鎖消失,但是條件一是基礎,不能被破壞。
3、各種死鎖的介紹
先舉一個順序死鎖的例子。
構建一個LeftRightDeadLock類,這個類中有兩個共享資源right,left我們通過對這兩個共享資源加鎖的方式來控制程序的執行流程,但是這個示例在高并發的場景下存在順序死鎖的風險。
如下示意圖存在死鎖風險
LeftRightDeadLock示例代碼:
package com.liziba.dl; /** *
* 順序死鎖 *
* * @Author: Liziba */ public class LeftRightDeadLock { private final Object right = new Object(); private final Object left = new Object(); /** * 加鎖順序從left -> right */ public void leftToRight() { synchronized (left) { synchronized (right) { System.out.println(Thread.currentThread().getName() + " left -> right lock."); } } } /** * 加鎖順序right -> left */ public void rightToLeft() { synchronized (right) { synchronized (left) { System.out.println(Thread.currentThread().getName() + " right -> left lock."); } } } }測試代碼,通過創建多個線程,并發執行上面的LeftRightDeadLock
public static void main(String[] args) { LeftRightDeadLock lrDeadLock = new LeftRightDeadLock(); for (int i = 0; i < 10; i++) { new Thread(() -> { // 為了更好的演示死鎖,將兩個方法的調用放置到同一個線程中執行 lrDeadLock.leftToRight(); lrDeadLock.rightToLeft(); }, "ThreadA-"+i).start(); } }
可以看到如下的運行結果,程序并未結束,但是也無法繼續運行。
產生這種情況的原因,是不同的線程通過不同順序去獲取相同的鎖;比如線程1獲取鎖的順序是left -> right,而線程2獲取鎖的順序是right -> left,在某種情況下會發生死鎖。拿上面的案例分析,我們通過Java自帶的jps和jstack工具查看java進程ID和線程相關信息。
jps查看LeftRightDeadLock的進程id為17968
jstack查看進程中的線程信息,線程信息比較多,我把重要的復制出來,如下的圖中能很明顯的看到產生了死鎖。
這里省略了很多線程當前狀態信息
解決順序死鎖的辦法其實就是保證所有線程以相同的順序獲取鎖就行。
動態鎖順序死鎖與上面的鎖順序死鎖其實最本質的區別,就在于動態鎖順序死鎖鎖住的資源無法確定或者會發生改變。
比如說銀行轉賬業務中,賬戶A向賬戶B轉賬,賬戶B也可以向賬戶A轉賬,這種情況下如果加鎖的方式不正確就會發生死鎖,比如如下代碼:
定義簡單的賬戶類Account
package com.liziba.dl; import java.math.BigDecimal; /** *
* 賬戶類 *
* * @Author: Liziba */ public class Account { /** 賬戶 */ public String number; /** 余額 */ public BigDecimal balance; public Account(String number, BigDecimal balance) { this.number = number; this.balance = balance; } public void setNumber(String number) { this.number = number; } public void setBalance(BigDecimal balance) { this.balance = balance; } }定義轉賬類TransferMoney,其中有transferMoney()方法用于accountFrom賬戶向accountTo轉賬金額amt:
package com.liziba.dl; import java.math.BigDecimal; /** *
* 轉賬類 *
* * @Author: Liziba */ public class TransferMoney { /** * 轉賬方法 * * @param accountFrom 轉賬方 * @param accountTo 接收方 * @param amt 轉賬金額 * @throws Exception */ public static void transferMoney(Account accountFrom, Account accountTo, BigDecimal amt) throws Exception { synchronized (accountFrom) { synchronized (accountTo) { BigDecimal formBalance = accountFrom.balance; if (formBalance.compareTo(amt) < 0) { throw new Exception(accountFrom.number + " balance is not enough."); } else { accountFrom.setBalance(formBalance.subtract(amt)); accountTo.setBalance(accountTo.balance.add(amt)); System.out.println("Form" + accountFrom.number + ": " + accountFrom.balance.toPlainString() +"\t" + "To" + accountTo.number + ": " + accountTo.balance.toPlainString()); } } } } }上面這個類看似規定了鎖的順序由accountFrom到accountTo不會產生死鎖,但是這個accountFrom和accountTo是由調用方來傳入的,當A向B轉賬時accountFrom = A,accountTo = B;當B向A轉賬時accountFrom = B,accountTo = A;假設兩者在同一時刻給對方發起轉賬,則仍然存在3.1中鎖順序死鎖問題。比如如下測試:
public static void main(String[] args) { // 賬戶A && 賬戶B Account accountA = new Account("111111", new BigDecimal(10000)); Account accountB = new Account("2222222", new BigDecimal(10000)); // 循環創建線程 A -> B ; B -> A 各一百個線程 for (int i = 0; i < 100; i++) { new Thread(() -> { try { // 轉賬順序 A -> B transferMoney(accountA, accountB, new BigDecimal(10)); } catch (Exception e) { return; } }).start(); new Thread(() -> { try { // 轉賬順序 B -> A transferMoney(accountB, accountA, new BigDecimal(10)); } catch (Exception e) { return; } }).start(); } }
程序執行無法正確結束,如下所示:
依然使用jps+ jstack查看這個java進程的線程信息,發現Thread-89和Thread-90之間產生死鎖
解決動態鎖順序死鎖的辦法,就是通過一定的手段來嚴格控制加鎖的順序。比如通過對象中某一個唯一的屬性值比如id;或者也可以通過對象的散列值+hash沖突解決來控制加鎖的順序。
我們通過對象的散列值+hash沖突解決的方式來優化上面的代碼:
package com.liziba.dl; import java.math.BigDecimal; /** *
* 轉賬類優化 -> 通過hash算法 *
* * @Author: Liziba */ public class TransferMoneyOptimize { /** hash 沖突時使用第三個鎖(優秀的hash算法沖突是很少的!) */ private static final Object conflictShareLock = new Object(); /** * 轉賬方法 * * @param accountFrom 轉賬方 * @param accountTo 接收方 * @param amt 轉賬金額 * @throws Exception */ public static void transferMoney(Account accountFrom, Account accountTo, BigDecimal amt) throws Exception { // 計算hash值 int accountFromHash = System.identityHashCode(accountFrom); int accountToHash = System.identityHashCode(accountTo); // 如下三個分支能一定控制賬戶之間的轉是不會產生死鎖的 if (accountFromHash > accountToHash) { synchronized (accountFrom) { synchronized (accountTo) { transferMoneyHandler(accountFrom, accountTo, amt); } } } else if (accountToHash > accountFromHash) { synchronized (accountTo) { synchronized (accountFrom) { transferMoneyHandler(accountFrom, accountTo, amt); } } } else { // 解決hash沖突 synchronized (conflictShareLock) { synchronized (accountFrom) { synchronized (accountTo) { transferMoneyHandler(accountFrom, accountTo, amt); } } } } } /** * 賬戶金額增加處理 * * @param accountFrom 轉賬方 * @param accountTo 接收方 * @param amt 轉賬金額 * @throws Exception */ private static void transferMoneyHandler(Account accountFrom, Account accountTo, BigDecimal amt) throws Exception { if (accountFrom.balance.compareTo(amt) < 0) { throw new Exception(accountFrom.number + " balance is not enough."); } else { accountFrom.setBalance(accountFrom.balance.subtract(amt)); accountTo.setBalance(accountTo.balance.add(amt)); System.out.println("Form" + accountFrom.number + ": " + accountFrom.balance.toPlainString() +"\t" + "To" + accountTo.number + ": " + accountTo.balance.toPlainString()); } } }測試代碼與上面錯誤的示例代碼一致,經過數次其輸出結果均為如下:
在上面兩種死鎖的產生原因都是因為兩個線程以不同的順序獲取相同的所導致的,而解決的辦法都是通過一定的規范來嚴格控制加鎖的順序,這樣就能正確的規避死鎖的風險。
死鎖的產生往往沒有上述兩種死鎖產生的那么明顯,就算其存在死鎖風險也只有在高并發的場景下才會暴露出來(這并不意味著沒得高并發的應用就不用考慮死鎖問題了啊,弟兄們!)。如下介紹一種隱藏的比較深的死鎖,這種死鎖產生在多個協作對象的函數調用不透明。
如下以出租車為例介紹協作對象之間死鎖的產生,其主要涉及到以下幾個類(省略了很多代碼,自行腦補哈!):
Coordinate -> 坐標類,出租車經緯度信息類
Taxi -> 出租車類,出租車所屬于某個出租車車隊Fleet,此外包含當前坐標location和目的地坐標destination,出租車在更新目的地信息的時候會判斷當前坐標與目的地坐標是否相等,相等則會通知所屬車隊車輛空閑,可以接收下一個目的地
Fleet -> 出租車車隊類,出租車類包含兩個集合taxis和available,分別用來保存車隊中所有車輛信息和車隊中當前空閑的出租車信息,此外提供獲取車隊中所有出租車當前地址信息的快照方法getImage()
Image -> 車輛地址信息快照類,用于獲取出租車的地址信息
Coordinate(坐標類) 代碼示例:
package com.liziba.dl; /** *
* 坐標類 *
* * @Author: Liziba */ public class Coordinate { /** 經度 */ private Double longitude; /** 緯度 */ private Double latitude; // 省略 getXxx,setXxx等方法 }Taxi(出租車類)代碼示例;
package com.liziba.dl; import java.util.Objects; /** *
* 出租車類 *
* * @Author: Liziba */ public class Taxi { /** 出租車唯一標志 */ private String id; /** 當前坐標 */ private Coordinate location; /** 目的地坐標 */ private Coordinate destination; /** 所屬車隊 */ private final Fleet fleet; /** * 獲取當前地址信息 * @return */ public synchronized Coordinate getLocation() { return location; } /** * 更新當前地址信息 * 如果當前地址與目的地地址一致,則表名到達目的地需要通知車隊,當前出租車空閑可用前往下一個目的地 * * @param location */ public synchronized void setLocation(Coordinate location) { this.location = location; if (location.equals(destination)) { fleet.free(this); } } public Coordinate getDestination() { return destination; } /** * 設置目的地 * * @param destination */ public synchronized void setDestination(Coordinate destination) { this.destination = destination; } public Taxi(Fleet fleet) { this.fleet = fleet; } public String getId() { return id; } public void setId(String id) { this.id = id; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Taxi taxi = (Taxi) o; return Objects.equals(location, taxi.location) && Objects.equals(destination, taxi.destination); } @Override public int hashCode() { return Objects.hash(location, destination); } }Fleet(出租車車隊類)示例代碼:
package com.liziba.dl; import java.util.Set; /** *
* 車隊類 -> 調度管理出租車 *
* * @Author: Liziba */ public class Fleet { /** 車隊中所有出租車 */ private final SetImage(車輛地址信息快照類)示例代碼:
package com.liziba.dl; import java.util.HashMap; import java.util.Map; /** *
* 獲取所有出租車在某一時刻的位置快照 *
* * @Author: Liziba */ public class Image { Map在上述代碼中,看不到一個方法中有對多個資源直接加鎖,但仔細分析卻能發現在方法的調用之間是存在對多個資源“隱式”加鎖的,比如Taxi中的setLocation(Coordinate location)與Fleet中的Image getImage()。
setLocation(Coordinate location)方法需要獲取當前出租車Taxi對象的鎖以及出租車所屬車隊Fleet的鎖
getImage()方法需要獲取當前車隊Fleet的鎖,以及在遍歷出租車獲取其地址信息時需要獲取每個出租車Taxi對象的鎖
如上所示的這兩種情況無法避免同時執行的情況,因此存在死鎖的可能性,其執行流程如下:
Taxi中的setLocation(Coordinate location)方法與getImage()方法中包含其他方法的調用,方法的調用應該是透明的也就是說,調用方無需知道方法內部的執行邏輯,這是正確的。但是方法中調用的其他方法可能是同步方法或者方法中會發生較長時間的阻塞,這會導致死鎖或者線程長時間等待等問題。基于此類問題,可以采用縮小同步代碼的訪問(鎖盡可能少的代碼)和開放調用(不加鎖)來解決(Open Call)。
上述代碼我們基于上面提的兩種方式來優化:
Taxi -> TaxiOptimize(優化出租車類):
package com.liziba.dl; import java.util.Objects; /** *
* 出租車類優化 *
* * @Author: Liziba */ public class TaxiOptimize { // 省略相同的屬性和函數 /** * 優化內容 * setLocation(Coordinate location)方法不在加鎖 * 將同步范圍(鎖住的代碼)縮小 * this的鎖與fleet順序獲取 ,鎖內沒有嵌套,不會死鎖 * * @param location */ public void setLocation(Coordinate location) { this.location = location; boolean release = false; synchronized (this) { if (location.equals(destination)) { release = true; } } if (release) { fleet.free(this); } } }Fleet -> FleetOptimize(優化出租車車隊類):
package com.liziba.dl; import java.util.HashSet; import java.util.Set; /** *
* 出租車車隊類優化 *
* * @Author: Liziba */ public class FleetOptimize { // 省略相同的屬性和函數 /** * 優化內容 * getImage()不再加鎖 * 將同步范圍(鎖住的代碼)縮小 * this(出租車車隊對象)與drawMarker()方法中獲取taxi對象的鎖不再嵌套不會死鎖 * * @return */ public Image getImage() { Set上述的代碼雖然在同步語義上有一定的改變,但是符合業務場景的需求。具體在開發中怎么去抉擇鎖的范圍和加鎖的順序,需要各位開發大佬仔細斟酌,畢竟加鎖的代碼就那么點,用的好名垂千古,用不好遺臭萬年,哈哈哈哈。
上面發生的死鎖都是兩個線程相互持有對方需要獲取的鎖資源又不釋放本身持有的鎖;而資源死鎖與上面的案例有些相似,只是這里相互持有對方需要的資源(比如數據庫連接池中的數據庫連接)。現在假設有兩個數據庫連接池分別用來訪問數據庫A和數據庫B,這時有多個任務需要同時訪問數據庫A和數據庫B,他們都需要從數據庫連接池中獲取連接才能訪問對應的數據庫。做個極端的假設,數據庫連接池A只有一個連接,數據庫連接池B也只有一個連接(這只是為了更好的理解資源死鎖的產生!),那么此時可能會出現下面所示的情況:
如上的這種情況在數據庫連接池中連接數量較高的時候發生的情況是十分少的,但也并不是完全沒有可能。
如下通過Executors.newSingleThreadExecutor()構建一個只有一個線程的線程池,提交的主任務會再次提交兩個任務到這個線程池中去執行,在主任務中等待兩個子任務的結果,而子任務又必須等到主任務執行結束后才能執行,這種情況就會產生線程饑餓死鎖。
package com.lizba.currency.deadlock; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; /** *
* 單線程Executor中任務發送死鎖 *
* * @Author: Liziba * @Date: 2021/7/1 21:25 */ public class ThreadDeadLock { /** 單個線程的線程池 */ static ExecutorService executorService = Executors.newSingleThreadExecutor(); public static class Task1 implements Callable4、死鎖的避免和診斷
關于死鎖的避免主要是這幾個方面:
盡可能使用無鎖編程,使用開放調用的編碼設計
盡可能的縮小鎖的范圍,防止鎖住的資源過多引發阻塞和饑餓
如果加鎖的時候需要獲取多個鎖則應該正確的設計鎖的順序
使用定時鎖,比如Lock中的tryLock()
關于死鎖的診斷主要是這幾個方面:
找出代碼什么地方會使用多個鎖,對這些代碼實例進行全局分析
通過線程轉儲(Thread Dump)信息來分析死鎖
5、死鎖以外的其他活躍性危險
除了死鎖以外,并發的程序中可能還會存在以下幾種風險
線程饑餓在上面的線程池案例中也提到過,它指的是當前線程無法獲取到CPU的執行周期(一直被其他線程占用執行),類似發生的還有在ReetranLock中的非公平鎖的實現也可能會出現線程饑餓的問題。
關于線程饑餓的解決辦法:
不隨意改變線程的優先級,盡量使得線程的優先級一致(這個在大部分場景都是適用的)
任務的執行盡量保持隨機性或者公平性(性能考慮優先)
響應時間長指的是某個線程執行的任務占有較長的CPU執行時間,會導致后續的操作阻塞,導致程序失去響應。比如說瀏覽某個網頁,向服務端發起的某個請求中包含運行時間較長的任務,此時前端程序將會失去響應,使得用戶體驗極差。
關于響應時間長的解決辦法:
異步執行
避免代碼中鎖住的資源過大或者是CPU密集型的資源(盡量優化)
提升硬件設備
合理的設計線程執行的優先級
活鎖指的是線程不阻塞,會持續保持運行,但是這里的運行時重復的執行同一個任務。比如消息發送用隊列來存儲需要發送的消息,某條消息由于某些原因不能發送成功并且沒有被丟棄或者做其他處理,而是直接回到隊列的頭部重新執行,這會導致這條消息一直循環不斷的執行下去。
關于活鎖的解決辦法:
增加重試的隨機性
增大重試間隔時間
設置最大重試次數
Java 任務調度
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。