03|互斥鎖(上):解決原子性問題

在上一篇中我們提到洋满,一個或者多個操作在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 位牙丽,如下圖所示)简卧。

img

在單核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)以下模型:

img

我們把一段需要互斥執(zhí)行的代碼稱為臨界區(qū)。線程在進入臨界區(qū)前蛾扇,首先嘗試加鎖如果成功攘烛,則進入臨界區(qū),此時我們稱為這個線程持有鎖镀首;否則就等待坟漱,直到持有鎖的線程解鎖,持有鎖的線程執(zhí)行完臨界區(qū)代碼后更哄,執(zhí)行解鎖unlock()芋齿。

這個 過程非常像辦公室高峰搶占坑位,每個人都是進坑鎖門(加鎖)竖瘾,出坑開門(解鎖)沟突,如廁這個事就是臨界區(qū)。很長時間里 我也事這么理解的捕传,這樣理解本身沒有問題惠拭,但卻很容易讓我們忽視兩個非常重要的點,我們的鎖是什么?我們保護的又是什么职辅?

改進后的模型

我們知道在現(xiàn)實世界里棒呛,鎖和鎖要保護的資源是有對應關(guān)系的,比如你用你家的鎖保護你家的東西域携,我用我家的鎖保護我家的東西簇秒。在并發(fā)世界里,鎖和資源也應該有這個對應關(guān)系秀鞭,但這個對應關(guān)系在我們上面的模型中是沒有體現(xiàn)的趋观,所以我們需要完善一下我們的模型。

img

首先锋边,我們把臨界區(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() 也是互斥的恤浪。

img

這個模型更像是現(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ā)問題了箱亿。

img

總結(jié)

互斥鎖,在并發(fā)領(lǐng)域知名度很高弃秆,只要有了并發(fā)問題届惋,大家很容易想到的就是加鎖,加鎖能夠保證執(zhí)行臨界區(qū)代碼的互斥性菠赚,這樣理解雖然正確但是卻不能知道你真正用好互斥鎖脑豹,臨界區(qū)的代碼是操作受保護資源的路徑,類似于球場的入口衡查,入口一定要檢票瘩欺,也就是要加鎖,但不是隨便一把鎖就有效拌牲,必須深入分析受保護對象跟受保護資源的關(guān)系俱饿,綜合考慮受保護資源的訪問路徑,多方面考量才能用好互斥鎖塌忽。

synchronized 是 Java 在語言層面提供的互斥原語拍埠,其實 Java 里面還有很多其他類型的鎖,但作為互斥鎖土居,原理都是相通的:鎖枣购,一定有一個要鎖定的對象嬉探,至于這個鎖定的對象要保護的資源以及在哪里加鎖 / 解鎖,就屬于設計層面的事情了棉圈。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末涩堤,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子分瘾,更是在濱河造成了極大的恐慌胎围,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,324評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件芹敌,死亡現(xiàn)場離奇詭異痊远,居然都是意外死亡,警方通過查閱死者的電腦和手機氏捞,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,356評論 3 392
  • 文/潘曉璐 我一進店門碧聪,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人液茎,你說我怎么就攤上這事逞姿。” “怎么了捆等?”我有些...
    開封第一講書人閱讀 162,328評論 0 353
  • 文/不壞的土叔 我叫張陵滞造,是天一觀的道長。 經(jīng)常有香客問我栋烤,道長谒养,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,147評論 1 292
  • 正文 為了忘掉前任明郭,我火速辦了婚禮买窟,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘薯定。我一直安慰自己始绍,他們只是感情好,可當我...
    茶點故事閱讀 67,160評論 6 388
  • 文/花漫 我一把揭開白布话侄。 她就那樣靜靜地躺著亏推,像睡著了一般。 火紅的嫁衣襯著肌膚如雪年堆。 梳的紋絲不亂的頭發(fā)上吞杭,一...
    開封第一講書人閱讀 51,115評論 1 296
  • 那天,我揣著相機與錄音嘀韧,去河邊找鬼篇亭。 笑死,一個胖子當著我的面吹牛锄贷,可吹牛的內(nèi)容都是我干的译蒂。 我是一名探鬼主播曼月,決...
    沈念sama閱讀 40,025評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼柔昼!你這毒婦竟也來了哑芹?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,867評論 0 274
  • 序言:老撾萬榮一對情侶失蹤捕透,失蹤者是張志新(化名)和其女友劉穎聪姿,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體乙嘀,經(jīng)...
    沈念sama閱讀 45,307評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡末购,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,528評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了虎谢。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片盟榴。...
    茶點故事閱讀 39,688評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖婴噩,靈堂內(nèi)的尸體忽然破棺而出擎场,到底是詐尸還是另有隱情,我是刑警寧澤几莽,帶...
    沈念sama閱讀 35,409評論 5 343
  • 正文 年R本政府宣布迅办,位于F島的核電站,受9級特大地震影響章蚣,放射性物質(zhì)發(fā)生泄漏站欺。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,001評論 3 325
  • 文/蒙蒙 一纤垂、第九天 我趴在偏房一處隱蔽的房頂上張望镊绪。 院中可真熱鬧,春花似錦洒忧、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,657評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至履磨,卻和暖如春蛉抓,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背剃诅。 一陣腳步聲響...
    開封第一講書人閱讀 32,811評論 1 268
  • 我被黑心中介騙來泰國打工巷送, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人矛辕。 一個月前我還...
    沈念sama閱讀 47,685評論 2 368
  • 正文 我出身青樓笑跛,卻偏偏與公主長得像付魔,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子飞蹂,可洞房花燭夜當晚...
    茶點故事閱讀 44,573評論 2 353

推薦閱讀更多精彩內(nèi)容