在上一篇中我們提到洋满,一個或者多個操作在CPU 執(zhí)行過程中不被中斷的特性踩身,稱為“原子性”稍浆。 理解這個特性有助于你分析并發(fā)編程Bug 出現(xiàn)的原因互艾, 例如可以利用它分析出long 型變量在 32 位機器上讀寫可能出現(xiàn)的詭異 Bug试和, 明明已經(jīng)把變量成功寫入了內(nèi)存,重新讀出來卻不是自己寫入的纫普。
那原子性問題到底該如何解決呢阅悍?
你已經(jīng)知道,原子性問題的源頭是線程切換昨稼,如果能夠禁用線程切換不是就能夠解決這個問題了嘛?而操作系統(tǒng)做線程切換是依賴CPU 中斷的节视,所以禁止CPU 中斷就能夠禁止線程切換。
在早期單核CPU 時代假栓,這個 方案的卻是可行的寻行,而且 有很多應用案例,但是并不適合多核場景匾荆。這里我們以 32 位 CPU 上執(zhí)行 long 型變量的寫操作為例來說明這個問題拌蜘,long 型變量是 64 位,在 32 位 CPU 上執(zhí)行寫操作會被拆分成兩次寫操作(寫高 32 位和寫低 32 位牙丽,如下圖所示)简卧。
在單核CPU 場景下,同一時刻只有一個線程執(zhí)行烤芦,禁止CPU 中斷举娩,意味著操作系統(tǒng)不會重新調(diào)度線程,也就是禁止了線程切換,也就是CPU 使用權(quán)的線程可以不間斷的執(zhí)行铜涉,所以兩次寫操作一定是:要么被執(zhí)行智玻,要么都沒有被執(zhí)行,具有原子性骄噪。
但是在多核場景下尚困,同一時刻蠢箩,有可能有兩個線程在同時執(zhí)行链蕊,一個線程執(zhí)行在CPU-1 上,一個線程執(zhí)行在CPU-2 上谬泌, 此時禁止CPU 中斷滔韵, 只須保證CPU 上的線程連續(xù)執(zhí)行, 并不能保證同一時刻只有一個線程執(zhí)行掌实,如果這兩個線程同時寫long 型變量高 32 位的話陪蜻,那就有可能出現(xiàn)我們開頭提及的詭異 Bug 了。
“同一時刻只有一個線程執(zhí)行” 這個條件非常重要贱鼻,我們稱之為互斥宴卖,如果我們能保證對共享變量的修改是互斥的,那么邻悬,無論是單核CPU 還是多核CPU 症昏,就能保證原子性了。
簡易鎖模型
當談到互斥父丰,相信聰明的你一定想到了那個殺手級解決方案:鎖肝谭,同時大腦中還會出現(xiàn)以下模型:
我們把一段需要互斥執(zhí)行的代碼稱為臨界區(qū)。線程在進入臨界區(qū)前蛾扇,首先嘗試加鎖如果成功攘烛,則進入臨界區(qū),此時我們稱為這個線程持有鎖镀首;否則就等待坟漱,直到持有鎖的線程解鎖,持有鎖的線程執(zhí)行完臨界區(qū)代碼后更哄,執(zhí)行解鎖unlock()芋齿。
這個 過程非常像辦公室高峰搶占坑位,每個人都是進坑鎖門(加鎖)竖瘾,出坑開門(解鎖)沟突,如廁這個事就是臨界區(qū)。很長時間里 我也事這么理解的捕传,這樣理解本身沒有問題惠拭,但卻很容易讓我們忽視兩個非常重要的點,我們的鎖是什么?我們保護的又是什么职辅?
改進后的模型
我們知道在現(xiàn)實世界里棒呛,鎖和鎖要保護的資源是有對應關(guān)系的,比如你用你家的鎖保護你家的東西域携,我用我家的鎖保護我家的東西簇秒。在并發(fā)世界里,鎖和資源也應該有這個對應關(guān)系秀鞭,但這個對應關(guān)系在我們上面的模型中是沒有體現(xiàn)的趋观,所以我們需要完善一下我們的模型。
首先锋边,我們把臨界區(qū)要保護的資源標注出來皱坛,如圖中臨界區(qū)里增加了一個元素:受保護的資源R,其次,我們要保護資源R就得為它創(chuàng)建一把鎖LR;最后針對這把鎖LR豆巨, 我們還得在進出臨界區(qū)時添上加鎖操作和解鎖操作剩辟。另外,在鎖LR 和 受保護資源之間往扔,我特地用了一條線做了關(guān)聯(lián)贩猎,這個關(guān)聯(lián)關(guān)系非常重要。很多并發(fā)Bug 的出現(xiàn)萍膛,都是因為把它忽略了吭服。然后就出現(xiàn)了鎖自家門來保護別家財產(chǎn)的事情,這樣的Bug 非常不好診斷卦羡,因為潛意識里噪馏,我們已經(jīng)認為已經(jīng)正確加鎖了。
Java 語言提供的鎖技術(shù):synchronized
鎖是一種通用的技術(shù)方案绿饵,Java 語言里提供的synchronized 關(guān)鍵字欠肾,就是鎖的一種實現(xiàn)。synchronized 關(guān)鍵字可以用來修飾方法拟赊,也可以用來修飾代碼塊刺桃,它的使用示例基本都是以下這個樣子:
class X {
// 修飾非靜態(tài)方法
synchronized void foo() {
// 臨界區(qū)
}
// 修飾靜態(tài)方法
synchronized static void bar() {
// 臨界區(qū)
}
// 修飾代碼塊
Object obj = new Object();
void baz() {
synchronized(obj) {
// 臨界區(qū)
}
}
}
看完之后 你可能覺得有點奇怪吸祟,這個 和我們上面提到的模型有點對不上啊瑟慈,加鎖lock() 和解鎖 unlock() 在哪里呢? 其實這兩個操作都是有的屋匕,只是這兩個操作是被Java 默默加上去的葛碧。Java 編譯器會在 synchronized 修飾的方法或代碼塊前后自動加上加鎖 lock() 和解鎖 unlock(),這樣做的好處就是加鎖 lock() 和解鎖 unlock() 一定是成對出現(xiàn)的过吻, 畢竟忘記解鎖unlock() 可是個致命的 Bug(意味著其他線程只能死等下去了)进泼。
那 synchronized 里的加鎖 lock() 和解鎖 unlock() 鎖定的對象在哪里呢蔗衡? 上面的代碼我們看到只有修飾代碼塊的時候鎖定了一個 obj 對象, 那修飾方法的時候鎖定的是什么呢乳绕?這個也是Java 的一條隱式規(guī)則:
當修飾靜態(tài)方法的時候绞惦,鎖定的是當前類的Class 對象,在上面的例子中就是Class X洋措;
當修飾非靜態(tài)方法時济蝉,鎖定的是當前示例對象this 。
對于上面的例子菠发,synchronized 修飾靜態(tài)方法相當于:
class X {
// 修飾靜態(tài)方法
synchronized(X.class) static void bar() {
// 臨界區(qū)
}
}
修飾非靜態(tài)方法王滤,相當于:
class X {
// 修飾非靜態(tài)方法
synchronized(this) void foo() {
// 臨界區(qū)
}
}
用 synchronized 解決 count+=1 問題
相信你一定記得我們前面文章中提到的count+=1 存在并發(fā)的問題,現(xiàn)在我們可以嘗試用synchronized 來小試牛刀一把雷酪,代碼如下所示淑仆。SafeCalc 這個類有兩個方法,一個是get() 方法哥力,用來獲得 value 的值; 另一個是 addOne() 方法墩弯,用來給 value 加 1吩跋, 并且 addOne() 方法我們用 synchronized 修飾。 那么我們使用的這兩兩個方法有沒有并發(fā)問題呢渔工?
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
我們先來看看 addOne() 方法锌钮,首先可以肯定,被 synchronized 修飾后引矩,無論是單核 CPU 還是多核 CPU梁丘,只有一個線程能夠執(zhí)行 addOne() 方法,所以一定能保證原子操作旺韭, 那是否有可見性問題呢氛谜?要回答這問題,就要重溫一下上一篇文章中提到的管程中鎖的規(guī)則区端。
管程中鎖的規(guī)則:對一個鎖的解鎖Happens-Before 于后續(xù)對這個鎖的加鎖值漫。
管程就是我們這里的 synchronized(至于為什么叫管程,我們后面介紹)织盼,我們知道 synchronized 修飾的臨界區(qū)是互斥的杨何,也就是說同一時刻只有一個線程執(zhí)行臨界區(qū)的代碼;而所謂“對一個鎖解鎖 Happens-Before 后續(xù)對這個鎖的加鎖”沥邻,指的是前一個線程的解鎖操作對后一個線程的加鎖操作可見危虱,綜合 Happens-Before 的傳遞性原則,我們就能得出前一個線程在臨界區(qū)修改的共享變量(該操作在解鎖之前)唐全,對后續(xù)進入臨界區(qū)(該操作在加鎖之后)的線程是可見的埃跷。
按照 這個規(guī)則,如果多個線程執(zhí)行addOne() 方法, 可見性是可以保證的捌蚊,也就是說如果有1000 個線程執(zhí)行addOne() 方法集畅, 最終結(jié)果一定是value 值增加了1000,看到這個結(jié)果缅糟,問題終于解決了挺智。
但也許,你一不小心就忽視了get() 方法窗宦。 執(zhí)行 addOne() 方法后赦颇,value 的值對 get() 方法是可見的嗎?這個可見性是沒法保證的赴涵。 管程中鎖的規(guī)則就是媒怯,只保證后續(xù)對這個鎖的加鎖的可見性,而 get() 方法并沒有加鎖操作髓窜, 所以可見性沒法保證扇苞。那如何解決呢?很簡單寄纵,就是get() 方法也 synchronized 一下鳖敷,完整的代碼如下所示。
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
上面的代碼轉(zhuǎn)換為我們提到的鎖模型程拭,就是下面圖示的樣子定踱,get() 方法和 addOne() 方法都需要訪問 value 這個受保護的資源,這個資源用 this 這把鎖來保護恃鞋。線程要進入臨界區(qū) get() 和 addOne()崖媚,必須先獲得 this 這把鎖,這樣 get() 和 addOne() 也是互斥的恤浪。
這個模型更像是現(xiàn)實世界里的球賽門票的管理畅哑,一個作為只允許一個人使用,這個座位就是“受保護的資源”资锰,球賽的入口就是Java 類里的方法敢课,而門票就是用來保護資源的“鎖”, Java 里的檢票工作是由synchronized 解決的绷杜。
鎖和受保護資源的關(guān)系
我們前面提到直秆,受保護的資源和鎖之間的關(guān)聯(lián)關(guān)系非常重要,他們的關(guān)系是怎么樣的呢鞭盟?一個合理的關(guān)系是:受保護資源和鎖之間的關(guān)聯(lián)關(guān)系是 N:1 的關(guān)系圾结。 還拿前面球賽門票的管理來類比,就是一個座位齿诉,我們只能用一張票來保護筝野,如果多發(fā)了重復的票晌姚,那就要打架了。現(xiàn)實世界里歇竟,我們可以用多把鎖來保護同一個資源挥唠,但在并發(fā)領(lǐng)域是不行的,并發(fā)領(lǐng)域的鎖和現(xiàn)實世界的鎖不是完全匹配的焕议。不過倒是可以用同一把鎖來保護多個資源宝磨,這個對應到現(xiàn)實世界就是我們所謂的“包場”了。
上面的那個例子我稍作改動盅安,把value 改成靜態(tài)變量唤锉,把 addOne() 方法改成靜態(tài)方法,此時 get() 方法和 addOne() 方法是否存在并發(fā)問題呢别瞭?
如果你仔細觀察窿祥,就會發(fā)現(xiàn)改動后的代碼是用兩個鎖保護一個資源。這個受保護的資源就是靜態(tài)變量 value蝙寨,兩個鎖分別是 this 和 SafeCalc.class晒衩。我們可以用下面這幅圖來形象描述這個關(guān)系。由于臨界區(qū) get() 和 addOne() 是用兩個鎖保護的籽慢,因此這兩個臨界區(qū)沒有互斥關(guān)系浸遗,臨界區(qū) addOne() 對 value 的修改對臨界區(qū) get() 也沒有可見性保證,這就導致并發(fā)問題了箱亿。
總結(jié)
互斥鎖,在并發(fā)領(lǐng)域知名度很高弃秆,只要有了并發(fā)問題届惋,大家很容易想到的就是加鎖,加鎖能夠保證執(zhí)行臨界區(qū)代碼的互斥性菠赚,這樣理解雖然正確但是卻不能知道你真正用好互斥鎖脑豹,臨界區(qū)的代碼是操作受保護資源的路徑,類似于球場的入口衡查,入口一定要檢票瘩欺,也就是要加鎖,但不是隨便一把鎖就有效拌牲,必須深入分析受保護對象跟受保護資源的關(guān)系俱饿,綜合考慮受保護資源的訪問路徑,多方面考量才能用好互斥鎖塌忽。
synchronized 是 Java 在語言層面提供的互斥原語拍埠,其實 Java 里面還有很多其他類型的鎖,但作為互斥鎖土居,原理都是相通的:鎖枣购,一定有一個要鎖定的對象嬉探,至于這個鎖定的對象要保護的資源以及在哪里加鎖 / 解鎖,就屬于設計層面的事情了棉圈。