Java併發程式設計實戰(4)- 死鎖
阿新 • • 發佈:2021-01-10
在這篇文章中,我們主要討論一下死鎖及其解決辦法。
[toc]
# 概述
在上一篇文章中,我們討論瞭如何使用一個互斥鎖去保護多個資源,以銀行賬戶轉賬為例,當時給出的解決方法是基於Class物件建立互斥鎖。
這樣雖然解決了同步的問題,但是能在現實中使用嗎?答案是不可以,尤其是在高併發的情況下,原因是我們使用的互斥鎖的範圍太大,以轉賬為例,我們的做法會鎖定整個賬戶Class物件,這樣會導致轉賬操作只能序列進行,但是在實際場景中,大量的轉賬操作業務中的雙方是不相同的,直接在Class物件級別上加鎖是不能接受的。
那如果在物件例項級別上加鎖,使用細粒度鎖,會有什麼問題?**可能會發生死鎖。**
我們接下來看一下造成死鎖的原因和可能的解決方案。
# 死鎖案例
什麼是死鎖?
> 死鎖是指一組互相競爭資源的執行緒因互相等待,導致“永久”阻塞的現象。
一般來說,當我們使用**細粒度鎖**時,它在提升效能的同時,也可能會導致死鎖。
我們還是以銀行轉賬為例,來看一下死鎖是如何發生的。
首先,我們先定義個BankAccount物件,來儲存基本資訊,程式碼如下。
```
public class BankAccount {
private int id;
private double balance;
private String password;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public double getBalance() {
return balance;
}
public void setBalance(double balance) {
this.balance = balance;
}
}
```
接下來,我們使用細粒度鎖來嘗試完成轉賬操作,程式碼如下。
```
public class BankTransferDemo {
public void transfer(BankAccount sourceAccount, BankAccount targetAccount, double amount) {
synchronized(sourceAccount) {
synchronized(targetAccount) {
if (sourceAccount.getBalance() > amount) {
System.out.println("Start transfer.");
System.out.println(String.format("Before transfer, source balance:%s, target balance:%s", sourceAccount.getBalance(), targetAccount.getBalance()));
sourceAccount.setBalance(sourceAccount.getBalance() - amount);
targetAccount.setBalance(targetAccount.getBalance() + amount);
System.out.println(String.format("After transfer, source balance:%s, target balance:%s", sourceAccount.getBalance(), targetAccount.getBalance()));
}
}
}
}
}
```
我們用下面的程式碼來做簡單測試。
```
public static void main(String[] args) throws InterruptedException {
BankAccount sourceAccount = new BankAccount();
sourceAccount.setId(1);
sourceAccount.setBalance(50000);
BankAccount targetAccount = new BankAccount();
targetAccount.setId(2);
targetAccount.setBalance(20000);
BankTransferDemo obj = new BankTransferDemo();
Thread t1 = new Thread(() ->{
for (int i = 0; i < 10000; i++) {
obj.transfer(sourceAccount, targetAccount, 1);
}
});
Thread t2 = new Thread(() ->{
for (int i = 0; i < 10000; i++) {
obj.transfer(targetAccount, sourceAccount, 1);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Finished.");
}
```
測試程式碼中包含了2個執行緒,其中t1執行緒迴圈從sourceAccount向targetAccount轉賬,而t2執行緒會迴圈從targetAccount向sourceAccount轉賬。
從執行結果來看,t1執行緒中的迴圈在執行600次左右時,t2執行緒也建立好,開始迴圈轉賬了,這時就會發生死鎖,導致t1執行緒和t2執行緒都無法繼續執行。
我們可以用下面的資源分配圖來更直觀的描述死鎖。
![](https://img2020.cnblogs.com/blog/26980/202101/26980-20210110113748238-660932981.png)
# 死鎖的原因和預防
併發程式一旦死鎖,一般沒有特別好的辦法,很多時候我們只能重啟應用,因此,**解決死鎖問題的最好辦法是規避死鎖。**
我們先來看一下死鎖發生的條件,一個叫[Coffman](https://en.wikipedia.org/wiki/Edward_G._Coffman_Jr.)的牛人,於1971年在ACM Computing Surveys發表了一篇名為[System Deadlocks](https://dl.acm.org/doi/10.1145/356586.356588)的文章,他總結了只有以下四個條件全部滿足的情況下,才會發生死鎖:
* 互斥,共享資源X和Y只能被一個執行緒佔用。
* 佔有且等待,執行緒t1已經取得共享資源X,在等待共享資源Y的時候,不釋放共享資源X。
* 不可搶佔,其他執行緒不能強行搶佔執行緒t1佔有的資源。
* 迴圈等待,執行緒t1等待執行緒t2佔有的資源,執行緒t2等待執行緒t1佔有的資源,就是迴圈等待。
通過上述描述,我們能夠推匯出,**只要破壞上面其中一個條件,就可以避免死鎖的發生。**
但是第一個條件互斥,是不可以被破壞的,否則我們就沒有用鎖的必要了,那麼我們來看如何破壞其他三個條件。
## 破壞佔用且等待條件
如果要破壞佔用且等待條件,我們可以嘗試一次性申請全部資源,這樣就不需要等待了。
在實現過程中,我們需要建立一個新的角色,負責同時申請和同時釋放全部資源,我們可以將其稱為Allocator。
我們來看一下具體的程式碼實現。
```
public class Allocator {
private volatile static Allocator instance;
private Allocator() {}
public static Allocator getInstance() {
if (instance == null) {
synchronized(Allocator.class) {
if (instance == null) {
instance = new Allocator();
}
}
}
return instance;
}
private Set