一文看懂Java鎖機制

背景知識

指令流水線

CPU的基本工做是執行存儲的指令序列,即程序。程序的執行過程其實是不斷地取出指令、分析指令、執行指令的過程。java

幾乎全部的馮•諾伊曼型計算機的CPU,其工做均可以分爲5個階段:取指令、指令譯碼、執行指令、訪存取數和結果寫回。數組

現代處理器的體系結構中,採用了流水線的處理方式對指令進行處理。指令包含了不少階段,對其進行拆解,每一個階段由專門的硬件電路、寄存器來處 理,就能夠實現流水線處理。實現更高的CPU吞吐量,可是因爲流水線處理自己的額外開銷,可能會增長延遲。緩存

cpu多級緩存

在計算機系統中,CPU高速緩存(CPU Cache,簡稱緩存)是用於減小處理器訪問內存所需平均時間的部件。在金字塔式存儲體系中它位於自頂向下的第二層,僅次於CPU寄存器。其容量遠小於內存,但速度卻能夠接近處理器的頻率。數據結構

當處理器發出內存訪問請求時,會先查看緩存內是否有請求數據。若是存在(命中),則不經訪問內存直接返回該數據;若是不存在(失效),則要先把內存中的相應數據載入緩存,再將其返回處理器。多線程

緩存之因此有效,主要是由於程序運行時對內存的訪問呈現局部性(Locality)特徵。這種局部性既包括空間局部性(Spatial Locality),也包括時間局部性(Temporal Locality)。有效利用這種局部性,緩存能夠達到極高的命中率。app

問題引入

原子性

原子性:即一個操做或者多個操做 要麼所有執行而且執行的過程不會被任何因素打斷,要麼就都不執行。函數

示例方法:{i++ (i爲實例變量)}佈局

這樣一個簡單語句主要由三個操做組成:this

  • 讀取變量i的值
  • 進行加一操做
  • 將新的值賦值給變量i

若是對實例變量i的操做不作額外的控制,那麼多個線程同時調用,就會出現覆蓋現象,丟失部分更新。spa

另外,若是再考慮上工做內存和主存之間的交互,可細分爲如下幾個操做:

  • 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操做從工做內存中獲得的變量的值寫到主內存中的變量

定義原子操做的使用規則

  1. 不容許一個線程無緣由地(沒有發生過任何assign操做)把數據從工做內存同步會主內存中
  2. 一個新的變量只能在主內存中誕生,不容許在工做內存中直接使用一個未被初始化(load或者assign)的變量。即就是對一個變量實施use和store操做以前,必須先自行assign和load操做。
  3. 一個變量在同一時刻只容許一條線程對其進行lock操做,但lock操做能夠被同一線程重複執行屢次,屢次執行lock後,只有執行相同次數的unlock操做,變量纔會被解鎖。lock和unlock必須成對出現。
  4. 若是對一個變量執行lock操做,將會清空工做內存中此變量的值,在執行引擎使用這個變量以前須要從新執行load或assign操做初始化變量的值。
  5. 若是一個變量事先沒有被lock操做鎖定,則不容許對它執行unlock操做;也不容許去unlock一個被其餘線程鎖定的變量。
  6. 對一個變量執行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原則推導出來,那麼它們就不能保證它們的有序性,虛擬機能夠隨意地對它們進行重排序。

  1. 程序次序規則(Program Order Rule):一個線程內,邏輯上書寫在前面的操做先行發生於書寫在後面的操做。
  2. 鎖定規則(Monitor Lock Rule):一個unLock操做先行發生於後面對同一個鎖的lock操做。「後面」指時間上的前後順序。
  3. volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操做先行發生於後面對這個變量的讀操做。「後面」指時間上的前後順序。
  4. 傳遞規則(Transitivity):若是操做A先行發生於操做B,而操做B又先行發生於操做C,則能夠得出操做A先行發生於操做C。
  5. 線程啓動規則(Thread Start Rule):Thread對象的start()方法先行發生於此線程的每一個一個動做。
  6. 線程中斷規則(Thread Interruption Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件的發生(經過Thread.interrupted()檢測)。
  7. 線程終止規則(Thread Termination Rule):線程中全部的操做都先行發生於線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值手段檢測到線程已經終止執行。
  8. 對象終結規則(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上出隊入隊的操做,也就是減小了內部 維護的這些鎖的爭用。

Lock

另寫一篇專門講解AQS的鎖機制的文章,期待

相關文章
相關標籤/搜索