[併發理論基礎] 04 | 互斥鎖(下):如何用一把鎖保護多個資源?
上一篇文章提到受保護資源和鎖之間合理的關聯關係應該是 N:1 的關係,也就是說可以用一把鎖來保護多個資源,但是不能用多把鎖來保護一個資源。這一篇來討論如何保護多個資源。
當我們要保護多個資源時,首先要區分這些資源是否存在關聯關係。
一、保護沒有關聯關係的多個資源
銀行業務中有針對賬戶餘額(餘額是一種資源)的取款操作,也有針對賬戶密碼(密碼也是一種資源)的更改操作,我們可以爲賬戶餘額和賬戶密碼分配不同的鎖來解決併發問題,這個還是很簡單的。
示例代碼如下,賬戶類 Account 有兩個成員變量,分別是賬戶餘額 balance 和賬戶密碼 password。取款 withdraw() 和查看餘額 getBalance() 操作會訪問賬戶餘額 balance,我們創建一個 final 對象 balLock 作爲鎖;而更改密碼 updatePassword() 和查看密碼 getPassword() 操作會修改賬戶密碼 password,我們創建一個 final 對象 pwLock 作爲鎖。不同的資源用不同的鎖保護,各自管各自的,很簡單。
class Account {
// 鎖:保護賬戶餘額
private final Object balLock = new Object();
// 賬戶餘額
private Integer balance;
// 鎖:保護賬戶密碼
private final Object pwLock = new Object();
// 賬戶密碼
private String password;
// 取款
void withdraw(Integer amt) {
synchronized(balLock) {
if (this.balance > amt){
this.balance -= amt;
}
}
}
// 查看餘額
Integer getBalance() {
synchronized(balLock) {
return balance;
}
}
// 更改密碼
void updatePassword(String pw){
synchronized(pwLock) {
this.password = pw;
}
}
// 查看密碼
String getPassword() {
synchronized(pwLock) {
return password;
}
}
}
當然,我們也可以用一把互斥鎖來保護多個資源,例如我們可以用 this 這一把鎖來管理賬戶類裏所有的資源:賬戶餘額和用戶密碼。具體實現很簡單,示例程序中所有的方法都增加同步關鍵字 synchronized 就可以了,這裏我就不一一展示了。
但是用一把鎖有個問題,就是性能太差,會導致取款、查看餘額、修改密碼、查看密碼這四個操作都是串行的。而我們用兩把鎖,取款和修改密碼是可以並行的。用不同的鎖對受保護資源進行精細化管理,能夠提升性能。這種鎖還有個名字,叫細粒度鎖。
二、保護有關聯關係的多個資源
如果多個資源是有關聯關係的,那這個問題就有點複雜了。例如銀行業務裏面的轉賬操作,賬戶 A 減少 100 元,賬戶 B 增加 100 元。這兩個賬戶就是有關聯關係的。那對於像轉賬這種有關聯關係的操作,我們應該怎麼去解決呢?先把這個問題代碼化。我們聲明瞭個賬戶類:Account,該類有一個成員變量餘額:balance,還有一個用於轉賬的方法:transfer(),然後怎麼保證轉賬操作 transfer() 沒有併發問題呢?
class Account {
private int balance;
// 轉賬
void transfer(Account target, int amt){//amt爲轉賬金額,target爲轉賬對象
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
相信你的直覺會告訴你這樣的解決方案:用戶 synchronized 關鍵字修飾一下 transfer() 方法就可以了,於是你很快就完成了相關的代碼,如下所示。
class Account {
private int balance;
// 轉賬
synchronized void transfer(Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
在這段代碼中,臨界區內有兩個資源,分別是轉出賬戶的餘額 this.balance 和轉入賬戶的餘額 target.balance,並且用的是一把鎖 this,符合我們前面提到的,多個資源可以用一把鎖來保護,這看上去完全正確呀。真的是這樣嗎?可惜,這個方案僅僅是看似正確,爲什麼呢?
問題就出在 this 這把鎖上,this 這把鎖可以保護自己的餘額 this.balance,卻保護不了別人的餘額 target.balance,就像你不能用自家的鎖來保護別人家的資產一樣。
下面我們具體分析一下,假設有 A、B、C 三個賬戶,餘額都是 200 元,我們用兩個線程分別執行兩個轉賬操作:賬戶 A 轉給賬戶 B 100 元,賬戶 B 轉給賬戶 C 100 元,最後我們期望的結果應該是賬戶 A 的餘額是 100 元,賬戶 B 的餘額是 200 元, 賬戶 C 的餘額是 300 元。
我們假設線程 1 執行賬戶 A 轉賬戶 B 的操作,線程 2 執行賬戶 B 轉賬戶 C 的操作。這兩個線程分別在兩顆 CPU 上同時執行,那它們是互斥的嗎?我們期望是,但實際上並不是。因爲線程 1 鎖定的是賬戶 A 的實例(A.this),而線程 2 鎖定的是賬戶 B 的實例(B.this),所以這兩個線程可以同時進入臨界區 transfer()。同時進入臨界區的結果是什麼呢?線程 1 和線程 2 都會讀到賬戶 B 的餘額爲 200,導致最終賬戶 B 的餘額可能是 300(線程 1 後於線程 2 寫 B.balance,線程 2 寫的 B.balance 值被線程 1 覆蓋),可能是 100(線程 1 先於線程 2 寫 B.balance,線程 1 寫的 B.balance 值被線程 2 覆蓋),就是不可能是 200。
三、使用鎖的正確姿勢
如果要用鎖來保護多個資源,那麼鎖就要能覆蓋所有受保護資源。在上面的例子中,this 是對象級別的鎖,所以 A 對象和 B 對象都有自己的鎖,如何讓 A 對象和 B 對象共享一把鎖呢?
稍微開動腦筋,你會發現其實方案還挺多的,比如可以讓所有對象都持有一個唯一性的對象,這個對象在創建 Account 時傳入。方案有了,完成代碼就簡單了。示例代碼如下,我們把 Account 默認構造函數變爲 private,同時增加一個帶 Object lock 參數的構造函數,創建 Account 對象時,傳入相同的 lock,這樣所有的 Account 對象都會共享這個 lock 了。
class Account {
private Object lock;
private int balance;
private Account();
// 創建 Account 時傳入同一個 lock 對象
public Account(Object lock) {
this.lock = lock;
}
// 轉賬
void transfer(Account target, int amt){
// 此處檢查所有對象共享的鎖
synchronized(lock) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
這個辦法確實能解決問題,但是有點小瑕疵,它要求在創建 Account 對象的時候必須傳入同一個對象,如果創建 Account 對象時,傳入的 lock 不是同一個對象,那可就慘了,會出現鎖自家門來保護他家資產的荒唐事。在真實的項目場景中,創建 Account 對象的代碼很可能分散在多個工程中,傳入共享的 lock 真的很難。
所以,上面的方案缺乏實踐的可行性,我們需要更好的方案。還真有,就是用 Account.class 作爲共享的鎖。Account.class 是所有 Account 對象共享的,而且這個對象是 Java 虛擬機在加載 Account 類的時候創建的,所以我們不用擔心它的唯一性。使用 Account.class 作爲共享的鎖,我們就無需在創建 Account 對象時傳入了,代碼更簡單。
class Account {
private int balance;
// 轉賬
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
下面這幅圖很直觀地展示了我們是如何使用共享的鎖 Account.class 來保護不同對象的臨界區的。
使用Account.class獲得鎖有很明顯的性能問題,如果用Account.class作爲加鎖對象的話,那所有的轉賬功能將會是串行。實際上4個不同賬戶之間的轉賬是可以並行執行的,例如A給B轉賬,C給D轉賬,這兩個動作是可以一起執行的。
下一篇的內容涉及它的優化
對於方案一,可以在Account中添加一個靜態object,通過鎖這個object來實現一個鎖保護多個資源。這種方式比鎖class更安全。final用來修飾成員變量,該變量只能被賦值一次且它的值無法被改變。
private static final Object lock = new Object();
四、課後題
在第一個示例程序裏,我們用了兩把不同的鎖來分別保護賬戶餘額、賬戶密碼,創建鎖的時候,我們用的是:private final Object xxxLock = new Object();,如果賬戶餘額用 this.balance 作爲互斥鎖,賬戶密碼用 this.password 作爲互斥鎖,你覺得是否可以呢?
回答一:用this.balance 和this.password 都不行。在同一個賬戶多線程訪問時候,A線程取款進行this.balance-=amt;時候此時this.balance對應的值已經發生變換,線程B再次取款時拿到的balance對應的值並不是A線程中的,也就是說不能把可變的對象當成一把鎖。this.password 雖然說是String修飾但也會改變,所以也不行
回答二:不能用balance和password做爲鎖對象。這兩個對象balance是Integer,password是String都是不可變變對象,一但對他們進行賦值就會變成新的對象,加的鎖就失效了
舉例:
比如有線程A、B、C
線程A首先拿到balance1鎖,線程B這個時候也過來,發現鎖被拿走了,線程B被放入一個地方進行等待。
當A修改掉變量balance的值後,鎖由balance1變爲balance2.
線程B也拿到那個balance1鎖,這時候剛好有線程C過來,拿到了balance2鎖。
由於B和C持有的鎖不同,所以可以同時執行這個方法來修改balance的值,這個時候就有可能是線程B修改的值會覆蓋掉線程C修改的值。(BC鎖的不是一個對象。不能保證互斥性)