Java 并發編程(2): Java 中的同步原語

1 volatile

volatile 實現了輕量級的線程間通信機制.

1.1 volatile 的特性

  • 對volatile 變量的單個讀/寫, 等價于使用同一個鎖對這些單個讀/寫操作做了同步.
    • 同時, 它不會引起線程上下文的切換和調度, 從而比使用synchronized 的成本低的多.
  • 可見性 && 原子性.
    • 鎖的happens-before 規則保證了釋放鎖和獲取鎖的兩個線程間的內存可見性.
      • 對一個volatile 變量的讀, 總是能看到(任意線程) 對這個volatile 變量最后的寫入.
    • 同時, 鎖的語義保證了臨界區代碼的執行具有原子性.

1.2 volatile 讀/寫的內存語義

  • 從內存語義看, volatile 讀寫等同于鎖的釋放和獲取:
    • volatile 寫等同于鎖的釋放; volatile 讀等同于鎖的獲取.
  • volatile 寫: JMM 會把該線程對應的本地內存中的共享變量值刷新到主內存中.
  • volatile 讀: JMM 把該線程對應的本地內存置為無效, 然后從主內存中讀取共享變量.
  • A 線程寫一個volatile 變量, B 線程隨后讀取volatile 變量. 實質上是線程A 通過主內存向線程B 發送消息.
1.3 volatile 內存語義的實現
  • 原則:
    • 當第二個操作是volatile 寫時, 無論第一個操作是什么, 都不能進行重排序.
    • 當第一個操作是volatile 讀時, 無論第二個操作是什么, 都不能進行重排序.
    • 當第一個操作是volatile 寫, 第二個操作是volatile 讀時, 不能進行重排序.
  • JMM 采取保守策略, 插入內存屏障來實現volatile 語義.
    • volatile 寫后面都會插入StoreLoad 屏障, 來避免volatile 寫后面可能的volatile 讀/寫的重排序.
      • 所以volatile 寫比volatile 讀的開銷大的多.

3.1.4 volatile 匯編指令的實現

  • volatile 變量進行寫操作時, 會在指令前加上Lock, 并遵守以下兩條原則:
    • 原則1: 將當前CPU 緩存行的數據回寫到系統內存.
    • 原則2: 使其它CPU 里緩存了該內存地址的數據無效.
  • 實現原則1: 鎖總線/緩存.
    • 總線鎖定: 在總線上放入Lock# 信號以獨占內存. 開銷過大.
    • 緩存鎖定: 鎖定本地內存區域的緩存并回寫到內存, 并使用緩存一致性機制來保證修改的原子性.
    • 緩存一致性會阻止同時修改兩個以上CPU 緩存的內存區域數據.
  • 實現原則2: MESI(修改, 獨占, 共享, 無效).
    • 每個CPU 通過嗅探在總線上傳播的數據來檢查自己緩存的值是否過期,若發現已過期, 將其置為無效.

3.1.4 增強volatile 內存語義的原因

  • 舊的內存模型中, 允許volatile 變量與普通變量之間的重排序.
    • 從而使得volatile 的讀寫不具有鎖的釋放獲取鎖具有的內存語義.
  • 為了提供比鎖更輕量級的線程間通信機制, 嚴格限制了volatile 變量與普通變量的重排序.
    • 從而保證了volatile 等同于鎖的內存語義.

3.2 synchronized 鎖

鎖讓臨界區互斥執行, 同時可以讓釋放鎖的線程向獲取同一鎖的線程發送消息.

3.2.1 鎖的釋放和獲取的內存語義

  • happens-before 中的監視器的鎖規則, 保證了線程間的可見性.
  • 完全等同于volatile 的內存語義.
    • 當線程釋放鎖時, JMM 會把該線程對應的本地內存中的共享變量刷新到主內存中.
    • 當線程獲取鎖時, JMM 會把該線程對應的本地內存置為無效, 從而使得監視器保護的臨界區代碼必須從主內存中讀取共享變量.

3.2.2 鎖的實現原理

  • 基礎: 每個對象(實例, Class對象)都可以作為鎖.
  • 實現: 基于進入和退出Monitor 對象.
    • 任何一個對象都有一個monitor 與之關聯. 當一個Monitor 被持有后, 處于鎖定狀態.
    • 編譯后, 在同步代碼塊的開始位置, 插入monitorenter 指令, 在同步塊的結束和異常處, 插入monitorexit 指令.
    • 線程執行到monitorenter 指令時, 會嘗試獲取對象對應的monitor 的所有權(即鎖).
  • 鎖存儲在Java 對象頭里.

