文章目錄
- 緩存一致性協議(MESI)
- volatile
- 1. volatile 的作用
- 2.volatile的底層實現
- 3,volatile 實現單例模式的雙重鎖(面手寫)
- synchronized
- 1,基本用法
- 2,可重入性
- 3,Java對象頭
- 4,實現原理
- (1)代碼塊同步的實現
- (2)方法同步的實現
- 5,鎖的升級與對比
- 原子操作的實現原理
- 1,術語
- 2,如何實現原子操作
- 3,Java如何實現原子操作
- CAS實現原子操作的三大問題
- CAS及其損耗CPU性能
緩存一致性協議(MESI)
MESI 是四種緩存行狀態的縮寫:
狀態 | 英文全稱 | 說明 |
---|---|---|
M (Modified) | 已修改 | 緩存行已被當前CPU修改,與主存不一致,其他CPU不能持有該數據的有效副本 |
E (Exclusive) | 獨占 | 緩存行僅被當前CPU持有,與主存一致,其他CPU沒有該數據的副本 |
S (Shared) | 共享 | 緩存行被多個CPU共享,所有副本與主存一致 |
I (Invalid) | 無效 | 緩存行數據已失效,必須從主存或其他CPU重新加載 |
MESI 的工作示例:
假設兩個CPU核心(Core1和Core2)訪問同一內存地址 X
:
- 初始狀態
X
在主存中的值為0
- Core1和Core2的緩存中均無
X
- Core1 讀取 X
- Core1 緩存
X
,狀態變為 E (Exclusive) - 直接從主存加載
X=0
- Core1 緩存
- Core2 讀取 X
- Core1 的
X
狀態降級為 S (Shared) - Core2 也緩存
X
,狀態為 S
- Core1 的
- Core1 修改 X=1
- Core1 發送 總線事務,使 Core2 的
X
緩存行失效(狀態變為 I) - Core1 的
X
狀態變為 M (Modified),并更新緩存值
- Core1 發送 總線事務,使 Core2 的
- Core2 再次讀取 X
- 發現
X
緩存行無效(狀態為 I) - 向總線發送請求,Core1 將
X=1
寫回主存,并降級為 S - Core2 重新加載
X=1
,狀態變為 S
- 發現
volatile
volatile
是 Java 提供的一種輕量級同步機制,用于確保多線程環境下的 可見性 和 禁止指令重排序,但它 不保證原子性。
特性 | 說明 | 實現原理 |
---|---|---|
可見性 | 一個線程修改 volatile 變量后,其他線程立即可見新值 | 內存屏障 + 緩存一致性協議(MESI) |
有序性 | 禁止 JVM 對 volatile 變量的讀寫操作進行重排序 | 插入內存屏障指令 |
非原子性 | volatile 不能保證復合操作(如 i++)的原子性 | 需配合 synchronized/CAS |
1. volatile 的作用
(1) 保證可見性
- 問題:普通變量在多線程環境下,一個線程修改后,其他線程可能無法立即看到最新值(由于 CPU 緩存)。
- volatile 的解決方案:
- 寫操作:立即刷新到主內存,并 使其他 CPU 緩存失效。
- 讀操作:強制從主內存重新加載最新值。
(2) 禁止指令重排序
- 問題:JVM 和 CPU 可能對指令進行優化重排,導致多線程環境下出現意外行為。
- volatile 的解決方案:
- 通過 內存屏障(Memory Barrier) 禁止 JVM 和 CPU 對
volatile
變量的讀寫操作進行重排序。
- 通過 內存屏障(Memory Barrier) 禁止 JVM 和 CPU 對
2.volatile的底層實現
-
內存屏障
-
寫操作
- **StoreStore 屏障:**位于volatile之前,確保 volatile 寫之前的 所有普通寫操作 都已完成(刷新到主內存)
- **StoreLoad 屏障:**位于volatile之后,禁止當前
Store
與之后的Load
重排序;強制刷新寫緩沖區到主內存。
// 線程1 x = 1; // 普通寫 StoreStoreBarrier(); // 確保 x=1 刷入內存 volatileVar = 2; // volatile 寫 StoreLoadBarrier(); // 確保 volatile 寫對所有線程可見
-
讀操作
- **LoadLoad 屏障:**位于volatile之后,防止 volatile 讀與 后續的普通讀操作 重排序
- **LoadStore 屏障:**位于volatile之后,防止 volatile 讀與 后續的普通寫操作 重排序
// 線程2 int tmp = volatileVar; // volatile 讀 LoadLoadBarrier(); // 防止后續讀重排序 LoadStoreBarrier(); // 防止后續寫重排序 int b = x; // 普通讀(此時能看到線程1的 x=1)
-
-
緩存一致性協議
3,volatile 實現單例模式的雙重鎖(面手寫)
雙檢鎖/雙重校驗鎖(DCL,即 double-checked locking)
**JDK 版本:**JDK1.5 起
**是否 Lazy 初始化:**是(即使用到這個變量時才會實例化)
**是否多線程安全:**是
**實現難度:**較復雜
**描述:**這種方式采用雙鎖機制,安全且在多線程情況下能保持高性能。
getInstance() 的性能對應用程序很關鍵。
實例
public class Singleton { private volatile static Singleton singleton; private Singleton (){} public static Singleton getSingleton() { if (singleton == null) { synchronized (Singleton.class) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; }
}
- 私有構造器:禁止外部實例化
- 雙重檢查
- 第一次檢查(無鎖):
避免每次調用getSingleton()
都進入同步塊,提升性能。 - 第二次檢查(加鎖后):
防止多個線程同時通過第一次檢查后重復創建實例。
- 第一次檢查(無鎖):
- 同步鎖(synchronized)
- 保證 實例化過程的原子性,防止多線程并發創建多個實例。
- volatile 關鍵字
- 解決 指令重排序問題,確保其他線程不會獲取到未初始化的對象。
如果不使用 volatile 關鍵字,JVM 可能會對這三個子步驟進行指令重排。
- 為 Singleton對象分配內存
- 將對象賦值給引用 singleton
- 調用構造方法初始化成員變量
這種重排序會導致 singleton 引用在對象完全初始化之前就被其他線程訪問到。具體來說,如果一個線程執行到步驟 2 并設置了 singleton 的引用,但尚未完成對象的初始化,這時另一個線程可能會看到一個“半初始化”的 Singleton對象。
- 線程 A 執行到
if (singleton == null)
,判斷為 true,進入同步塊。 - 線程 B 執行到
if (singleton == null)
,判斷為 true,進入同步塊。
如果線程 A 執行 singleton = new Penguin()
時發生指令重排序:
- 線程 A 分配內存并設置引用,但尚未調用構造方法完成初始化。
- 線程 B 此時判斷
singleton != null
,直接返回這個“半初始化”的對象。
這樣就會導致線程 B 拿到一個不完整的 Penguin 對象,可能會出現空指針異常或者其他問題。
于是,我們可以為 singleton 變量添加 volatile 關鍵字,來禁止指令重排序,確保對象的初始化完成后再將其賦值給 singleton。
synchronized
1,基本用法
-
加在靜態方法上:鎖定的是類
-
加在非靜態方法:鎖定的是方法的調用者,當前實例。
-
修飾代碼塊:鎖定的是傳入的對象
并發學習之synchronized,JVM內存圖,線程基礎知識-CSDN博客
2,可重入性
從互斥鎖的設計上來說,當一個線程試圖操作一個由其他線程持有的對象鎖的臨界資源時,將會處于阻塞狀態,但當一個線程再次請求自己持有對象鎖的臨界資源時,這種情況屬于重入鎖,請求將會成功。
synchronized 就是可重入鎖,因此一個線程調用 synchronized 方法的同時,在其方法體內部調用該對象另一個 synchronized 方法是允許的。
3,Java對象頭
Java對象在內存中的布局分為三部分:對象頭(Header)實例數據(Instance Data)和 對齊填充(Padding)。
對象頭是synchronized實現的基礎,它包含兩部分信息:Mark Word(標記字段)和 Klass Pointer(類型指針,指向對象的類元數據的指針,JVM通過這個指針確定對象是哪個類的實例)。
Mark Word 的格式:
鎖狀態 | 29 bit 或 61 bit | 1 bit 是否是偏向鎖? | 2 bit 鎖標志位 |
---|---|---|---|
無鎖 | 0 | 01 | |
偏向鎖 | 線程 ID | 1 | 01 |
輕量級鎖 | 指向棧中鎖記錄的指針 | 此時這一位不用于標識偏向鎖 | 00 |
重量級鎖 | 指向互斥量(重量級鎖)的指針 | 此時這一位不用于標識偏向鎖 | 10 |
GC 標記 | 此時這一位不用于標識偏向鎖 | 11 |
synchronized的同步是基于進入和退出Monitor對象實現的,每個Java對象都與一個Monitor相關聯。
那什么是Monitor對象
在不同的鎖狀態下,Mark word會存儲不同的信息,這也是為了節約內存常用的設計。當鎖狀態為重量級鎖(鎖標識位=10)時,Mark word中會記錄指向Monitor對象的指針,這個Monitor對象也稱為管程或監視器鎖。
每個對象都存在著一個 Monitor對象與之關聯。執行 monitorenter 指令就是線程試圖去獲取 Monitor 的所有權,搶到了就是成功獲取鎖了;執行 monitorexit 指令則是釋放了Monitor的所有權。
4,實現原理
JVM規范中對于synchronized的實現分為兩種方式:代碼塊同步和方法同步,它們底層采用了不同的實現策略,但最終都可以歸結為對Monitor對象的操作。
monitorenter指令是在編譯后插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有后,它將處于鎖定狀態。線程執行到monitorenter指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。
- 不是每個Java對象都有一個物理Monitor對象
- 只有進入重量級鎖狀態時才會創建真正的Monitor對象
- 偏向鎖和輕量級鎖階段,鎖信息存儲在對象頭中
- Monitor資源由JVM管理
- Monitor對象不是Java層面的對象
- 由JVM在需要時創建(通常位于C++層實現)
monitorenter
指令用于獲取對象的監視器鎖(Monitor lock),主要功能包括:
- 鎖獲取:嘗試獲取與對象關聯的 Monitor
- 鎖升級:根據競爭情況可能觸發鎖升級(偏向鎖→輕量級鎖→重量級鎖)
- 重入計數:支持同一線程的鎖重入
執行 monitorenter 時:
1. 檢查對象頭中的鎖標志位- 如果是無鎖狀態(01):a. 嘗試通過CAS將對象頭Mark Word替換為當前線程指針(偏向鎖)b. 成功則獲取鎖,失敗則升級為輕量級鎖- 如果是輕量級鎖(00):a. 檢查是否當前線程已持有鎖(鎖重入)b. 如果是,recursions+1c. 如果不是,自旋嘗試獲取或升級為重量級鎖- 如果是重量級鎖(10):a. 進入操作系統的互斥量等待隊列
2. 獲取成功后,對象頭將記錄鎖狀態和持有線程信息
monitorexit
指令用于釋放對象的監視器鎖,主要功能包括:
- 鎖釋放:釋放對 Monitor 的持有
- 喚醒線程:在重量級鎖狀態下喚醒等待線程
- 重入處理:減少重入計數,只在完全釋放時真正放開鎖
執行 monitorexit 時:
1. 檢查當前線程是否是鎖的持有者- 如果不是,拋出 IllegalMonitorStateException
2. 減少重入計數(recursions)
3. 如果重入計數歸零:a. 恢復對象頭的無鎖狀態(輕量級鎖)b. 或喚醒 EntryList 中的線程(重量級鎖)
4. 如果是同步塊結束處的 monitorexit:a. 正常退出同步區域
5. 如果是異常路徑的 monitorexit:a. 仍然確保鎖被釋放b. 重新拋出異常
(1)代碼塊同步的實現
代碼塊同步是顯式同步,通過monitorenter
和monitorexit
指令實現:
- 每個
monitorenter
必須有對應的monitorexit
- 編譯器會為同步塊生成異常處理表,確保異常發生時也能釋放鎖
- 可以針對任意對象進行同步
(2)方法同步的實現
方法同步是隱式同步,通過在方法訪問標志中設置ACC_SYNCHRONIZED
標志實現:
-
調用方法時會隱式獲取Monitor,沒有顯式的
monitorenter
和monitorexit
指令 -
方法正常完成或異常拋出時會隱式釋放Monitor
-
同步的Monitor對象是方法所屬的實例(非靜態方法)或Class對象(靜態方法)
-
JVM在方法調用時自動處理鎖的獲取和釋放
特性 | monitorenter/monitorexit | ACC_SYNCHRONIZED |
---|---|---|
實現級別 | 字節碼指令 | 方法訪問標志 |
鎖對象 | 顯式指定任意對象 | 隱式使用 this 或 Class 對象 |
異常處理 | 顯式生成 monitorexit | JVM 自動處理 |
可觀察性 | 可在字節碼中直接看到 | 只能通過訪問標志識別 |
優化可能性 | 較難優化 | 更易被 JIT 優化 |
5,鎖的升級與對比
鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖后不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率。
-
偏向鎖:
-
設計目的:優化只有一個線程訪問同步塊的場景
-
實現原理:HotSpot 的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低而引入了偏向鎖。當一個線程訪問同步塊并獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下對象頭的Mark Word里是否存儲著指向當前線程的偏向鎖。如果測試成功,表示線程已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設置成1(表示當前是偏向鎖):如果沒有設置,則使用CAS競爭鎖;如果設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。
-
升級觸發條件:
- 另一個線程嘗試獲取該鎖(產生競爭)
- 調用
hashCode()
方法(因為偏向鎖會占用哈希碼位置)
-
-
輕量級鎖:
-
設計目的:優化線程交替執行同步塊的場景最適合少量線程(建議≤2個活躍競爭線程)和短時間同步的場景
-
輕量級鎖加鎖:線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用于存儲鎖記錄的空間,官方稱為Displaced Mark Word。并將對象頭中的Mark Word復制到鎖記錄中。然后線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖,如果失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。
-
輕量級鎖解鎖:輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。
-
升級觸發條件:
- CAS 操作失敗(表示有競爭|兩個線程的CAS操作出現重疊|競爭發生在同一時間窗口)
- 自旋獲取鎖超過一定次數
-
-
重量級鎖
依賴于操作系統的互斥量(mutex) 實現的,而操作系統中線程間狀態的轉換需要相對較長的時間,所以重量級鎖效率很低,但被阻塞的線程不會消耗 CPU。
原子操作的實現原理
1,術語
-
緩存行:緩存行是CPU緩存的最小讀寫單位,通常為 64字節。三級緩存就是由緩存行組成。
- L1/L2:每個核心獨占,減少多核競爭。
- L3:多核共享,避免頻繁訪問內存。
-
CAS:比較并且交換。CAS需要兩個值,一個舊值,一個新值。舊值用來比較操作期間是否發生變化,如果沒有發生變化才會交換新值。
-
CPU流水線技術
時間軸 | 指令1 | 指令2 | 指令3 | 指令4 | --------+----------+----------+----------+----------+ Cycle1 | IF1 | | | | Cycle2 | ID1 | IF2 | | | Cycle3 | EX1 | ID2 | IF3 | | Cycle4 | MEM1 | EX2 | ID3 | IF4 | Cycle5 | WB1 | MEM2 | EX3 | ID4 | Cycle6 | | WB2 | MEM3 | EX4 | Cycle7 | | | WB3 | MEM4 | Cycle8 | | | | WB4 |
-
內存順序沖突:內存順序沖突 是由于 CPU/編譯器優化導致的 指令重排問題導致的內存訪問順序與程序邏輯順序不一致,從而引發數據競爭、邏輯錯誤等問題。
2,如何實現原子操作
- 總線鎖
- 緩存鎖:處理器標記該緩存行為 “鎖定” 狀態,阻止其他核心同時訪問。
總線鎖定把CPU和內存之間的通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。
有兩種情況不適用緩存鎖:
- 操作的數據沒有緩存在緩存行中,或者操作數據跨了多個緩存行會使用總線鎖
- 某些處理器不支持
3,Java如何實現原子操作
-
**
AtomicInteger
**等原子類 -
使用volatile,synchronized關鍵字
-
使用CAS循環實現原子操作
/** * 使用CAS實現線程安全計數器 */private void safeCount() {for (;;) {int i = atomicI.get();boolean suc = atomicI.compareAndSet(i, ++i);if (suc) {break;}}}AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0); ref.compareAndSet(100, 101, stamp, stamp + 1); // 檢查值和版本號
CAS實現原子操作的三大問題
- ABA問題:CAS在操作值時,如果一個值由A變為B又變為A,那么使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。解決思路是使用版本號,如上AtomicStampedReference
- 循環時間長CPU開銷大,自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。
- 只能保證一個共享變量的原子操作,如果是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖。當多個線程同時競爭同一變量時,大量 CAS 操作會失敗,導致線程自旋(循環重試)。自旋期間線程持續占用 CPU,執行無效循環,消耗 CPU 周期。
假設有1000線程并且這個CPU切換比較快速,其中一個CAS成功了,那剩余的999個就都白計算了,還不如加鎖禁止其他線程操作,這樣不會造成CPU的劇烈浪費。所以CAS只適合低烈度的并發。