目錄
一、解決原子性問題——互斥
1、那原子性問題到底該如何解決呢?
2、簡易鎖模型
3、改進後的鎖模型
4、Java 語言提供的鎖技術:synchronized
5、用 synchronized 解決 count+=1 問題
6、鎖和受保護資源的關系
二、如何用一把鎖保護多個資源?
1、保護沒有關聯關系的多個資源
2、保護有關聯關系的多個資源
3、使用鎖的正确姿勢
一、解決原子性問題——互斥
一個或者多個操作在 CPU 執行的過程中不被中斷的特性,稱為“原子性”。
1、那原子性問題到底該如何解決呢?
原子性問題的源頭是線程切換,如果能夠禁用線程切換那不就能解決這個問題了嗎?而作業系統做線程切換是依賴 CPU 中斷的,是以禁止 CPU 發生中斷就能夠禁止線程切換。
在早期單核 CPU 時代,這個方案的确是可行的,而且也有很多應用案例,但是并不适合多核場景。這裡我們以 32 位 CPU 上執行 long 型變量的寫操作為例來說明這個問題,long 型變量是 64 位,在 32 位 CPU 上執行寫操作會被拆分成兩次寫操作(寫高 32 位和寫低 32 位,如下圖所示)。
在單核 CPU 場景下,同一時刻隻有一個線程執行,禁止 CPU 中斷,意味着作業系統不會重新排程線程,也就是禁止了線程切換,獲得 CPU 使用權的線程就可以不間斷地執行,是以兩次寫操作一定是:要麼都被執行,要麼都沒有被執行,具有原子性。
但是在多核場景下,同一時刻,有可能有兩個線程同時在執行,一個線程執行在 CPU-1 上,一個線程執行在 CPU-2 上,此時禁止 CPU 中斷,隻能保證 CPU 上的線程連續執行,并不能保證同一時刻隻有一個線程執行,如果這兩個線程同時寫 long 型變量高 32 位的話,那就有可能出現明明已經把變量成功寫入記憶體,重新讀出來卻不是自己寫入的現象。
“同一時刻隻有一個線程執行”這個條件非常重要,我們稱之為互斥。如果我們能夠保證對共享變量的修改是互斥的,那麼,無論是單核 CPU 還是多核 CPU,就都能保證原子性了。
2、簡易鎖模型
我們把一段需要互斥執行的代碼稱為臨界區。線程在進入臨界區之前,首先嘗試加鎖 lock(),如果成功,則進入臨界區,此時我們稱這個線程持有鎖;否則呢就等待,直到持有鎖的線程解鎖;持有鎖的線程執行完臨界區的代碼後,執行解鎖 unlock()。
這個過程非常像辦公室裡高峰期搶占坑位,每個人都是進坑鎖門(加鎖),出坑開門(解鎖),如廁這個事就是臨界區。這樣了解本身沒有問題,但卻很容易讓我們忽視兩個非常非常重要的點:我們鎖的是什麼?我們保護的又是什麼?
3、改進後的鎖模型
首先,我們要把臨界區要保護的資源标注出來,如圖中臨界區裡增加了一個元素:受保護的資源 R;其次,我們要保護資源 R 就得為它建立一把鎖 LR;最後,針對這把鎖 LR,我們還需在進出臨界區時添上加鎖操作和解鎖操作。另外,在鎖 LR 和受保護資源之間,我特地用一條線做了關聯,這個關聯關系非常重要。很多并發 Bug 的出現都是因為把它忽略了,然後就出現了類似鎖自家門來保護他家資産的事情,這樣的 Bug 非常不好診斷,因為潛意識裡我們認為已經正确加鎖了。
4、Java 語言提供的鎖技術:synchronized
鎖是一種通用的技術方案,Java 語言提供的 synchronized 關鍵字,就是鎖的一種實作。synchronized 關鍵字可以用來修飾方法,也可以用來修飾代碼塊,它的使用示例基本上都是下面這個樣子:
public class X {
// 修飾非靜态方法
synchronized void foo() {
// 臨界區
}
// 修飾靜态方法
synchronized static void bar() {
// 臨界區
}
// 修飾代碼塊
Object obj = new Object();
void baz() {
synchronized (obj) {
// 臨界區
}
}
}
看完之後你可能會覺得有點奇怪,這個和我們上面提到的模型有點對不上号啊,加鎖 lock() 和解鎖 unlock() 在哪裡呢?其實這兩個操作都是有的,隻是這兩個操作是被 Java 默默加上的,Java 編譯器會在 synchronized 修飾的方法或代碼塊前後自動加上加鎖 lock() 和解鎖 unlock(),這樣做的好處就是加鎖 lock() 和解鎖 unlock() 一定是成對出現的,畢竟忘記解鎖 unlock() 可是個緻命的 Bug(意味着其他線程隻能死等下去了)。
那 synchronized 裡的加鎖 lock() 和解鎖 unlock() 鎖定的對象在哪裡呢?上面的代碼我們看到隻有修飾代碼塊的時候,鎖定了一個 obj 對象,那修飾方法的時候鎖定的是什麼呢?這個也是 Java 的一條隐式規則:
- 當修飾靜态方法的時候,鎖定的是目前類的 Class 對象,在上面的例子中就是 Class X;
- 當修飾非靜态方法的時候,鎖定的是目前執行個體對象 this。
對于上面的例子,synchronized 修飾靜态方法相當于:
class X {
// 修飾靜态方法
synchronized(X.class) static void bar() {
// 臨界區
}
}
修飾非靜态方法,相當于:
class X {
// 修飾非靜态方法
synchronized(this) void foo() {
// 臨界區
}
}
5、用 synchronized 解決 count+=1 問題
相信你一定記得我們前面文章中提到過的 count+=1 存在的并發問題,現在我們可以嘗試用 synchronized 來小試牛刀一把,代碼如下所示。SafeCalc 這個類有兩個方法:一個是 get() 方法,用來獲得 value 的值;另一個是 addOne() 方法,用來給 value 加 1,并且 addOne() 方法我們用 synchronized 修飾。那麼我們使用的這兩個方法有沒有并發問題呢?
class SafeCalc {
long value = 0L;
long get() { // 該方法沒法保證可見性
return value;
}
synchronized void addOne() {
value += 1;
}
}
們先來看看 addOne() 方法,首先可以肯定,被 synchronized 修飾後,無論是單核 CPU 還是多核 CPU,隻有一個線程能夠執行 addOne() 方法,是以一定能保證原子操作,那是否有可見性問題呢?要回答這問題,就要提到管程中鎖的規則。
對一個鎖的解鎖 Happens-Before 于後續對這個鎖的加鎖。
管程,就是我們這裡的 synchronized,我們知道 synchronized 修飾的臨界區是互斥的,也就是說同一時刻隻有一個線程執行臨界區的代碼;而所謂“對一個鎖解鎖 Happens-Before 後續對這個鎖的加鎖”,指的是前一個線程的解鎖操作對後一個線程的加鎖操作可見,綜合 Happens-Before 的傳遞性原則,我們就能得出前一個線程在臨界區修改的共享變量(該操作在解鎖之前),對後續進入臨界區(該操作在加鎖之後)的線程是可見的。
按照這個規則,如果多個線程同時執行 addOne() 方法,可見性是可以保證的,也就說如果有 1000 個線程執行 addOne() 方法,最終結果一定是 value 的值增加了 1000。
但也許,你一不小心就忽視了 get() 方法。執行 addOne() 方法後,value 的值對 get() 方法是可見的嗎?這個可見性是沒法保證的。管程中鎖的規則,是隻保證後續對這個鎖的加鎖的可見性,而 get() 方法并沒有加鎖操作,是以可見性沒法保證。那如何解決呢?很簡單,就是 get() 方法也 synchronized 一下,完整的代碼如下所示。
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
上面的代碼轉換為我們提到的鎖模型,就是下面圖示這個樣子。get() 方法和 addOne() 方法都需要通路 value 這個受保護的資源,這個資源用 this 這把鎖來保護。線程要進入臨界區 get() 和 addOne(),必須先獲得 this 這把鎖,這樣 get() 和 addOne() 也是互斥的。
6、鎖和受保護資源的關系
我們前面提到,受保護資源和鎖之間的關聯關系非常重要,他們的關系是怎樣的呢?一個合理的關系是:受保護資源和鎖之間的關聯關系是 N:1 的關系。
上面那個例子我稍作改動,把 value 改成靜态變量,把 addOne() 方法改成靜态方法,此時 get() 方法和 addOne() 方法是否存在并發問題呢?
public class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
如果你仔細觀察,就會發現改動後的代碼是用兩個鎖保護一個資源。這個受保護的資源就是靜态變量 value,兩個鎖分别是 this 和 SafeCalc.class。我們可以用下面這幅圖來形象描述這個關系。由于臨界區 get() 和 addOne() 是用兩個鎖保護的,是以這兩個臨界區沒有互斥關系,臨界區 addOne() 對 value 的修改對臨界區 get() 也沒有可見性保證,這就導緻并發問題了。
總結
互斥鎖,在并發領域的知名度極高,隻要有了并發問題,大家首先容易想到的就是加鎖,因為大家都知道,加鎖能夠保證執行臨界區代碼的互斥性。這樣了解雖然正确,但是卻不能夠指導你真正用好互斥鎖。臨界區的代碼是操作受保護資源的路徑,類似于球場的入口,入口一定要檢票,也就是要加鎖,但不是随便一把鎖都能有效。是以必須深入分析鎖定的對象和受保護資源的關系,綜合考慮受保護資源的通路路徑,多方面考量才能用好互斥鎖。
synchronized 是 Java 在語言層面提供的互斥原語,其實 Java 裡面還有很多其他類型的鎖,但作為互斥鎖,原理都是相通的:鎖,一定有一個要鎖定的對象,至于這個鎖定的對象要保護的資源以及在哪裡加鎖 / 解鎖,就屬于設計層面的事情了。
二、如何用一把鎖保護多個資源?
在上文中,我們提到受保護資源和鎖之間合理的關聯關系應該是 N:1 的關系,也就是說可以用一把鎖來保護多個資源,但是不能用多把鎖來保護一個資源。
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 就可以了。
但是用一把鎖有個問題,就是性能太差,會導緻取款、檢視餘額、修改密碼、檢視密碼這四個操作都是串行的。而我們用兩把鎖,取款和修改密碼是可以并行的。用不同的鎖對受保護資源進行精細化管理,能夠提升性能。這種鎖還有個名字,叫細粒度鎖。
2、保護有關聯關系的多個資源
如果多個資源是有關聯關系的,那這個問題就有點複雜了。例如銀行業務裡面的轉賬操作,賬戶 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,就像你不能用自家的鎖來保護别人家的資産,也不能用自己的票來保護别人的座位一樣。
3、使用鎖的正确姿勢
如果用同一把鎖來保護多個資源,也就是現實世界的“包場”,那在程式設計領域應該怎麼“包場”呢?很簡單,隻要我們的鎖能覆寫所有受保護資源就可以了。在上面的例子中,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 還沒來得及發生變化)。是以解決原子性問題,是要保證中間狀态對外不可見。
最後:不能用可變對象做鎖