3.2.3 JDK 1.6 中的鎖

  • 為了減少鎖的性能消耗. 引入了新的鎖類型.
    • 級別從低到高: 無鎖狀態 -> 偏向鎖狀態 -> 輕量級鎖狀態 -> 重量級鎖狀態.
    • 狀態會隨著競爭情況逐漸升級, 但不能降級.
  • 偏向鎖
    • 基礎: 多數情況下, 鎖總是由同一線程多次獲得, 而不存在多線程競爭.
    • 減少鎖獲取的代價: 在對象頭和棧幀中的鎖記錄中存儲偏向鎖的線程ID.
      • 之后進入和退出同步塊時, 只需測試存儲的偏向鎖, 如果匹配, 直接獲取鎖. 否則使用CAS 競爭鎖.
    • 直到競爭出現才釋放鎖的機制.
      • 需要等待全局安全點才能撤銷偏向鎖.
    • 如果應用程序里所有的鎖通常情況下處于競爭狀態, 則通過JVM 參數關閉默認打開的偏向鎖, 從而默認進入輕量級鎖狀態.
  • 輕量級鎖
    • 線程在執行同步塊之前, JVM 會先在當前線程的棧幀中創建用于存儲鎖記錄的空間.
    • 然后, 線程嘗試使用CAS 將對象頭中的Mark Word 替換為指向鎖記錄的指針, 如果成功,當前線程獲取鎖, 否則嘗試使用自旋來獲取鎖.
    • 鎖處于該狀態下時, 其它試圖獲取鎖的線程會被阻塞住, 知道持有鎖的線程釋放后會喚醒這些線程進行鎖競爭.
優點 缺點 使用場景
偏向鎖 加解鎖無消耗 若存在線程間的鎖競爭,會帶來額外的鎖撤銷消耗 只有一個線程訪問同步塊
輕量級鎖 競爭的線程不會阻塞 得不到鎖競爭的線程,會使用自旋來消耗CPU 追求響應速度, 同步塊的執行速度快
重量級鎖 線程競爭不會自旋 線程阻塞, 響應時間慢 追求吞吐量,同步塊執行速度慢

3.3 final

3.3.1 final 與的內存語義

  • 對于final 域, 編譯器和CPU 要遵守兩個重排序規則:
    • 構造函數內對一個final 域的寫入, 與隨后把該被構造對象的引用賦值給一個引用變量, 這兩個操作不能重排序.
    • 初次讀一個包含final 域的對象的引用, 與隨后初次讀這個final 域. 這兩個操作不能重排序.

3.3.2 寫final 域的重排序規則

  • 禁止把final 域的寫重排序到構造函數之外.
    • 在final 域的寫之后, 構造函數return 之前, 插入StoreStore 屏障.
  • 確保: 在對象引用為任意線程可見之前, 對象的final 域已經被證券的初始化過了.
    • 普通變量不具備這個保障.

3.3.3 讀final 域的重排序規則

  • 在讀final 域操作的前面,插入LoadLoad 屏障.
  • 確保: 在讀一個對象的final 域之前, 一定會先讀包含這個final 與的對象的引用(null 的判定).

3.3.4 final 域為引用類型

  • 約束: 在構造函數內對一個final 引用的對象的成員域的寫入, 與隨后在構造函數外把這個被構造對象的引用賦值給一個引用變量, 這兩個操作之間不能重排序.
  • 寫final 引用域的線程, 和讀final 引用域的線程之間, 需要使用同步原語(lock/volatile)來確保可見性.

3.3.5 final 引用的'溢出'

  • 如果在構造函數內, 將this 賦值給全局引用, 其它線程可以通過該全局引用, 訪問到未被初始化過的final 域.

3.3.6 X86 CPU 中的final 實現

  • 由于不會對寫-寫作重排序, 省去了寫final 域所需的StoreStore 屏障.
  • 不會對存在間接依賴關系的操作重排序, 讀final 域的LoadLoad 屏障也被省去.
  • X86 CPU 不需要對final 域的讀寫插入任何的內存屏障.

3.3.6 增強final 語義的原因

  • 舊的實現, 可能會讀取到未初始化過的final 域.
  • 新實現確保: 只要對象是正確構造的(未'溢出'), 則不需要使用同步, 任意線程都可以看到final 域在構造函數中初始化過的值.

