在之前的《并發程式設計學習筆記之二中并發問題的源頭》了解到引發原子性問題主要是線程切換。在Java中一行代碼最後可能會被翻譯成多條計算機指令,更何況是代碼塊或是方法。一個或者多個操作在CPU中執行不被中斷,稱之其具有原子性。
線程切換依賴的是CPU中斷。在單核CPU,阻止CPU中斷是一間相對可行的事,隻要保證同一時刻隻有一個線程執行就可以。不過到了多核CPU下,禁止中斷CPU隻能保證CPU上的線程同時執行,但是并不能保證同一時刻隻有一條線程執行。那麼還有如何保證同一時刻隻有一條線程去修改共享變量(也就是互斥)的有效方法嗎?
簡易鎖模型

如圖就是一個簡易鎖模型,藍色的部分就是臨界區也就是每個線程間互斥的部分(也是受保護的資源)。線程在進入臨界區之前進行加鎖lock()操作,持有鎖的線程執行臨界區代碼後會進行解鎖unlock()操作。這就是最簡單的模型。就好像停車,當有空車位的時候,你可以把車停上去,也就是獲得這個車位的鎖。車位就是臨界區,在這段時間别的車是無法停靠到你的車位的,當你車子開走的時候的,也就是釋放鎖的過程,其他車可以停到這個車位了。
指定目标的鎖
加鎖可以解決并發問題,但是前提是加對鎖,鎖對資源。就比如在小區,把車停到别人家車庫,那是肯定不行的。那把上面的簡易鎖模型改進下就是:
這樣是不是就明确了是自家鎖,去鎖自家的資源了。首先,我們要把臨界區要保護的資源标注出來,如圖中臨界區裡增加了一個元素:受保護的資源 R;其次,我們要保護資源 R 就得為它建立一把鎖 R;最後,針對這把鎖 R,我們還需在進出臨界區時添上加鎖操作和解鎖操作。另外,在鎖 R 和受保護資源之間,我特地用一條線做了關聯,這個關聯關系非常重要。
鎖在代碼中的展現:
鎖是一種通用的技術,在Java中synchronized就是一種鎖的展現,synchronized可以修飾靜态方法,非靜态方法,代碼塊等。
代碼事例:
class X {
// 修飾非靜态方法
synchronized void foo() {
// 臨界區
}
// 修飾靜态方法
synchronized static void bar() {
// 臨界區
}
// 修飾代碼塊
Object obj = new Object();
void baz() {
synchronized(obj) {
// 臨界區
}
}
}
synchronized的加鎖和解鎖unlock的動作是被java預設加上的,是為了操作的一把鎖。而對于鎖定的對象,synchronized也有預設的規則:
- 當修飾靜态方法的時候,鎖定的是目前類的 Class 對象,在上面的例子中就是 Class X;
- 當修飾非靜态方法的時候,鎖定的是目前執行個體對象 this。
還記得count+=1的原子性問題嗎,現在有了鎖就迎刃而解了。代碼如下:
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
這裡加鎖的本意是任意時刻隻有一條線程進去臨界區。是以此時無論是一個CPU還是多個CPU通路addOne方法執行10000次,得到的最count的值都是10000。但是還有一個問題就是get擷取最終的值會是10000嗎?這個還真不一定。回想下鎖的Happens-Before原則,“對一個鎖解鎖 Happens-Before 後續對這個鎖的加鎖”,指的是前一個線程的解鎖操作對後一個線程的加鎖操作可見,綜合 Happens-Before 的傳遞性原則,我們就能得出前一個線程在臨界區修改的共享變量(該操作在解鎖之前),對後續進入臨界區(該操作在加鎖之後)的線程是可見的。而get方法沒有加鎖,是以不滿意Happens-Before原則,是以get方法擷取最終值不一定是10000。當然解決的方式也是給get方法加鎖。此時應該你應該注意到鎖和資源應該是有一定關系的。
鎖與資源的關系
鎖與資源的關系可以是一對多,也就是一把鎖可以鎖多個資源。但是不能多把鎖去一個資源。比如我們去看球賽,一張門票可以免費停車,那麼就是一張門票可以鎖定一個座位和一個車位。不可以出現兩張重複的票去鎖定一個座位或者車位。同時還有一個問題,鎖是不可變得。你不能用昨天票再來看今天的球賽,也不能用其他羽毛球比賽的票來看NBA的比賽。
如何用一把鎖鎖定多個資源
一把鎖保護多個沒有關聯關系的資源可以解決并發問題,也很符合我們的慣性思維。但是所有操作都是串行化的話,就會産生性能方面的問題,估計你自己也接受不了。最佳的解決方法就是将鎖細化也就是細粒度鎖。用不同的鎖對受保護資源進行精細化管理。來看下銀行賬戶類的例子:
在一個賬戶類下有餘額和密碼兩個成員變量。取款和查詢餘額操作餘額這個資源;修改密碼和檢視密碼是操作的密碼這個資源。兩個資源沒有絕對的關聯關系。我們用一個final 對象 balLock 作為鎖鎖定餘額,用一個 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;
}
}
}
取款,查詢餘額和檢視密碼,修改密碼用同一把鎖來,四個操作隻能串行化,同一時刻隻能執行一個操作,而現在不同的鎖鎖不同的資源,操作密碼和操作餘額操作是可以并行的,效率大大提高,資源管理更細化,性能得到提高。總結一下就是:不相關的資源,用不同的鎖去保護。
用鎖來保護有關聯關系的資源
什麼是有關聯關系的資源,拿銀行的轉賬業務舉個例子,賬戶A給賬戶B轉100元,賬戶A減少100元,賬戶B增加100元,兩個賬戶是有關聯關系的。代碼示例如下:
class Account {
private int balance;
// 轉賬
synchronized void transfer(Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
聲明一個賬戶類,再聲明一個賬戶類的成員方法餘額,一個被鎖修飾的轉賬方法。此時synchronized鎖住的是兩個資源一個是目前賬戶,一個目标賬戶。看起來貌似沒有什麼問題。臨界區的兩個資源被同一把鎖住。但是問題就是出在這把鎖上。在非靜态方法中,synchronized鎖住的是目前對象this,也就是目前賬戶,那麼目标同時給其他轉賬時,那目标賬戶讀到的值一定就是最新的嗎?
再來具體分析下:兩條線程同時進行轉賬操作,涉及 A、B、C三個賬戶都是200元。線程1由A賬戶給B賬戶轉賬100元,線程2由賬戶B給賬戶C轉賬100元。理想情況是A賬戶最終是100元,B賬戶是200元,C賬戶是300元。實際可能并不是這樣的。
假設線程1和2在兩個CPU上執行,那麼線程1和線程2都可以進去臨界區,因為線程1鎖的賬戶A,線程2鎖的的賬戶B。那麼就有可能線程1和2讀到賬戶B的餘額都是200元。假如線程1先于線程2寫balance,線程1寫的balance會被線程2寫的balance覆寫,那賬戶B的餘額就是100元;如果線程2先于線程1寫balance,線程2寫的balance會被線程1的balance覆寫,那麼賬戶B的餘額會是300元,就是不可能是200元。流程圖如下:
上面發生的問題是每個資源都是自己的鎖鎖自己的資源。這對于有關聯的資源顯然是行不通的。那麼該如何用一把鎖去鎖住兩個資源呢?
沒錯,可以建立一個對象,讓所有對象都總有一個唯一的對象,在建立賬戶的時候傳入。這樣就可以保證多個資源共享一把鎖。不過這裡有一個明顯的弊端就是每建立一個賬戶對象就需要把這個鎖傳入,這實施起來就很有難度了,畢竟在實施可能是多個工程下,這樣把保證傳入的是一個lock鎖對象,想想都覺得頭疼。同時,如果傳入的不是一個鎖對象的話,那問題就更嚴重了。那還有沒有更優的做法?這個确實有。代碼如下:
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 對象共享的,而且這個對象是 Java 虛拟機在加載 Account 類的時候建立的,是以不用擔心它的唯一性。問題就這樣解決了。流程圖如下:
引申:轉賬的操作其實就是為了保證轉賬過程“原子性”的特征,隻不過這裡的原子性是面向Java的,而JMM中的原子性是面向CPU指令的。而原子性的本質就是其實不是不可分割,不可分割隻是外在表現,其本質是多個資源間有一緻性的要求,操作的中間狀态對外不可見。解決原子性的本質就是保證中間狀态對外不可見。
死鎖問題是如何産生的?
在上面的銀行轉賬的例子中,共享鎖的問題雖然解決了一把鎖保護多個資源的并發問題,但是也産生了轉賬串行化的問題。在現實生活中,賬戶A向賬戶B轉賬,賬戶B向賬戶C轉賬的操作本來是可以并行的,但是共享鎖卻把他們串行化了,這麼差的性能怎麼能夠接受?是以上面的例子是脫離實際的。不過在這之前貌似有一種将鎖細化的得方法能解決串行化的問題,話不多說,
上代碼:
class Account {
private int balance;
// 轉賬
void transfer(Account target, int amt){
// 鎖定轉出賬戶
synchronized(this) {
// 鎖定轉入賬戶
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
細粒度鎖解決了串行化的問題,但是這樣我們寫付出了一定的代碼,上述代碼執行過程中會遇到這樣的問題:
假設兩個線程同時進行轉賬操作,線程1執行賬戶A向賬戶B轉賬,線程2執行賬戶B向賬戶A轉賬。此時兩個線程可進去transfer擷取到轉出賬戶的鎖,線程1獲得賬戶A的鎖,線程2獲得賬戶B的鎖。兩條線程繼續執行擷取轉入賬戶的鎖,問題就來了,線程1需要擷取賬戶B的鎖,可是線程 2還沒有釋放,是以就進入等待狀态(synchronized不會主動釋放鎖);而線程2需要擷取賬戶A的鎖,而賬戶A的鎖還在被線程1持有,是以也進去等待嘗試階段。最終結果就是兩條線程擷取不到想要的鎖,就會死等下去,這也就是死鎖。
産生死鎖,一直等待下下去,CPU資源占用會飙升,直到拖垮整個應用。最簡單的方式就是重新開機應用,可這不能避免下次死鎖的産生。死鎖有沒有發生的必要條件,還真有,大牛,Coffman 早就總結過了,隻有以下這四個條件都發生時才會出現死鎖:
- 互斥,共享資源 X 和 Y 隻能被一個線程占用;
- 占有且等待,線程 1 已經取得共享資源 X,在等待共享資源 Y 的時候,不釋放共享資源 X;
- 不可搶占,其他線程不能強行搶占線程 1 占有的資源;
- 循環等待,線程 1 等待線程 2占有的資源,線程 2等待線程 1占有的資源,就是循環等待。
在已經産生死鎖的四個必須條件,那麼隻需要破壞掉其中一個條件死鎖也不會發生了:
用鎖就是為了互斥,是以互斥的條件是破壞不了的,那麼其他三個條件呢:
- 對于“占用且等待”這個條件,我們可以一次性申請所有的資源,這樣就不存在等待了。在代碼中也就是一次獲得賬戶A和賬戶B
- 對于“不可搶占”這個條件,占用部分資源的線程進一步申請其他資源時,如果申請不到,可以主動釋放它占有的資源,這樣不可搶占這個條件就破壞掉了。對應代碼中,如果線程1執行,擷取不到賬戶B,那就釋放掉賬戶A;
- 對于“循環等待”這個條件,可以靠按序申請資源來預防。所謂按序申請,是指資源是有線性順序的,申請的時候可以先申請資源序号小的,再申請資源序号大的,這樣線性化後自然就不存在循環了。
那麼如何優化銀行賬戶的例子呢,先來看如何破壞“占用且等待”的條件。要保證一次擷取全部資源,重新定義一個類Alocator,聲明一個全局的list用來存儲賬戶資源。方法apply用來存儲資源,free方法用來歸還資源。在transfer方法之前先嘗試擷取資源,如果擷取全部資源再進行轉賬操作,轉賬結束釋放資源。要注意的是Alocator必須是單例,也隻能是單例。代碼如下:
class Allocator {
private List<Object> als =new ArrayList<Object>();
// 一次性申請所有資源
synchronized boolean apply(Object from, Object to){
if(als.contains(from) ||
als.contains(to)){
return false;
} else {
als.add(from);
als.add(to);
}
return true;
}
// 歸還資源
synchronized void free(Object from, Object to){
als.remove(from);
als.remove(to);
}
}
class Account {
// actr 應該為單例
private Allocator actr;
private int balance;
// 轉賬
void transfer(Account target, int amt){
// 一次性申請轉出賬戶和轉入賬戶,直到成功
while(!actr.apply(this, target))
;
try{
// 鎖定轉出賬戶
synchronized(this){
// 鎖定轉入賬戶
synchronized(target){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
} finally {
actr.free(this, target)
}
}
}
如何破壞不可搶占資源?synchronized擷取不到資源的時候會進入阻塞狀态,不會釋放已鎖定的資源。隻有sdk中的lock鎖來替換下。
破壞這個條件,需要對資源進行排序,然後按序申請資源。這個實作非常簡單,我們假設每個賬戶都有不同的屬性 id,這個 id 可以作為排序字段,申請的時候,我們可以按照從小到大的順序來申請。比如下面代碼中,①~⑥處的代碼對轉出賬戶(this)和轉入賬戶(target)排序,然後按照序号從小到大的順序鎖定賬戶。這樣就不存在“循環”等待了。
class Account {
private int id;
private int balance;
// 轉賬
void transfer(Account target, int amt){
Account left = this ①
Account right = target; ②
if (this.id > target.id) { ③
left = target; ④
right = this; ⑤
} ⑥
// 鎖定序号小的賬戶
synchronized(left){
// 鎖定序号大的賬戶
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
以上的形式都是用自己
引申:到底互斥鎖的是什麼?
在上提到每一個對象都可以作為鎖:
- 對于普通同步方法,鎖是目前執行個體對象。
- 對于靜态同步方法,鎖是目前類的Class對象。
- 對于同步方法塊,鎖是Synchonized括号裡配置的對象
從JVM規範中Synchonized在JVM裡的實作原理,JVM基于進入和退出Monitor對象來實作方法同步和代碼塊同步,但兩者的實作細節不一樣。代碼塊同步是使用monitorenter和monitorexit指令實作的,而方法同步是使用另外一種方式實作的,細節在JVM規範裡并沒有詳細說明。但是,方法的同步同樣可以使用這兩個指令來實作。
monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它将處于鎖定狀态。線程執行到monitorenter指令時,将會嘗試擷取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。
synchronized用的鎖是存在Java對象頭裡的。如果對象是數組類型,則虛拟機用3個字寬(Word)存儲對象頭,如果對象是非數組類型,則用2字寬存儲對象頭。在32位虛拟機中,1字寬等于4位元組,即32bit。Java對象頭裡的Mark Word裡預設存儲對象的HashCode、分代年齡和鎖标記位。