Java併發程式設計實戰(3)- 互斥鎖
阿新 • • 發佈:2021-01-09
我們在這篇文章中主要討論如何使用互斥鎖來解決併發程式設計中的原子性問題。
[toc]
# 概述
併發程式設計中的原子性問題的源頭是執行緒切換,那麼禁止執行緒切換可以解決原子性問題嗎?
這需要分情況討論,在單核CPU的情況下,同一時刻只有一個執行緒執行,禁止CPU中斷,就意味著作業系統不會重新排程執行緒,也就禁止了執行緒切換,這樣獲取CPU使用權的執行緒就可以不間斷的執行。
在多核CPU的情況下,同一時刻,有可能有兩個執行緒同時執行,一個執行緒執行在CPU-1上,另外一個執行緒執行在CPU-2上,這時禁止CPU中斷,只能保證某一個CPU上的執行緒連續執行,但並不能保證只有一個執行緒在執行。
**同一時刻只有一個執行緒執行**,我們稱之為*互斥*,如果我們能夠保證對共享變數的修改是互斥的,那麼無論是單核CPU還是多核CPU,就都能保證原子性了。
如何能做到呢?答案就是互斥鎖。
# 互斥鎖模型
## 互斥鎖簡易模型
當我們談論互斥鎖時,我們一般會把一段需要互斥執行的程式碼稱為臨界區,下面是一個簡單的示意圖。
![](https://img2020.cnblogs.com/blog/26980/202101/26980-20210109145040241-257458825.png)
當執行緒進入臨界區之前,首先嚐試加鎖,如果成功,可以進去臨界區,如果失敗,需要等待。當臨界區的程式碼被執行完畢或者發生異常時,執行緒釋放鎖。
## 互斥鎖改進模型
上面的模型雖然直觀,但是過於簡單,我們需要考慮2個問題:
* 我們鎖的是什麼?
* 我們保護的又是什麼?
在現實世界中,鎖和鎖要保護的資源是有對應關係的,通俗的講,你用你家的鎖保護你家的東西,我用我家的鎖保護我家的東西。
在併發程式設計的世界中,鎖和資源也應該有類似的對應關係。
下面是改進後的鎖模型。
![](https://img2020.cnblogs.com/blog/26980/202101/26980-20210109145110470-18827131.png)
首先,我們要把臨界區中要保護的資源R標註出來,然後,我們為資源R建立一個鎖LR,最後,在我們進入和離開臨界區時,需要對鎖LR進行加鎖和解鎖操作。
通過這樣的處理,我們就在鎖和資源之間建立了關聯關係,不會出現類似於“用我家的鎖去保護你家的資源”的問題。
# Java世界中的互斥鎖
在Java語言中,我們通過**synchronized關鍵字**來實現互斥鎖。
synchronized關鍵字可以應用在方法上,也可以直接應用在程式碼塊中。
我們來看下面的示例程式碼。
```
public class SynchronizedDemo {
// 修飾例項方法
synchronized void updateData() {
// 業務程式碼
}
// 修飾靜態方法
synchronized static void retrieveData() {
// 業務程式碼
}
// 修飾程式碼塊
Object obj = new Object();
void createData() {
synchronized(obj) {
// 業務程式碼
}
}
}
```
和我們描述的互斥鎖模型相比,我們並沒有在上述程式碼中看到加鎖和解鎖相關的程式碼,這是因為Java編譯器已經自動為我們在synchronized關鍵字修改的方法或者程式碼塊前後添加了加鎖和解鎖邏輯。這樣做的好處是我們不用擔心執行加鎖操作後,忘了解鎖操作。
## synchronized中的鎖和鎖物件
我們在使用synchronized關鍵字時,它鎖定的物件是什麼呢?如果沒有顯式指定鎖物件,Java有如下預設規則
* 當修飾靜態方法時,鎖定的是當前類的Class物件。
* 當修飾非靜態方法時,鎖定的是當前例項物件this。
根據上述規則,下面的程式碼是等價的。
```
// 修飾例項方法
synchronized void updateData() {
// 業務程式碼
}
// 修飾例項方法
synchronized(this) void updateData2() {
// 業務程式碼
}
```
```
// 修飾靜態方法
synchronized static void retrieveData() {
// 業務程式碼
}
// 修飾靜態方法
synchronized(SynchronizedDemo.class) static void retrieveData2() {
// 業務程式碼
}
```
## synchronized示例
我們在之前的文章中描述過count=count+1的例子,當時沒有做併發控制,結果引發了原子性問題,我們現在看一下,如何使用synchronized關鍵字來解決併發問題。
首先我們來複習一下Happens-Before規則,synchronized修飾的臨界區是互斥的,也就是說同一時刻只有一個執行緒執行臨界區的程式碼,而Happens-Before中的“對一個鎖解鎖Happens-Before後續對這個鎖的加鎖”,指的是前一個執行緒解鎖操作對後一個執行緒的加鎖操作是可見的,然後結合Happens-Before傳遞性原則,我們可以得出**前一個執行緒在臨界區修改的共享變數,對於後續完成加鎖進入臨界區的執行緒是可見的。**
下面是修改後的程式碼:
```
public class ConcurrencySafeAddDemo {
private long count = 0;
private synchronized void safeAdd() {
int index = 0;
while (index < 10000) {
count = count + 1;
index++;
}
}
private void reset() {
this.count = 0;
}
private void addTest() throws InterruptedException {
List threads = new ArrayList();
for (int i = 0; i < 6; i++) {
threads.add(new Thread(() -> {
this.safeAdd();
}));
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
threads.clear();
System.out.println(String.format("Count is %s", count));
}
public static void main(String[] args) throws InterruptedException {
ConcurrencySafeAddDemo demoObj = new ConcurrencySafeAddDemo();
for (int i = 0; i < 10; i++) {
demoObj.addTest();
demoObj.reset();
}
}
}
```
執行結果如下。
```
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
Count is 60000
```
這裡和我們的預期是一致的。
和第一版的程式碼相比,我們只是用synchronized關鍵字修飾了safeAdd()方法。
# 鎖與受保護的資源的關係
對於互斥鎖來說,鎖與受保護的資源之間的關聯關係非常重要,那麼這兩者之間到底是什麼關係呢?一個合理的解釋是:鎖與受保護的資源之間是N:1的關係,也就是說:
* 一個鎖可以應用到多個受保護資源
* 一個受保護資源上只能有一個鎖
我們可以用球賽門票來做類比,其中座位是資源,門票是鎖。一個座位只能用一張門票來保護,如果是“包場”的情況,一張包場門票就可以對應多個座位。不會出現一個座位有多張門票的情況。
同理,**在互斥鎖的場景下,如果兩個鎖使用了不同的鎖物件,那麼這兩個所對應的臨界區不是互斥的。** 這一點很重要,忽視它的話,很容易引發莫名其妙的併發問題。
例如,我們把上面示例程式碼中的safeAdd()方法改成下面的樣子,它還能正常工作嗎?
```
private void safeAdd() {
int index = 0;
synchronized(new Object()) {
while (index < 10000) {
count = count + 1;
index++;
}
}
}
```
這裡,我們在為synchronized關鍵字設定鎖物件時,每次都新建一個Object物件,那麼每個執行緒在執行到這裡時,都是使用不同的鎖物件,那麼臨界區中的程式碼就不是互斥的,最後得出的結果也不會是我們期望的。
```
Count is 17355
Count is 18215
Count is 19244
Count is 20863
Count is 60000
Count is 60000
Count is 60000
Count is 20430
Count is 60000
Count is 60000
```
# 一個鎖保護多個資源
上面我們談到一個互斥鎖可以保護多個資源,但是一個資源不可以被多個互斥鎖保護。
那麼,我們如何用一個鎖來保護多個資源呢?
## 一個鎖保護多個沒有關聯關係的資源
對於多個沒有關聯關係的資源,我們很容易用一個鎖去保護。
以銀行賬戶為例,銀行賬戶可以有取款操作,也有修改密碼操作,那麼賬戶餘額和賬戶密碼就是兩個沒有關聯關係的資源。
我們來看下面的示例程式碼。
```
public class BankAccountLockDemo {
private double balance;
private String password;
private Object commonLockObj = new Object();
// 取錢
private void withdrawMoney(double amount) {
synchronized(commonLockObj) {
// 業務程式碼
balance = balance - amount;
}
}
// 修改密碼
private void changePassword(String newPassword) {
synchronized(commonLockObj) {
// 業務程式碼
password = newPassword;
}
}
}
```
我們可以看到,上述程式碼使用了共享鎖`commonLockObj`來保護balance和password,是可以正常工作的。
但是這樣做存在的問題是取款和修改密碼操作不能同時進行,從業務角度看,這兩塊業務是沒有關聯的, 應該是可以並行的。
解決辦法是每個業務使用各自的互斥鎖對相關資源進行保護。上述程式碼中可以建立兩個鎖物件:`balanceLockObj`和`passwordLockObj`,這樣兩個業務操作就不會互相影響了,這樣的鎖也被稱為**細粒度鎖**。
## 一個鎖保護多個有關聯關係的資源
對於有關聯關係的資源,情況會複雜一些。
我們以轉賬操作為例進行說明,轉賬的過程會涉及兩個賬戶的餘額,這兩個餘額就是兩個有關聯關係的資源。
我們來看下面的示例程式碼。
```
public class BankAccountTransferLockDemo {
private double balance;
private Object lockObj = new Object();
private void transfer(BankAccountTransferLockDemo sourceAccount, BankAccountTransferLockDemo targetAccount, double amount) {
synchronized(lockObj) {
sourceAccount.balance = sourceAccount.balance - amount;
targetAccount.balance = targetAccount.balance + amount;
}
}
}
```
上述程式碼有問題嗎? 答案是有問題。
看上去我們在操作balance的時候,使用了加鎖處理,但是需要注意這裡的鎖物件是`lockObj`,是一個Object物件,如果此時有其他業務也需要操作相同賬戶的balance,例如存取款操作,其他業務是沒有辦法使用`lockObj`來建立鎖的,從而造成多個業務同時操作balance,引發併發問題。
問題的解決辦法是**我們建立的鎖需要能夠覆蓋受保護資源的所有場景。**
回到我們上面的示例,如果使用Object物件作為鎖物件不能覆蓋所有相關業務,那麼我們需要升級鎖物件,將其由Object物件變為Class物件,程式碼如下:
```
private void transfer(BankAccountTransferLockDemo sourceAccount, BankAccountTransferLockDemo targetAccount, double amount) {
synchronized(BankAccountTransferLockDemo.class) {
sourceAccount.balance = sourceAccount.balance - amount;
targetAccount.balance = targetAccount.balance + amount;
}
}
```
上述資源之間的關聯關係,如果用更具體、更專業的語言來描述,其實是一種“原子性”的特徵,原子性有兩層含義:1) CPU指令級別的原子性,2)業務含義上的原子性。
“原子性”的本質什麼?
> 原子性的表象是不可分割,其本質是**多個資源間有一致性的要求,操作的中間狀態對外不可見。**
解決原子性問題,就是要保證中間狀態對外不可見,這也是互斥鎖要解決的