鎖和受保護資源之間合理的關聯關系應該是 1:N 的關系。當我們要保護多個資源時,首先要區分這些資源是否存在關聯關系。
保護沒有關聯關系的多個資源
例如:球場的座位(資源)和電影院的座位(資源)就是沒有關聯關系的,這種場景非常容易解決,那就是球賽有球賽的門票(鎖),電影院有電影院的門票(鎖),各自管理各自的。(門票:鎖 ;座位:資源)
對應到程式設計領域,例如,銀行業務中有針對賬戶餘額(餘額是一種資源)的取款操作,也有針對賬戶密碼(密碼也是一種資源)的更改操作,我們可以為賬戶餘額和賬戶密碼配置設定不同的鎖來解決并發問題,這個還是很簡單的。
相關的示例代碼如下,賬戶類 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){
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,就像你不能用自家的鎖來保護别人家的資産,也不能用自己的票來保護别人的座位一樣。

用鎖 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 來保護不同對象的臨界區的。
總結
相信你看完這篇文章後,對如何保護多個資源已經很有心得了,關鍵是要分析多個資源之間的關系。如果資源之間沒有關系,很好處理,每個資源一把鎖就可以了。如果資源之間有關聯關系,就要選擇一個粒度更大的鎖,這個鎖應該能夠覆寫所有相關的資源。除此之外,還要梳理出有哪些通路路徑,所有的通路路徑都要設定合适的鎖,這個過程可以類比一下門票管理。
我們再引申一下上面提到的關聯關系,關聯關系如果用更具體、更專業的語言來描述的話,其實是一種“原子性”特征,在前面的文章中,我們提到的原子性,主要是面向 CPU 指令的,轉賬操作的原子性則是屬于是面向進階語言的,不過它們本質上是一樣的。
“原子性”的本質是什麼?其實不是不可分割,不可分割隻是外在表現,其本質是多個資源間有一緻性的要求,操作的中間狀态對外不可見。例如,在 32 位的機器上寫 long 型變量有中間狀态(隻寫了 64 位中的 32 位),在銀行轉賬的操作中也有中間狀态(賬戶 A 減少了 100,賬戶 B 還沒來得及發生變化)。是以解決原子性問題,是要保證中間狀态對外不可見。
課後思考
在第一個示例程式裡,我們用了兩把不同的鎖來分别保護賬戶餘額、賬戶密碼,建立鎖的時候,我們用的是:private final Object xxxLock = new Object();,如果賬戶餘額用 this.balance 作為互斥鎖,賬戶密碼用 this.password 作為互斥鎖,你覺得是否可以呢?
不行,一旦對他們指派,就會變成新的對象,加的鎖就失效了,無法保證那個互斥性了;
舉個例子,假如this.balance = 10 ,多個線程通路賬戶餘額這個資源,則多個線程同時競争同一把鎖this.balance,此時隻有一個線程A拿到了鎖,其他線程B等待,拿到鎖的線程A進行this.balance -= 1操作,this.balance = 9。 該線程A釋放鎖, 之前等待鎖的線程B繼續競争this.balance=10的鎖,新加入的線程C競争this.balance=9的鎖,導緻多個鎖對應一個資源,即線程B和C持有不同的鎖去通路同一個資源,無法保證同一時刻一個線程通路資源,無法保證互斥!