上一篇文章 可見性有序性,Happens-before來搞定,解決了併發三大問題中的兩個,今天我們就聊聊如何解決原子性問題
原子性問題的源頭就是 執行緒切換,但在多核 CPU 的大背景下,不允許執行緒切換是不可能的,正所謂「魔高一尺,道高一丈」,新規矩來了:
互斥: 同一時刻只有一個執行緒執行
實際上,上面這句話的意思是: 對共享變數的修改是互斥的,也就是說執行緒 A 修改共享變數時其他執行緒不能修改,這就不存在操作被打斷的問題了,那麼如何實現互斥呢?
鎖
對併發有所瞭解的小夥伴馬上就能想到 鎖 這個概念,並且你的第一反應很可能就是使用 synchronized,這裡列出來你常見的 synchronized 的三種用法:
public class ThreeSync {
private static final Object object = new Object();
public synchronized void normalSyncMethod(){
//臨界區
}
public static synchronized void staticSyncMethod(){
//臨界區
}
public void syncBlockMethod(){
synchronized (object){
//臨界區
}
}
}
複製程式碼
三種 synchronized 鎖的內容有一些差別:
- 對於普通同步方法,鎖的是當前例項物件,通常指 this
- 對於靜態同步方法,鎖的是當前類的 Class 物件,如 ThreeSync.class
- 對於同步方法塊,鎖的是 synchronized 括號內的物件
我特意在三種 synchronized 程式碼裡面新增了「臨界區」字樣的註釋,那什麼是臨界區呢?
臨界區: 我們把需要互斥執行的程式碼看成為臨界區
說到這裡,和大家串的知識都是表層認知,如何用鎖保護有效的臨界區才是關鍵,這直接關係到你是否會寫出併發的 bug,瞭解過本章內容後,你會發現無論是隱式鎖/內建鎖 (synchronized) 還是顯示鎖 (Lock) 的使用都是在找尋這種關係,關係對了,一切就對了,且看
上面鎖的三種方式都可以用下圖來表達:
執行緒進入臨界區之前,嘗試加鎖 lock(), 加鎖成功,則進入臨界區(對共享變數進行修改),持有鎖的執行緒執行完臨界區程式碼後,執行 unlock(),釋放鎖。針對這個模型,大家經常用搶佔廁所坑位來形容:
在學習 Java 早期我就是這樣記憶與理解鎖的,但落實到程式碼上,我們很容易忽略兩點:
- 我們鎖的是什麼?
- 我們保護的又是什麼?
將這兩句話聯合起來就是你的鎖能否對臨界區的資源起到保護的作用?所以我們要將上面的模型進一步細化
現實中,我們都知道自己的鎖來鎖自己需要保護的東西 ,這句話翻譯成你的行動語言之後你已經明確知道了:
- 你鎖的是什麼
- 你保護的資源是什麼
CPU 可不像我們大腦這麼智慧,我們要明確說明我們鎖的是什麼,我們要保護的資源是什麼,它才會用鎖保護我們想要保護的資源(共享變數)
拿上圖來說,資源 R (共享變數) 就是我們要保護的資源,所以我們就要建立資源 R 的鎖來保護資源 R,細心的朋友可能發現上圖幾個問題:
LR 和 R 之間有明確的指向關係 我們編寫程式時,往往腦子中的模型是對的,但是忽略了這個指向關係,導致自己的鎖不能起到保護資源 R 的作用(用別人家的鎖保護自己家的東西或用自己家的鎖保護別人家的東西),最終引發併發 bug,所以在你勾畫草圖時,要明確找到這個關係
左圖 LR 虛線指向了非共享變數 我們寫程式的時候很容易這麼做,不確定哪個是要保護的資源,直接大雜燴,用 LR 將要保護的資源 R 和沒必要保護的非共享變數一起保護起來了,舉兩個例子來說你就明白這麼做的壞處了
- 編寫序列程式時,是不建議 try...catch 整個方法的,這樣如果出現問是很難定位的,道理一樣,我們要用鎖精確的鎖住我們要保護的資源就夠了,其他無意義的資源是不要鎖的
- 鎖保護的東西越多,臨界區就越大,一個執行緒從走入臨界區到走出臨界區的時間就越長,這就讓其他執行緒等待的時間越久,這樣併發的效率就有所下降,其實這是涉及到鎖粒度的問題,後續也都會做相關說明
作為程式猿還是簡單拿程式碼說明一下心裡比較踏實,且看:
public class ValidLock {
private static final Object object = new Object();
private int count;
public synchronized void badSync(){
//其他與共享變數count無關的業務邏輯
count++;
}
public void goodSync(){
//其他與共享變數count無關的業務邏輯
synchronized (object){
count++;
}
}
}
複製程式碼
這裡並不是說 synchronized 放在方法上不好,只是提醒大家用合適的鎖的粒度才會更高效
在計數器程式例子中,我們會經常這麼寫:
public class SafeCounter {
private int count;
public synchronized void counter(){
count++;
}
public synchronized int getCount(){
return count;
}
}
複製程式碼
下圖就是上面程式的模型展示:
這裡我們鎖的是 this,可以保護 this.count。但有些同學認為 getCount 方法沒必要加 synchronized 關鍵字,因為是讀的操作,不會對共享變數做修改,如果不加上 synchronized 關鍵字,就違背了我們上一篇文章 happens-before 規則中的監視器鎖規則:
對一個鎖的解鎖 happens-before 於隨後對這個鎖的加鎖 也就是說對 count 的寫很可能對 count 的讀不可見,也就導致髒讀
上面我們看到一個 this 鎖是可以保護多個資源的,那用多個不同的鎖保護一個資源可以嗎?來看一段程式:
public class UnsafeCounter {
private static int count;
public synchronized void counter(){
count++;
}
public static synchronized int calc(){
return count++;
}
}
複製程式碼
睜大眼睛仔細看,一個鎖的是 this,一個鎖的是 UnsafeCounter.class, 他們都想保護共享變數 count,你覺得如何?下圖就是行面程式的模型展示:
原子性,多鎖一個資源.png
兩個臨界區是用兩個不同的鎖來保護的,所以臨界區沒有互斥關係,也就不能保護 count,所以這樣加鎖是無意義的
總結
- 解決原子性問題,就是要互斥,就是要保證中間狀態對外不可見
- 鎖是解決原子性問題的關鍵,明確知道我們鎖的是什麼,要保護的資源是什麼,更重要的要知道你的鎖能否保護這個受保護的資源(圖中的箭頭指向)
- 有效的臨界區是一個入口和一個出口,多個臨界區保護一個資源,也就是一個資源有多個並行的入口和多個出口,這就沒有起到互斥的保護作用,臨界區形同虛設
- 鎖自己家門能保護資源就沒必要鎖整個小區,如果鎖了整個小區,這嚴重影響其他業主的活動(鎖粒度的問題)
本文以 synchronized 鎖舉例來說明如何解決原子性問題,主要是幫助大家建立巨集觀的理念,用於解決原子性問題,這樣後續你看到無論什麼鎖,只要腦海中回想起本節說明的模型,你會發現都是換湯不換藥,學習起來就非常輕鬆了.
到這裡併發的三大問題 有序性,可見性,原子性都有了解決方案,這是遠看併發,讓大家有了巨集觀的概念;但面試和實戰都是講求細節的,接下來我們由遠及近,逐步看併發的細節,順帶說明那些面試官經常會問到的問題
更多資訊請訪問個人部落格:dayarch.top/
靈魂追問
- 多個鎖鎖一個資源一定會有問題嗎?
- 什麼時候需要鎖小區,而不能鎖某一戶呢?
- 銀行轉賬,兩人互轉和別人給自己轉,用什麼樣的鎖粒度合適呢?
提高效率工具
推薦閱讀
- 這次走進併發的世界,請不要錯過
- 學併發程式設計,透徹理解這三個核心是關鍵
- 併發Bug之源有三,請睜大眼睛看清它們
- 可見性有序性,Happens-before來搞定
- 基礎面試,為什麼面試官總喜歡問String?
歡迎持續關注公眾號:「日拱一兵」
- 前沿 Java 技術乾貨分享
- 高效工具彙總 | 回覆「工具」
- 面試問題分析與解答
- 技術資料領取 | 回覆「資料」
以讀偵探小說思維輕鬆趣味學習 Java 技術棧相關知識,本著將複雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注......