一文看懂 Java 鎖機制
背景知識
指令流水線
CPU 的基本工作是執行存儲的指令序列,即程序。程序的執行過程實際上是不斷地取出指令、分析指令、執行指令的過程。
幾乎所有的馮 • 諾伊曼型計算機的 CPU,其工作都可以分爲 5 個階段:取指令、指令譯碼、執行指令、訪存取數和結果寫回。
現代處理器的體系結構中,採用了流水線的處理方式對指令進行處理。指令包含了很多階段,對其進行拆解,每個階段由專門的硬件電路、寄存器來處 理,就可以實現流水線處理。實現更高的 CPU 吞吐量,但是由於流水線處理本身的額外開銷,可能會增加延遲。
cpu 多級緩存
在計算機系統中,CPU 高速緩存(CPU Cache,簡稱緩存)是用於減少處理器訪問內存所需平均時間的部件。在金字塔式存儲體系中它位於自頂向下的第二層,僅次於 CPU 寄存器。其容量遠小於內存,但速度卻可以接近處理器的頻率。
當處理器發出內存訪問請求時,會先查看緩存內是否有請求數據。如果存在(命中),則不經訪問內存直接返回該數據;如果不存在(失效),則要先把內存中的相應數據載入緩存,再將其返回處理器。
緩存之所以有效,主要是因爲程序運行時對內存的訪問呈現局部性(Locality)特徵。這種局部性既包括空間局部性(Spatial Locality),也包括時間局部性(Temporal Locality)。有效利用這種局部性,緩存可以達到極高的命中率。
問題引入
原子性
原子性:即一個操作或者多個操作 要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
示例方法:{i++ (i 爲實例變量)}
這樣一個簡單語句主要由三個操作組成:
-
讀取變量 i 的值
-
進行加一操作
-
將新的值賦值給變量 i
如果對實例變量 i 的操作不做額外的控制,那麼多個線程同時調用,就會出現覆蓋現象,丟失部分更新。
另外,如果再考慮上工作內存和主存之間的交互,可細分爲以下幾個操作:
-
read 從主存讀取到工作內存 (非必須)
-
load 賦值給工作內存的變量副本(非必須)
-
use 工作內存變量的值傳給執行引擎
-
執行引擎執行加一操作
-
assign 把從執行引擎接收到的值賦給工作內存的變量
-
store 把工作內存中的一個變量的值傳遞給主內存(非必須)
-
write 把工作內存中變量的值寫到主內存中的變量(非必須)
可見性
可見性:是指當多個線程訪問同一個變量時,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值
存在可見性問題的根本原因是由於緩存的存在,線程持有的是共享變量的副本,無法感知其他線程對於共享變量的更改,導致讀取的值不是最新的。
while (flag) {//語句1
doSomething();//語句2
}
flag = false;//語句3
線程 1 判斷 flag 標記,滿足條件則執行語句 2;線程 2flag 標記置爲 false,但由於可見性問題,線程 1 無法感知,就會一直循環處理語句 2。
順序性
順序性:即程序執行的順序按照代碼的先後順序執行
由於編譯重排序和指令重排序的存在,是的程序真正執行的順序不一定是跟代碼的順序一致,這種情況在多線程情況下會出現問題。
if (inited == false) {
context = loadContext(); //語句1
inited = true; //語句2
}
doSomethingwithconfig(context); //語句3
由於語句 1 和語句 2 沒有依賴性,語句 1 和語句 2 可能 並行執行 或者 語句 2 先於語句 1 執行,如果這段代碼兩個線程同時執行,線程 1 執行了語句 2,而語句 1 還沒有執行完,這個時候線程 2 判斷 inited 爲 true,則執行語句 3,但由於 context 沒有初始化完成,則會導致出現未知的異常。
JMM 內存模型
Java 虛擬機規範定義了 Java 內存模型(Java Memory Model,JMM)來屏蔽各種硬件和操作系統的內存訪問差異,以實現讓 Java 程序在各種平臺下都能達到一致的內存訪問效果(C/C++ 等則直接使用物理機和 OS 的內存模型,使得程序須針對特定平臺編寫),它在多線程的情況下尤其重要。
內存劃分
JMM 的主要目標是定義程序中各個變量的訪問規則,即在虛擬機中將變量存儲到內存和從內存中取出變量這樣的底層細節。這裏的變量是指共享變量,存在競爭問題的變量,如實例字段、靜態字段、數組對象元素等,不包括線程私有的局部變量、方法參數等,因爲私有變量不存在競爭問題。可以認爲 JMM 包括內存劃分、變量訪問操作與規則兩部分。
分爲主內存和工作內存,每個線程都有自己的工作內存,它們共享主內存。
-
主內存(Main Memory)存儲所有共享變量的值。
-
工作內存(Working Memory)存儲該線程使用到的共享變量在主內存的的值的副本拷貝。
線程對共享變量的所有讀寫操作都在自己的工作內存中進行,不能直接讀寫主內存中的變量。
不同線程間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞必須通過主內存完成。
這種劃分與 Java 內存區域中堆、棧、方法區等的劃分是不同層次的劃分,兩者基本沒有關係。硬要聯繫的話,大致上主內存對應 Java 堆中對象的實例數據部分、工作內存對應棧的部分區域;從更低層次上說,主內存對應物理硬件內存、工作內存對應寄存器和高速緩存。
內存間交互規則
關於主內存與工作內存之間的交互協議,即一個變量如何從主內存拷貝到工作內存,如何從工作內存同步到主內存中的實現細節。Java 內存模型定義了 8 種原子操作來完成:
-
lock: 將一個變量標識爲被一個線程獨佔狀態
-
unclock: 將一個變量從獨佔狀態釋放出來,釋放後的變量纔可以被其他線程鎖定
-
read: 將一個變量的值從主內存傳輸到工作內存中,以便隨後的 load 操作
-
load: 把 read 操作從主內存中得到的變量值放入工作內存的變量的副本中
-
use: 把工作內存中的一個變量的值傳給執行引擎,每當虛擬機遇到一個使用到變量的指令時都會使用該指令
-
assign: 把一個從執行引擎接收到的值賦給工作內存中的變量,每當虛擬機遇到一個給變量賦值的指令時,都要使用該操作
-
store: 把工作內存中的一個變量的值傳遞給主內存,以便隨後的 write 操作
-
write: 把 store 操作從工作內存中得到的變量的值寫到主內存中的變量
定義原子操作的使用規則
-
不允許一個線程無原因地(沒有發生過任何 assign 操作)把數據從工作內存同步會主內存中
-
一個新的變量只能在主內存中誕生,不允許在工作內存中直接使用一個未被初始化(load 或者 assign)的變量。即就是對一個變量實施 use 和 store 操作之前,必須先自行 assign 和 load 操作。
-
一個變量在同一時刻只允許一條線程對其進行 lock 操作,但 lock 操作可以被同一線程重複執行多次,多次執行 lock 後,只有執行相同次數的 unlock 操作,變量纔會被解鎖。lock 和 unlock 必須成對出現。
-
如果對一個變量執行 lock 操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量之前需要重新執行 load 或 assign 操作初始化變量的值。
-
如果一個變量事先沒有被 lock 操作鎖定,則不允許對它執行 unlock 操作;也不允許去 unlock 一個被其他線程鎖定的變量。
-
對一個變量執行 unlock 操作之前,必須先把此變量同步到主內存中(執行 store 和 write 操作)
從上面可以看出,把變量從主內存複製到工作內存需要順序執行 read、load,從工作內存同步回主內存則需要順序執行 store、write。總結:
-
read、load、use 必須成對順序出現,但不要求連續出現。assign、store、write 同之;
-
變量誕生和初始化:變量只能從主內存 “誕生”,且須先初始化後才能使用,即在 use/store 前須先 load/assign;
-
lock 一個變量後會清空工作內存中該變量的值,使用前須先初始化;unlock 前須將變量同步回主內存;
-
一個變量同一時刻只能被一線程 lock,lock 幾次就須 unlock 幾次;未被 lock 的變量不允許被執行 unlock,一個線程不能去 unlock 其他線程 lock 的變量。
long 和 double 型變量的特殊規則
Java 內存模型要求前述 8 個操作具有原子性,但對於 64 位的數據類型 long 和 double,在模型中特別定義了一條寬鬆的規定:允許虛擬機將沒有被 volatile 修飾的 64 位數據的讀寫操作劃分爲兩次 32 位的操作來進行。即未被 volatile 修飾時線程對其的讀取 read 不是原子操作,可能只讀到 “半個變量” 值。雖然如此,商用虛擬機幾乎都把 64 位數據的讀寫實現爲原子操作,因此我們可以忽略這個問題。
先行發生原則
Java 內存模型具備一些先天的 “有序性”,即不需要通過任何同步手段(volatile、synchronized 等)就能夠得到保證的有序性,這個通常也稱爲 happens-before 原則。
如果兩個操作的執行次序不符合先行原則且無法從 happens-before 原則推導出來,那麼它們就不能保證它們的有序性,虛擬機可以隨意地對它們進行重排序。
-
程序次序規則(Program Order Rule):一個線程內,邏輯上書寫在前面的操作先行發生於書寫在後面的操作。
-
鎖定規則(Monitor Lock Rule):一個 unLock 操作先行發生於後面對同一個鎖的 lock 操作。“後面” 指時間上的先後順序。
-
volatile 變量規則(Volatile Variable Rule):對一個 volatile 變量的寫操作先行發生於後面對這個變量的讀操作。“後面” 指時間上的先後順序。
-
傳遞規則(Transitivity):如果操作 A 先行發生於操作 B,而操作 B 又先行發生於操作 C,則可以得出操作 A 先行發生於操作 C。
-
線程啓動規則(Thread Start Rule):Thread 對象的 start() 方法先行發生於此線程的每個一個動作。
-
線程中斷規則(Thread Interruption Rule):對線程 interrupt() 方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生(通過 Thread.interrupted() 檢測)。
-
線程終止規則(Thread Termination Rule):線程中所有的操作都先行發生於線程的終止檢測,我們可以通過 Thread.join() 方法結束、Thread.isAlive() 的返回值手段檢測到線程已經終止執行。
-
對象終結規則(Finaizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於他的 finalize() 方法的開始。
問題解決
原子性
-
由 JMM 直接保證的原子性變量操作包括 read、load、use、assign、store、write;
-
基本數據類型的讀寫(工作內存)是原子性的
由 JMM 的 lock、unlock 可實現更大範圍的原子性保證,但是這是 JVM 需要實現支持的功能,對於開發者則是有由 synchronized 關鍵字 或者 Lock 讀寫鎖 來保證原子性。
可見性
volatile 變量值被一個線程修改後會立即同步回主內存、變量值被其他線程讀取前立即從主內存刷新值到工作內存。即 read、load、use 三者連續順序執行,assign、store、write 連續順序執行。
synchronized/Lock 由 lock 和 unlock 的使用規則保證
-
“對一個變量執行 unlock 操作之前,必須先把此變量同步到主內存中(執行 store 和 write 操作)”。
-
"如果對一個變量執行 lock 操作,將會清空工作內存中此變量的值,在執行引擎使用這個變量之前需要重新執行 load 或 assign 操作初始化變量的值"
final 修飾的字段在構造器中一旦初始化完成,且構造器沒有把 “this” 的引用傳遞出去,則其他線程可立即看到 final 字段的值。
順序性
volatile 禁止指令重排序
synchronized/Lock “一個變量在同一個時刻只允許一條線程對其執行 lock 操作”
開發篇
volatile
被 volatile 修飾的變量能保證器順序性和可見性
順序性
- 對一個 volatile 變量的寫操作先行發生於後面對這個變量的讀操作。“後面” 指時間上的先後順序
可見性
-
當寫一個 volatile 變量時,JMM 會把該線程對應的工作內存中的共享變量刷新到主內存。
-
當讀一個 volatile 變量時,JMM 會把該線程對應的工作內存置爲無效,線程接下來將從主內存中讀取共享變量。
volatile 相比於 synchronized/Lock 是非常輕量級,但是使用場景是有限制的:
-
對變量的寫入操作不依賴於其當前值,即僅僅是讀取和單純的寫入,比如操作完成、中斷或者狀態之類的標誌
-
禁止對 volatile 變量操作指令的重排序
實現原理
volatile 底層是通過 cpu 提供的內存屏障指令來實現的。硬件層的內存屏障分爲兩種:Load Barrier 和 Store Barrier 即讀屏障和寫屏障。
內存屏障有兩個作用:
-
阻止屏障兩側的指令重排序
-
強制把寫緩衝區 / 高速緩存中的髒數據等寫回主內存,讓緩存中相應的數據失效
final
對於 final 域的內存語義,編譯器和處理器要遵守兩個重排序規則(內部實現也是使用內存屏障):
-
寫 final 域的重排序規則:在構造函數內對一個 final 域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
-
讀 final 域的重排序規則:初次讀一個包含 final 域的對象的引用,與隨後初次讀這個 final 域,這兩個操作之間不能重排序。
public class FinalExample {
int i;//普通域
final int j;//final域
static FinalExample obj;
public FinalExample () {
i = 1;//寫普通域。對普通域的寫操作【可能會】被重排序到構造函數之外
j = 2;//寫final域。對final域的寫操作【不會】被重排序到構造函數之外
}
// 寫線程A執行
public static void writer () {
obj = new FinalExample ();
}
// 讀線程B執行
public static void reader () {
FinalExample object = obj;//讀對象引用
int a = object.i;//讀普通域。可能會看到結果爲0(由於i=1可能被重排序到構造函數外,此時y還沒有被初始化)
int b = object.j;//讀final域。保證能夠看到結果爲2
}
}
初次讀對象引用與初次讀該對象包含的 final 域,這兩個操作之間存在間接依賴關係。由於編譯器遵守間接依賴關係,因此編譯器不會重排序這兩個操作。大多數處理器也會遵守間接依賴,也不會重排序這兩個操作。但有少數處理器允許對存在間接依賴關係的操作做重排序(比如 alpha 處理器),這個規則就是專門用來針對這種處理器的。
對於 final 域是引用類型,寫 final 域的重排序規則對編譯器和處理器增加了如下約束:
- 在構造函數內對一個 final 引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
synchronized
synchronized 用於修飾普通方法、修飾靜態方法、修飾代碼塊
-
確保代碼的同步執行(即不同線程間的互斥)(原子性)
-
確保對共享變量的修改能夠及時可見(可見性)
-
有效解決指令重排問題(順序性)
實現原理
使用對象的監視器 (Monitor,也有叫管程的) 進行控制
-
進入 / 加鎖時執行字節碼指令 MonitorEnter
-
退出 / 解鎖時執行字節碼指令 MonitorExit
-
當執行代碼有異常退出方法 / 代碼段時,會自動解鎖
使用哪個對象的監視器:
-
修飾對象方法時,使用當前對象的監視器
-
修飾靜態方法時,使用類類型(Class 的對象)監視器
-
修飾代碼塊時,使用括號中的對象的監視器
-
必須爲 Object 類或其子類的對象
MonitorEnter(加鎖):
-
每個對象都有一個關聯的監視器。
-
監視器被鎖住,當且僅當它有屬主 (Owner) 時。
-
線程執行 MonitorEnter 就是爲了成爲 Monitor 的屬主。
-
如果 Monitor 對象的記錄數 (Entry Count,擁有它的線程的重入次數) 爲 0, 將其置爲 1,線程將自己置爲 Monitor 對象的屬主。
-
如果 Monitor 的屬主爲當前線程,就會重入監視器,將其記錄數增一。
-
如果 Monitor 的屬主爲其它線程,當前線程會阻塞,直到記錄數爲 0,纔會 去競爭屬主權。
MonitorExit(解鎖):
-
執行 MonitorExit 的線程一定是這個對象所關聯的監視器的屬主。
-
線程將 Monitor 對象的記錄數減一。
-
如果 Monitor 對象的記錄數爲 0,線程就會執行退出動作,不再是屬主。
-
此時其它阻塞的線程就被允許競爭屬主。
對於 MonitorEnter、MonitorExit 來說,有兩個基本參數:
-
線程
-
關聯監視器的對象
關鍵結構
在 JVM 中,對象在內存中的佈局分爲三塊區域:對象頭、實例數據、對齊填充。如下:
實例變量
-
存放類的屬性數據信息,包括父類的屬性信息
-
如果是數組的實例變量,還包括數組的長度
-
這部分內存按 4 字節對齊
填充數據
-
由於虛擬機要求對象起始地址必須是 8 字節的整數倍
-
填充數據僅僅是爲了字節對齊
-
保障下一個對象的起始地址爲 8 的整數倍
-
長度可能爲 0
對象頭(Object Header)
-
對象頭由 Mark Word 、Class Metadata Address(類元數據地址) 和 數組長度(對象爲數組時)組成
-
在 32 位和 64 位的虛擬機中,Mark Word 分別佔用 32 字節和 64 字節,因此稱其爲 word
Mark Word 存儲的並非對象的 實際業務數據(如對象的字段值),屬於 額外存儲成本。爲了節約存儲空間,Mark Word 被設計爲一個 非固定的數據結構,以便在儘量小的空間中存儲儘量多的數據,它會根據對象的狀態,變換自己的數據結構,從而複用自己的存儲空間。
鎖的狀態共有 4 種: 無鎖、偏向鎖、輕量級鎖、重量級鎖。隨着競爭的增加,鎖的使用情況如下:
無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖
其中偏向鎖和輕量級鎖是從 JDK 6 時引入的,在 JDK 6 中默認開啓。鎖的升級 (鎖膨脹,inflate) 是單向的,只能從低到高(從左到右)。不會出現 鎖的降級。
偏向鎖
當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設爲 “01” (可偏向),即偏向模式。同時使用 CAS 操作把獲取到這個鎖的線程的 ID 記錄在對象的 Mark Word 之中,如果 CAS 操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不再進行任何同步操作。
當有另外一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖對象目前是否處於被鎖定的狀態,撤銷偏向(Revoke Bias)後恢復到未鎖定(標誌位爲 “01”,不可偏向)或 輕量級鎖定(標誌位爲 “00”)的狀態,後續的同步操作就進入輕量級鎖的流程。
輕量級鎖
進入到輕量級鎖說明不止一個線程嘗試獲取鎖,這個階段會通過自適應自旋 CAS 方式獲取鎖。如果獲取失敗,則進行鎖膨脹,進入重量級鎖流程,線程阻塞。
重量級鎖
重量級鎖是通過系統的線程互斥鎖來實現的,代價最昂貴
ContentionList,CXQ,存放最近競爭鎖的線程
-
LIFO,單向鏈表
-
很多線程都可以把請求鎖的線程放入隊列中
-
但只有一個線程能將線程出隊
EntryLis,表示勝者組
-
雙向鏈表
-
只有擁有鎖的線程纔可以訪問或變更 EntryLis
-
只有擁有鎖的線程在釋放鎖時,並且在 EntryList 爲空、ContentionList 不爲 空的情況下,才能將 ContentionList 中的線程全部出隊,放入到 EntryList 中
WaitSet,存放處於等待狀態的線程
-
將進行 wait() 調用的線程放入 WaitSet
-
當進行 notify()、notifyAll() 調用時,會將線程放入到 ContentionList 或 EntryList 隊列中
注意:
-
對一個線程而言,在任何時候最多隻處於三個集合中的一個
-
處於這三個集合中的線程,均爲 BLOCKED 狀態,底層使用互斥量來進行阻塞
當一個線程成功獲取到鎖時 對象監視器的 owner 字段從 NULL 變爲非空,指向此線程 必須將自己從 ContentionList 或 EntryList 中出隊
競爭型的鎖傳遞機制 線程釋放鎖時,不保證後繼線程一定可以獲得到鎖,而是後繼線程去競爭鎖
OnDeck,表示準備就緒的線程,保證任何時候都只有一個線程來直接競爭 鎖
-
在獲取鎖時,如果發生競爭,則使用自旋鎖來爭用,如果自旋後仍得不 到,再放入上述隊列中。
-
自旋可以減少 ContentionList 和 EntryList 上出隊入隊的操作,也就是減少了內部 維護的這些鎖的爭用。
OS 互斥鎖
重量級鎖是通過操作系統的線程互斥鎖來實現的,在 Linux 下,鎖所用的技術是 pthead_mutex_lock / pthead_mutex_unlock,即線程間的互斥鎖。
線程互斥鎖是基於 futex(Fast Userspace Mutex)機制實現的。常規的操作系統的同步機制 (如 IPC 等),調用時都需要陷入到內核中執行,即使沒有競爭也要執行一次陷入操作 (int 0x80,trap)。而 futex 則是內核態和用戶態的混合,無競爭時,獲取鎖和釋放鎖都不需要陷入內核。
初始分配
首先在內存分配 futex 共享變量,對線程而言,內存是共享的,直接分配 (malloc) 即可,爲整數類型,初始值爲 1。
獲取鎖
使用 CAS 對 futex 變量減 1,觀察其結果:
-
如果由 1 變爲 0,表示無競爭,繼續執行
-
如果小於 0,表示有競爭,調用 futex(..., FUTEX_WAIT, ...) 使當前線程休眠
釋放鎖
使用 CAS 給 futex 變量加 1
-
如果 futex 變量由 0 變爲 1,表示無競爭,繼續執行
-
如果 futex 變量變化前爲負值,表示有競爭,調用 futex(..., FUTEX_WAKE, ...) 喚醒一個或多個等待線程
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/dFh4WN1je8VmFdYX8Czhew