一個(gè)或者多個(gè)操作在 CPU 執(zhí)行的過程中不被中斷的特性仔粥,稱為“原子性”杜秸。
那并發(fā)下的原子性問題到底該如何解決呢见秤?
原子性問題的源頭是線程切換幔欧,如果能夠禁用線程切換那不就能解決這個(gè)問題了嗎?而操作系統(tǒng)做線程切換是依賴 CPU 中斷的娜遵,所以禁止 CPU 發(fā)生中斷就能夠禁止線程切換蜕衡。
在早期單核 CPU 時(shí)代友存,這個(gè)方案的確是可行的特铝,而且也有很多應(yīng)用案例,但是并不適合多核場(chǎng)景晒骇。
例如纳胧,在32 位 CPU 上執(zhí)行 long 型變量的寫操作镰吆。long 型變量是 64 位,在 32 位 CPU 上執(zhí)行寫操作會(huì)被拆分成兩次寫操作(寫高 32 位和寫低 32 位跑慕,如下圖所示)万皿。
在單核 CPU 場(chǎng)景下摧找,同一時(shí)刻只有一個(gè)線程執(zhí)行,禁止 CPU 中斷牢硅,意味著操作系統(tǒng)不會(huì)重新調(diào)度線程蹬耘,也就是禁止了線程切換,獲得 CPU 使用權(quán)的線程就可以不間斷地執(zhí)行减余,所以兩次寫操作一定是:要么都被執(zhí)行综苔,要么都沒有被執(zhí)行,具有原子性位岔。
但是在多核場(chǎng)景下如筛,同一時(shí)刻,有可能有兩個(gè)線程同時(shí)在執(zhí)行抒抬,一個(gè)線程執(zhí)行在 CPU-1 上妙黍,一個(gè)線程執(zhí)行在 CPU-2 上,此時(shí)禁止 CPU 中斷瞧剖,只能保證 CPU 上的線程連續(xù)執(zhí)行拭嫁,并不能保證同一時(shí)刻只有一個(gè)線程執(zhí)行,如果這兩個(gè)線程同時(shí)寫 long 型變量高 32 位的話抓于,那就有可能出詭異 Bug 了做粤。
“同一時(shí)刻只有一個(gè)線程執(zhí)行”這個(gè)條件非常重要,我們稱之為互斥捉撮。如果我們能夠保證對(duì)共享變量的修改是互斥的怕品,那么,無論是單核 CPU 還是多核 CPU巾遭,就都能保證原子性了肉康。
簡(jiǎn)易鎖模型
互斥,很容易想到了那個(gè)殺手級(jí)解決方案:鎖灼舍。如同以下模型:
我們把一段需要互斥執(zhí)行的代碼稱為臨界區(qū)吼和。線程在進(jìn)入臨界區(qū)之前,首先嘗試加鎖 lock()骑素,如果成功炫乓,則進(jìn)入臨界區(qū),此時(shí)稱這個(gè)線程持有鎖献丑;否則呢就等待末捣,直到持有鎖的線程解鎖;持有鎖的線程執(zhí)行完臨界區(qū)的代碼后创橄,執(zhí)行解鎖 unlock()箩做。
這個(gè)過程非常像辦公室里高峰期搶占坑位,每個(gè)人都是進(jìn)坑鎖門(加鎖)妥畏,出坑開門(解鎖)邦邦,如廁這個(gè)事就是臨界區(qū)安吁。
但這樣理解很容易忽視兩個(gè)非常非常重要的點(diǎn):鎖的是什么?保護(hù)的又是什么圃酵?
改進(jìn)后的鎖模型
在現(xiàn)實(shí)世界里柳畔,鎖和鎖要保護(hù)的資源是有對(duì)應(yīng)關(guān)系的馍管,比如你用你家的鎖保護(hù)你家的東西郭赐,我用我家的鎖保護(hù)我家的東西。
在并發(fā)編程世界里确沸,鎖和資源也應(yīng)該有這個(gè)關(guān)系捌锭,但這個(gè)關(guān)系在上面的模型中是沒有體現(xiàn)的,所以需要完善一下模型罗捎。
首先要把臨界區(qū)要保護(hù)的資源標(biāo)注出來观谦,如圖中臨界區(qū)里增加了一個(gè)元素:受保護(hù)的資源 R;其次桨菜,要保護(hù)資源 R 就得為它創(chuàng)建一把鎖 LR豁状;最后,針對(duì)這把鎖 LR倒得,還需在進(jìn)出臨界區(qū)時(shí)添上加鎖操作和解鎖操作泻红。
另外,在鎖 LR 和受保護(hù)資源之間霞掺,有一條線做關(guān)聯(lián)谊路,這個(gè)關(guān)聯(lián)關(guān)系非常重要。很多并發(fā) Bug 的出現(xiàn)都是因?yàn)榘阉雎粤似斜颍缓缶统霈F(xiàn)了類似鎖自家門來保護(hù)他家資產(chǎn)的事情缠劝,這樣的 Bug 非常不好診斷。
Java 語言提供的鎖技術(shù):synchronized
鎖是一種通用的技術(shù)方案骗灶,Java 語言提供的 synchronized 關(guān)鍵字惨恭,就是鎖的一種實(shí)現(xiàn)。synchronized 關(guān)鍵字可以用來修飾方法耙旦,也可以用來修飾代碼塊喉恋,它的使用示例基本上都是下面這個(gè)樣子:
class X {
? // 修飾非靜態(tài)方法
? synchronized void foo() {
? ? // 臨界區(qū)
? }
? // 修飾靜態(tài)方法
? synchronized static void bar() {
? ? // 臨界區(qū)
? }
? // 修飾代碼塊
? Object obj = new Object();
? void baz() {
? ? synchronized(obj) {
? ? ? // 臨界區(qū)
? ? }
? }
}?
咋一看這個(gè)和我們上面提到的模型有點(diǎn)對(duì)不上母廷,加鎖 lock() 和解鎖 unlock() 在哪里呢轻黑?其實(shí)這兩個(gè)操作都是有的,只是這兩個(gè)操作是被 Java 默默加上的琴昆,Java 編譯器會(huì)在 synchronized 修飾的方法或代碼塊前后自動(dòng)加上加鎖 lock() 和解鎖 unlock()氓鄙,這樣做的好處就是加鎖 lock() 和解鎖 unlock() 一定是成對(duì)出現(xiàn)的,畢竟忘記解鎖 unlock() 可是個(gè)致命的 Bug(意味著其他線程只能死等下去了)业舍。
那 synchronized 里的加鎖 lock() 和解鎖 unlock() 鎖定的對(duì)象在哪里呢抖拦?上面的代碼我們看到只有修飾代碼塊的時(shí)候升酣,鎖定了一個(gè) obj 對(duì)象,那修飾方法的時(shí)候鎖定的是什么呢态罪?這個(gè)也是 Java 的一條隱式規(guī)則:
當(dāng)修飾靜態(tài)方法的時(shí)候噩茄,鎖定的是當(dāng)前類的 Class 對(duì)象,在上面的例子中就是 Class X复颈;
當(dāng)修飾非靜態(tài)方法的時(shí)候绩聘,鎖定的是當(dāng)前實(shí)例對(duì)象 this。
對(duì)于上面的例子耗啦,synchronized 修飾靜態(tài)方法相當(dāng)于:
class X {
? // 修飾靜態(tài)方法
? synchronized(X.class) static void bar() {
? ? // 臨界區(qū)
? }
}
修飾非靜態(tài)方法凿菩,相當(dāng)于:
class X {
? // 修飾非靜態(tài)方法
? synchronized(this) void foo() {
? ? // 臨界區(qū)
? }
}
用 synchronized 解決 count+=1 問題
count+=1 存在的并發(fā)問題,現(xiàn)在可以嘗試用 synchronized 來解決帜讲,代碼如下所示衅谷。SafeCalc 這個(gè)類有兩個(gè)方法:一個(gè)是 get() 方法,用來獲得 value 的值似将;另一個(gè)是 addOne() 方法获黔,用來給 value 加 1,并且 addOne() 方法我們用 synchronized 修飾在验。
class SafeCalc {
? long value = 0L;
? long get() {
? ? return value;
? }
? synchronized void addOne() {
? ? value += 1;
? }
}
對(duì)于addOne() 方法玷氏,首先可以肯定,被 synchronized 修飾后译红,無論是單核 CPU 還是多核 CPU预茄,只有一個(gè)線程能夠執(zhí)行 addOne() 方法,所以一定能保證原子操作侦厚,那是否有可見性問題呢耻陕?
管程中鎖的規(guī)則:對(duì)一個(gè)鎖的解鎖 Happens-Before 于后續(xù)對(duì)這個(gè)鎖的加鎖。
管程刨沦,就是我們這里的 synchronized诗宣, synchronized 修飾的臨界區(qū)是互斥的,也就是說同一時(shí)刻只有一個(gè)線程執(zhí)行臨界區(qū)的代碼想诅;而所謂“對(duì)一個(gè)鎖解鎖 Happens-Before 后續(xù)對(duì)這個(gè)鎖的加鎖”召庞,指的是前一個(gè)線程的解鎖操作對(duì)后一個(gè)線程的加鎖操作可見,綜合 Happens-Before 的傳遞性原則来破,我們就能得出前一個(gè)線程在臨界區(qū)修改的共享變量(該操作在解鎖之前)篮灼,對(duì)后續(xù)進(jìn)入臨界區(qū)(該操作在加鎖之后)的線程是可見的。
按照這個(gè)規(guī)則徘禁,如果多個(gè)線程同時(shí)執(zhí)行 addOne() 方法诅诱,可見性是可以保證的,也就說如果有 1000 個(gè)線程執(zhí)行 addOne() 方法送朱,最終結(jié)果一定是 value 的值增加了 1000娘荡。
但執(zhí)行 addOne() 方法后干旁,value 的值對(duì) get() 方法是可見的嗎?這個(gè)可見性是沒法保證的炮沐。管程中鎖的規(guī)則争群,是只保證后續(xù)對(duì)這個(gè)鎖的加鎖的可見性,而 get() 方法并沒有加鎖操作大年,所以可見性沒法保證换薄。那如何解決呢?很簡(jiǎn)單鲜戒,就是 get() 方法也 synchronized 一下专控,完整的代碼如下所示抹凳。
class SafeCalc {
? long value = 0L;
? synchronized long get() {
? ? return value;
? }
? synchronized void addOne() {
? ? value += 1;
? }
}
上面的代碼轉(zhuǎn)換為我們提到的鎖模型遏餐,就是下面圖示這個(gè)樣子。get() 方法和 addOne() 方法都需要訪問 value 這個(gè)受保護(hù)的資源赢底,這個(gè)資源用 this 這把鎖來保護(hù)失都。線程要進(jìn)入臨界區(qū) get() 和 addOne(),必須先獲得 this 這把鎖幸冻,這樣 get() 和 addOne() 也是互斥的粹庞。
這個(gè)模型更像現(xiàn)實(shí)世界里面球賽門票的管理,一個(gè)座位只允許一個(gè)人使用洽损,這個(gè)座位就是“受保護(hù)資源”庞溜,球場(chǎng)的入口就是 Java 類里的方法,而門票就是用來保護(hù)資源的“鎖”碑定,Java 里的檢票工作是由 synchronized 解決的流码。
鎖和受保護(hù)資源的關(guān)系
受保護(hù)資源和鎖之間的關(guān)聯(lián)關(guān)系非常重要,他們的關(guān)系是怎樣的呢延刘?一個(gè)合理的關(guān)系是:受保護(hù)資源和鎖之間的關(guān)聯(lián)關(guān)系是 N:1 的關(guān)系漫试。還拿前面球賽門票的管理來類比,就是一個(gè)座位碘赖,我們只能用一張票來保護(hù)驾荣,如果多發(fā)了重復(fù)的票,那就要打架了∑张荩現(xiàn)實(shí)世界里播掷,我們可以用多把鎖來保護(hù)同一個(gè)資源,但在并發(fā)領(lǐng)域是不行的撼班,并發(fā)領(lǐng)域的鎖和現(xiàn)實(shí)世界的鎖不是完全匹配的歧匈。不過倒是可以用同一把鎖來保護(hù)多個(gè)資源,這個(gè)對(duì)應(yīng)到現(xiàn)實(shí)世界就是我們所謂的“包場(chǎng)”了权烧。
上面那個(gè)例子如果稍作改動(dòng)眯亦,把 value 改成靜態(tài)變量伤溉,把 addOne() 方法改成靜態(tài)方法,此時(shí) get() 方法和 addOne() 方法是否存在并發(fā)問題呢妻率?
class SafeCalc {
? static long value = 0L;
? synchronized long get() {
? ? return value;
? }
? synchronized static void addOne() {
? ? value += 1;
? }
}
如果仔細(xì)觀察乱顾,就會(huì)發(fā)現(xiàn)改動(dòng)后的代碼是用兩個(gè)鎖保護(hù)一個(gè)資源。這個(gè)受保護(hù)的資源就是靜態(tài)變量 value宫静,兩個(gè)鎖分別是 this 和 SafeCalc.class走净。我們可以用下面這幅圖來形象描述這個(gè)關(guān)系。由于臨界區(qū) get() 和 addOne() 是用兩個(gè)鎖保護(hù)的孤里,因此這兩個(gè)臨界區(qū)沒有互斥關(guān)系伏伯,臨界區(qū) addOne() 對(duì) value 的修改對(duì)臨界區(qū) get() 也沒有可見性保證,這就導(dǎo)致并發(fā)問題了捌袜。
總結(jié)
互斥鎖说搅,在并發(fā)領(lǐng)域的知名度極高,只要有了并發(fā)問題虏等,大家首先容易想到的就是加鎖弄唧,因?yàn)榇蠹叶贾溃渔i能夠保證執(zhí)行臨界區(qū)代碼的互斥性霍衫。這樣理解雖然正確候引,但是卻不能夠指導(dǎo)你真正用好互斥鎖。臨界區(qū)的代碼是操作受保護(hù)資源的路徑敦跌,類似于球場(chǎng)的入口澄干,入口一定要檢票,也就是要加鎖柠傍,但不是隨便一把鎖都能有效麸俘。所以必須深入分析鎖定的對(duì)象和受保護(hù)資源的關(guān)系,綜合考慮受保護(hù)資源的訪問路徑携兵,多方面考量才能用好互斥鎖疾掰。
synchronized 是 Java 在語言層面提供的互斥原語,其實(shí) Java 里面還有很多其他類型的鎖徐紧,但作為互斥鎖静檬,原理都是相通的:鎖,一定有一個(gè)要鎖定的對(duì)象并级,至于這個(gè)鎖定的對(duì)象要保護(hù)的資源以及在哪里加鎖 / 解鎖拂檩,就屬于設(shè)計(jì)層面的事情了。