???????一個或者多個操作在CPU執行的過程中不被中斷的特性,稱為“原子性”。注意,原子性是面向cpu指令級別操作的,而不是面向高級語言操作。
解決原子性問題
???????帶來原子性問題的是線程切換,如果能夠禁用線程切換,那就能夠解決原子性問題。而操作系統做線程切換是依賴CPU中斷的,所以禁止CPU發生中斷就能夠禁止線程切換。這個方案,在單核CPU時代的確是可行的,而且也有很多應用案例,但是并不適合多核CPU場景。
???????在單核CPU場景下,同一時刻只有一個線程執行,禁止CPU中斷,意味著操作系統不會重新調度線程,也就是禁止了線程切換,獲得CPU使用權的線程就可以不間斷地執行,所以兩次寫操作一定是:要么都被執行,要么都沒有被執行,具有原子性。但是在多核場景下,同一時刻,有可能有兩個線程同時在執行,一個線程執行在CPU-1上,一個線程執行在CPU-2上,此時禁止CPU中斷,只能保證CPU上的線程連續執行,并不能保證同一時刻只有一個線程執行,如果這兩個線程同時寫long型變量高32位的話,那就有可能出現bug了。
???????“同一時刻只有一個線程執行”這個條件非常重要,稱之為互斥。如果能夠保證對共享變量的修改是互斥的,那么無論是單核CPU還是多核CPU,就能保證原子性了。
簡易鎖模型
???????說到互斥,一定會想到“鎖”這個方案,如圖:
??????一段需要互斥執行的代碼稱為臨界區。線程在進入臨界區之前,首先嘗試加鎖lock(),如果成功,則進入臨界區,此時稱這個線程持有鎖;否則,就等待,直到持有鎖的線程解鎖;持有鎖的線程執行完臨界區的代碼后,執行解鎖unlock()。但是,存在兩個極容易忽視的問題:鎖的是什么?保護的又是什么?
改進后的鎖模型
???????鎖和鎖要保護的資源是有對應關系的,上面的模型沒有體現出它們之間的對應關系,故需要完善,如圖:
???????首先,要把臨界區要保護的資源標注出來,如圖中臨界區里增加了一個元素:受保護的資源R;其次,要保護資源R就得為它創建一把鎖LR;最后,針對這把鎖LR,需要在進出臨界區時添加上加鎖操作和解鎖操作。另外,在鎖LR和受保護資源之間的關聯關系,如圖中的連線,這個關聯關系非常重要。
Java語言提供的鎖技術:synchronized
???????鎖是一種通用的技術方案,Java語言提供的synchronized關鍵字,就是鎖的一種實現。synchronized關鍵字可以用來修飾方法,也可以用來修飾代碼塊,但是他沒有顯示的加鎖lock()和解鎖unlock()操作,這兩個操作是被Java默默加上的,Java編譯器會在synchronized修飾的方法或者代碼塊前后自動加上加鎖lock()和解鎖unlock(),這樣做的好處就是加鎖lock()和解鎖unlock()一定是成對出現的,畢竟忘記解鎖unlock()可是一個致命的bug。
???????Java中有一條隱式規則:當synchronized修飾靜態方法的時候,鎖定的是當前類的Class對象;當synchronized修飾非靜態方式的時候,鎖定的是當前實例對象this;當synchronized修飾代碼塊的時候,鎖定的是其后括號的對象。
鎖和受保護資源的關系
???????一個合理的受保護資源和鎖之間的關聯關系是N:1。例如如下的代碼:
class SafeCalc {
static long value = 0;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
???????如果仔細觀察,就會發現改動后的代碼是用兩個鎖保護一個資源。這個受保護的資源就是靜態變量value,兩個鎖分別是this和SafeCalc.class。畫一幅圖來描述鎖和受保護資源的關系:
???????從圖中可知,由于臨界區get()和addOne()是用兩個鎖保護的,因此這兩個臨界區沒有互斥關系,臨界區addOne()對value的修改對臨界區get()也沒有可見性,這就導致并發問題了。所以在并發中,一定要注意受保護資源和鎖之間的關聯關系,是N:1的關系,反之,會導致并發問題。同時,在保護多個資源的時候,首先要區分這些資源是否存在關聯關系。
保護沒有關聯關系的多個資源
???????對于那些沒有關聯關系的多個資源,用不同的鎖來保護,解決并發問題。例如:銀行業務中,有針對賬戶余額的取款操作,也有針對賬戶密碼的更改操作,其中余額(balance)是一種資源,賬戶密碼(password)也是一種資源,為它們分配不同的資源即可解決并發問題。
class Accout {
//保護余額的鎖
private final Object balanceLock = new Object;
//賬戶余額
private Integer balance;
//保護賬戶密碼的鎖
private final Object passwordLock = new Object;
//賬戶密碼
private String password;
//取款
void withdraw(Integer amt) {
synchronized(balanceLock) {
if(this.balance > amt) {
this.balance -= amt;
}
}
}
//查看余額
Integer getBalance() {
synchronized(balanceLock) {
return balance;
}
}
//更改密碼
void updatePassword(String password) {
synchronized(passwordLock) {
this.password = password;
}
}
//查看密碼
String getPassword() {
synchronized(passwordLock) {
return password;
}
}
}
???????其實,可以用一把鎖來保護多個資源,例如用this這把鎖來管理賬戶類所有的資源,但是用一把鎖,性能會太差,所有的操作都是串行的。故用不同的鎖對沒有關聯關系的資源進行精細化管理,能夠提升性能,稱之為細粒度鎖。
保護有關聯關系的多個資源
???????銀行的轉賬業務中,涉及到兩個存在關聯關系的賬戶:轉出賬戶A和轉入賬戶B。要解決像這樣多個資源是有關聯關系的并發問題,有點復雜。先把問題代碼化,代碼如下:
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。看示意圖:
???????具體分析一下,假設有A、B、C三個賬戶,余額都是200元,用兩個線程分別執行這兩個轉賬操作:賬戶A轉給賬戶B100元,賬戶B轉給賬戶C100元,期望的結果是A的余額是100元,B的余額是200元,C的余額是300元。假設線程1執行賬戶A轉賬給賬戶B的操作,線程2執行賬戶B轉賬給賬戶C的操作。這個線程分別在兩顆CPU上同時執行,但是它們不互斥,因為線程1鎖定的是賬戶A的實例(A.this),而線程2鎖定的是賬戶B的實例(B.this),所以這兩個線程可以同時進入臨界區transfet(),所以有可能線程1和線程2都會讀到賬戶B的余額是為200,導致最終賬戶B的余額可能是300(線程1后于線程2寫B.balance,線程2寫的B.balance值被線程1覆蓋),也可能是100(線程1先于線程2寫B.balance,線程1寫的B.balance值被線程2覆蓋),就是不可能是200。
使用鎖的正確姿勢
???????鎖和受保護的資源的關聯關系是N:1,即可以用同一把鎖來保護多個資源,代碼上,只要使用的鎖能夠覆蓋所有受保護資源就可以實現。實現的方案還是挺多的:
- 讓所有的對象都持有一個唯一性的對象,但是這個方案缺乏實踐的可行性,因為在創建對象的時候,必須要傳入同一個鎖對象,否則就會出現并發的問題。
- 使用類的class對象,類的class對象是所有類的實例共享的,而且class對象是Java虛擬機在加載類的時候創建的,不要擔心其唯一性。
總結
???????對于如何保護多個資源,關鍵是要分析多個資源之間的關系。如果資源之間沒有關系,每個資源對應一把鎖即可。如果資源之間有關聯關系,就要選擇一個粒度更大的鎖,這個鎖應該能夠覆蓋所有相關的資源。
???????原子性,其實不是不可分割,不可分割只是外在表現,其本質就是多個資源間有一致性的要求,操作的中間狀態對外不可見。所以要解決原子性問題,是要保證中間狀態對外不可見。