3.4 原子操作

在Java 中通過鎖和循環CAS 的方式實現原子操作.

3.2.1 CPU 如何實現原子操作

  • 基于緩存加鎖或者總線加鎖的方式來實現多CPU 間的原子操作.
    • CPU 會自動保證基本的內存操作的原子性.
      • CPU 讀寫系統內存中的一個字節是原子的, 其間其它CPU 不能訪問該字節的內存地址.
  • 使用總線鎖
    • 沖突: 多CPU 可能同時從各種的緩存中讀取同一共享變量,然后進行寫操作, 最后寫入系統內存中.
    • 使用CPU 提供的LOCK # 信號.來獨占共享內存.
  • 使用緩存鎖
    • 總線鎖的開銷過大, 其間其它CPU 不能操作任何的內存地址的數據.
    • 頻繁使用的內存會緩存在CPU 的L1,L2,L3 高速緩存中, 原子操作可以直接在CPU 內部緩存中進行.
    • 緩存鎖定: 如果內存區域被緩存在CPU 的緩存行中, 當它執行鎖操作并回寫到內存時, CPU 修改內部的內存地址, 并使用緩存一致性機制來保證操作的原子性.
      • 緩存一致性: 阻止同時修改由兩個以上CPU 緩存的內存區域數據. 當其它CPU 回寫已被鎖定的緩存行數據時, 會使緩存行無效.

3.2.2 CAS: compareAndSwap()

  • 語義: 如果當前狀態值等于預期值, 則以原子方式將同步狀態設置為給定的更新值.
    • 此操作具有volatile 讀和寫的內存語義.
  • 實現concurrent 包:
    • 首先, 聲明共享變量為volatile.
    • 然后, 使用CAS 的原子條件更新來實現線程間的同步.
    • 同時, 以CAS/volatile 的內存語義實現線程間的通信.

3.2.3 CAS 的三大問題

  • ABA 問題: 檢查值時, 針對A->B->A的變化,可能會誤判為沒有變化.
    • 使用版本號解決: 1A->2B->3A. Atomic 包中的AtomicStampedReference.
  • 循環時間長開銷大.
  • 只能保證一個共享變量的原子操作.
    • 多個共享變量操作時, 只能用鎖.
    • 可以將多個共享變量合并為一個共享變量.

3.5 雙重檢查鎖定與延遲初始化

3.5.1 雙重檢查鎖的由來

  • 場景: 使用延遲初始化來推遲一些高開銷的對象初始化操作.
  • 線程安全: 使用synchronized 來對getInstance() 進行加鎖.
    • 當該方法會被多個線程頻繁調用時, 會導致程序的執行性能嚴重下降.
  • Double_checked Locking.
    if ( instance == null){    // 第一次檢查
        synchronized (DoubleCheckedLocing.clss){ // 加鎖
            if( instance == null) // 第二次檢查
                instance = new Instance(); // 對象的創建.
        }
        return instance;
    }

3.5.2 問題的根源

  • 在第一次檢查時, 代碼讀取到instance 不為null 時, instance 引用的對象可能還未完成初始化.
  • 對象創建的三個步驟:
    1. memory = allocate(); // 分配對象的內存空間.
    • ctorInstance(memory); // 初始化對象.
    • instance = memory; // 設置instance 指向剛分配的內存地址.
  • 其中, 步驟2 和步驟3 可能會被重排序.(由于該重排序并不會改變單線程中程序執行的結果).

3.5.3 基于volatile 的解決方案

  • 將instance 聲明為volatile 型.
    • volatile 的內存語義會禁止步驟2和3之間的重排序.

3.5.4 基于類初始化的解決方案

  • 在執行類的初始化期間, JVM 會去獲取一個鎖. 該鎖可以同步多個線程對同一個類的初始化.
  • 實質: 允許步驟2和3的重排序, 但禁止非構造線程'看到'該重排序.
  • 優勢是簡潔. 但基于volatile 的方案, 除了靜態字段, 還可以實現延遲初始化實例字段.

3.5.5 實踐

  • 字段延遲初始化降低了初始化類或創建實例的開銷, 但增加了訪問被延遲初始化的字段的開銷.
  • 多數情況下, 正常初始化要優于延遲初始化.
  • 如需要對實例字段使用線程安全的延遲初始化, 使用volatile 方案.
  • 如需要對靜態字段使用線程安全的延遲初始化, 使用類初始化的方案.
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容