1 volatile
volatile 實現了輕量級的線程間通信機制.
1.1 volatile 的特性
- 對volatile 變量的單個讀/寫, 等價于使用同一個鎖對這些單個讀/寫操作做了同步.
- 同時, 它不會引起線程上下文的切換和調度, 從而比使用synchronized 的成本低的多.
- 可見性 && 原子性.
- 鎖的happens-before 規則保證了釋放鎖和獲取鎖的兩個線程間的內存可見性.
- 對一個volatile 變量的讀, 總是能看到(任意線程) 對這個volatile 變量最后的寫入.
- 同時, 鎖的語義保證了臨界區代碼的執行具有原子性.
- 鎖的happens-before 規則保證了釋放鎖和獲取鎖的兩個線程間的內存可見性.
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 讀的開銷大的多.
- volatile 寫后面都會插入StoreLoad 屏障, 來避免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 可能同時從各種的緩存中讀取同一共享變量,然后進行寫操作, 最后寫入系統內存中.
- 使用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 引用的對象可能還未完成初始化.
- 對象創建的三個步驟:
- 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 方案.
- 如需要對靜態字段使用線程安全的延遲初始化, 使用類初始化的方案.