JVM掃盲-3:虛擬機器記憶體模型與高效併發

WngShhng發表於2019-02-27

當多個執行緒訪問同一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲取正確的結果,那這個物件是執行緒安全的。 關於定義的理解這是一個仁者見仁智者見智的事情。出現執行緒安全的問題一般是因為主記憶體和工作記憶體資料不一致性和重排序導致的,而解決執行緒安全的問題最重要的就是理解這兩種問題是怎麼來的,那麼,理解它們的核心在於理解 java 記憶體模型(JMM)。

1、虛擬機器記憶體模型 JMM

Java 記憶體模型,即 Java Memory Model,簡稱 JMM,它是一種抽象的概念,或者是一種協議,用來解決在併發程式設計過程中記憶體訪問的問題,同時又可以相容不同的硬體和作業系統,JMM 的原理與硬體一致性的原理類似。在硬體一致性的實現中,每個 CPU 會存在一個快取記憶體,並且各個 CPU 通過與自己的快取記憶體互動來向共享記憶體中讀寫資料。

如下圖所示,在 Java 記憶體模型中,所有的變數都儲存在主記憶體。每個 Java 執行緒都存在著自己的工作記憶體,工作記憶體中儲存了該執行緒用得到的變數的副本,執行緒對變數的讀寫都在工作記憶體中完成,無法直接操作主記憶體,也無法直接訪問其他執行緒的工作記憶體。當一個執行緒之間的變數的值的傳遞必須經過主記憶體。

當兩個執行緒 A 和執行緒 B 之間要完成通訊的話,要經歷如下兩步:

  1. 執行緒 A 從主記憶體中將共享變數讀入執行緒 A 的工作記憶體後並進行操作,之後將資料重新寫回到主記憶體中;
  2. 執行緒 B 從主存中讀取最新的共享變數

volatile 關鍵字使得每次 volatile 變數都能夠強制重新整理到主存,從而對每個執行緒都是可見的。

虛擬機器記憶體模型

需要注意的是,JMM 與 Java 記憶體區域的劃分是不同的概念層次,更恰當說 JMM 描述的是一組規則,通過這組規則控制程式中各個變數在共享資料區域和私有資料區域的訪問方式。在 JMM 中主記憶體屬於共享資料區域,從某個程度上講應該包括了堆和方法區,而工作記憶體資料執行緒私有資料區域,從某個程度上講則應該包括程式計數器、虛擬機器棧以及本地方法棧。

記憶體間互動的操作

上面介紹了 JMM 中主記憶體和工作記憶體互動以及執行緒之間通訊的原理,但是具體到各個記憶體之間如何進行變數的傳遞,JMM 定義了 8 種操作,用來實現主記憶體與工作記憶體之間的具體互動協議:

  1. lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔狀態;
  2. unlock(解鎖):作用於主記憶體變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定;
  3. read(讀取):作用於主記憶體變數,把一個變數值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的 load 動作使用;
  4. load(載入):作用於工作記憶體的變數,它把 read 操作從主記憶體中得到的變數值放入工作記憶體的變數副本中;
  5. use(使用):作用於工作記憶體的變數,把工作記憶體中的一個變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用變數的值的位元組碼指令時將會執行這個操作;
  6. assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作;
  7. store(儲存):作用於工作記憶體的變數,把工作記憶體中的一個變數的值傳送到主記憶體中,以便隨後的 write 的操作;
  8. write(寫入):作用於主記憶體的變數,它把 store 操作從工作記憶體中一個變數的值傳送到主記憶體的變數中。

如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順尋地執行 readload 操作,如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行 storewrite 操作。Java 記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證必須是連續執行。也就是 readload 之間,storewrite 之間是可以插入其他指令的,如對主記憶體中的變數 ab 進行訪問時,可能的順序是 read aread bload bload a

Java記憶體模型還規定了在執行上述八種基本操作時,必須滿足如下規則:

  1. 不允許 readloadstorewrite 操作之一單獨出現;
  2. 不允許一個執行緒丟棄它的最近 assign 的操作,即變數在工作記憶體中改變了之後必須同步到主記憶體中;
  3. 不允許一個執行緒無原因地(沒有發生過任何 assign 操作)把資料從工作記憶體同步回主記憶體中;
  4. 一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數。即就是對一個變數實施 usestore 操作之前,必須先執行過了 assignload 操作;
  5. 一個變數在同一時刻只允許一條執行緒對其進行 lock 操作,lockunlock 必須成對出現;
  6. 如果對一個變數執行 lock 操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前需要重新執行 loadassign 操作初始化變數的值;
  7. 如果一個變數事先沒有被 lock 操作鎖定,則不允許對它執行 unlock 操作,也不允許去unlock一個被其他執行緒鎖定的變數;
  8. 對一個變數執行 unlock 操作之前,必須先把此變數同步到主記憶體中(執行 storewrite 操作)。

此外,虛擬機器還對 voliate 關鍵字和 long 及 double 做了一些特殊的規定。

voliate 關鍵字的兩個作用

  1. 保證變數的可見性:當一個被 voliate 關鍵字修飾的變數被一個執行緒修改的時候,其他執行緒可以立刻得到修改之後的結果。當一個執行緒向被 voliate 關鍵字修飾的變數寫入資料的時候,虛擬機器會強制它被值重新整理到主記憶體中。當一個執行緒用到被 voliate 關鍵字修飾的值的時候,虛擬機器會強制要求它從主記憶體中讀取。
  2. 遮蔽指令重排序:指令重排序是編譯器和處理器為了高效對程式進行優化的手段,它只能保證程式執行的結果時正確的,但是無法保證程式的操作順序與程式碼順序一致。這在單執行緒中不會構成問題,但是在多執行緒中就會出現問題。非常經典的例子是在單例方法中同時對欄位加入 voliate,就是為了防止指令重排序。為了說明這一點,可以看下面的例子。

我們以下面的程式為例來說明 voliate 是如何防止指令重排序:

    public class Singleton {
        private volatile static Singleton singleton;

        private Singleton() {}

        public static Singleton getInstance() {
            if (singleton == null) { // 1
                sychronized(Singleton.class) {
                    if (singleton == null) {
                        singleton = new Singleton(); // 2
                    }
                }
            }
            return singleton;
        }
    } 
複製程式碼

實際上當程式執行到 2 處的時候,如果我們沒有使用 voliate 關鍵字修飾變數 singleton,就可能會造成錯誤。這是因為使用 new 關鍵字初始化一個物件的過程並不是一個原子的操作,它分成下面三個步驟進行:

  1. 給 singleton 分配記憶體
  2. 呼叫 Singleton 的建構函式來初始化成員變數
  3. 將 singleton 物件指向分配的記憶體空間(執行完這步 singleton 就為非 null 了)

如果虛擬機器存在指令重排序優化,則步驟 2 和 3 的順序是無法確定的。如果 A 執行緒率先進入同步程式碼塊並先執行了 3 而沒有執行 2,此時因為 singleton 已經非 null。這時候執行緒 B 到了 1 處,判斷 singleton 非 null 並將其返回使用,因為此時 Singleton 實際上還未初始化,自然就會出錯。

但是特別注意在 jdk 1.5 以前的版本使用了 volatile 的雙檢鎖還是有問題的。其原因是Java 5以前的JMM(Java 記憶體模型)是存在缺陷的,即時將變數宣告成 volatile 也不能完全避免重排序,主要是 volatile 變數前後的程式碼仍然存在重排序問題。這個 volatile 遮蔽重排序的問題在 jdk 1.5 (JSR-133) 中才得以修復,這時候 jdk 對 volatile 增強了語義,對 volatile 物件都會加入讀寫的記憶體屏障,以此來保證可見性,這時候 2-3 就變成了程式碼序而不會被 CPU 重排,所以在這之後才可以放心使用 volatile.

對 long 及 double 的特殊規定

虛擬機器除了對 voliate 關鍵字做了特殊規定,還對 long 及 double 做了一些特殊的規定:允許沒有被 volatile 修飾的 long 和 double 型別的變數讀寫操作分成兩個 32 位操作。也就是說,對 long 和 double 的讀寫是非原子的,它是分成兩個步驟來進行的。但是,你可以通過將它們宣告為 voliate 的來保證對它們的讀寫的原子性。

先行發生原則(happens-before) & as-if-serial

Java 記憶體模型是通過各種操作定義的,JMM 為程式中所有的操作定義了一個偏序關係,就是先行發生原則 (Happens-before)。它是判斷資料是否存在競爭、執行緒是否安全的主要依據。想要保證執行操作B的執行緒看到操作 A 的結果,那麼在 A 和 B 之間必須滿足 Happens-before 關係,否則 JVM 就可以對它們任意地排序。

先行發生原則主要包括下面幾項,當兩個變數之間滿足以下關係中的任意一個的時候,我們就可以判斷它們之間的是存在先後順序的,序列執行的。

  1. 程式次序規則 (Program Order Rule):在同一個執行緒中,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操縱。準確的說是程式的控制流順序,考慮分支和迴圈等;
  2. 管理鎖定規則 (Monitor Lock Rule):一個 unlock 操作先行發生於後面(時間上的順序)對同一個鎖的 lock 操作;
  3. volatile 變數規則 (Volatile Variable Rule):對一個 volatile 變數的寫操作先行發生於後面(時間上的順序)對該變數的讀操作;
  4. 執行緒啟動規則 (Thread Start Rule):Thread 物件的 start() 方法先行發生於此執行緒的每一個動作;
  5. 執行緒終止規則 (Thread Termination Rule):執行緒的所有操作都先行發生於對此執行緒的終止檢測,可以通過 Thread.join() 方法結束、Thread.isAlive() 的返回值等手段檢測到執行緒已經終止執行;
  6. 執行緒中斷規則 (Thread Interruption Rule):對執行緒 interrupt() 方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷時事件的發生。Thread.interrupted() 可以檢測是否有中斷髮生;
  7. 物件終結規則 (Finilizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的 finalize() 的開始;
  8. 傳遞性 (Transitivity):如果操作 A 先行發生於操作 B,操作 B 先行發生於操作 C,那麼可以得出 A 先行發生於操作 C。

不同操作時間先後順序與先行發生原則之間沒有關係,二者不能相互推斷,衡量併發安全問題不能受到時間順序的干擾,一切都要以先行發生原則為準。

如果兩個操作訪問同一個變數,且這兩個操作有一個為寫操作,此時這兩個操作就存在資料依賴性這裡就存在三種情況:1).讀後寫;2).寫後寫;3). 寫後讀,三種操作都是存在資料依賴性的,如果重排序會對最終執行結果會存在影響。編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴性關係的兩個操作的執行順序。

還有就是as-if-serial語義:不管怎麼重排序(編譯器和處理器為了提供並行度),(單執行緒)程式的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守 as-if-serial 語義。as-if-serial 語義保證單執行緒內程式的執行結果不被改變,happens-before 關係保證正確同步的多執行緒程式的執行結果不被改變。

先行發生原則 (happens-before) 和 as-if-serial 語義是虛擬機器為了保證執行結果不變的情況下提供程式的並行度優化所遵循的原則,前者適用於多執行緒的情形,後者適用於單執行緒的環境。

2、Java 執行緒

2.1 Java 執行緒的實現

在 Window 系統和 Linux 系統上,Java 執行緒的實現是基於一對一的執行緒模型,所謂的一對一模型,實際上就是通過語言級別層面程式去間接呼叫系統核心的執行緒模型,即我們在使用 Java 執行緒時,Java 虛擬機器內部是轉而呼叫當前作業系統的核心執行緒來完成當前任務。這裡需要了解一個術語,核心執行緒 (Kernel-Level Thread,KLT),它是由作業系統核心 (Kernel) 支援的執行緒,這種執行緒是由作業系統核心來完成執行緒切換,核心通過操作排程器進而對執行緒執行排程,並將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身,這也就是作業系統可以同時處理多工的原因。由於我們編寫的多執行緒程式屬於語言層面的,程式一般不會直接去呼叫核心執行緒,取而代之的是一種輕量級的程式 (Light Weight Process),也是通常意義上的執行緒,由於每個輕量級程式都會對映到一個核心執行緒,因此我們可以通過輕量級程式呼叫核心執行緒,進而由作業系統核心將任務對映到各個處理器,這種輕量級程式與核心執行緒間 1 對 1 的關係就稱為一對一的執行緒模型。

Java執行緒模型

如圖所示,每個執行緒最終都會對映到 CPU 中進行處理,如果 CPU 存在多核,那麼一個 CPU 將可以並行執行多個執行緒任務。

2.2 執行緒安全

Java中可以使用三種方式來保障程式的執行緒安全:1).互斥同步;2).非阻塞同步;3).無同步。

互斥同步

在Java中最基本的使用同步方式是使用 sychronized 關鍵字,該關鍵字在被編譯之後會在同步程式碼塊前後形成 monitorentermonitorexit 位元組碼指令。這兩個位元組碼都需要一個 reference 型別的引數來指明要鎖定和解鎖的物件。如果在 Java 程式中明確指定了物件引數,就會使用該物件,否則就會根據 sychronized 修飾的是例項方法還是類方法,去去物件例項或者 Class 物件作為加鎖物件。

synchronized 先天具有重入性:根據虛擬機器的要求,在執行 sychronized 指令時,首先要嘗試獲取物件的鎖。如果這個物件沒有被鎖定,或者當前執行緒已經擁有了該物件的鎖,就把鎖的計數器加 1,相應地執行 monitorexit 指令時會將鎖的計數器減 1,當計數器為 0 時就釋放鎖。若獲取物件鎖失敗,那當前執行緒就要阻塞等待,直到物件鎖被另外一個執行緒釋放為止。

除了使用 sychronized,我們還可以使用 JUC 中的 ReentrantLock 來實現同步,它與 sychronized 類似,區別主要表現在以下 3 個方面:

  1. 等待可中斷:當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待;
  2. 公平鎖:多個執行緒等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖無法保證,當鎖被釋放時任何在等待的執行緒都可以獲得鎖。sychronized 本身時非公平鎖,而 ReentrantLock 預設是非公平的,可以通過建構函式要求其為公平的。
  3. 鎖可以繫結多個條件:ReentrantLock 可以繫結多個 Condition 物件,而 sychronized 要與多個條件關聯就不得不加一個鎖,ReentrantLock 只要多次呼叫 newCondition 即可。

在 JDK1.5 之前,sychronized 在多執行緒環境下比 ReentrantLock 要差一些,但是在 JDK1.6 以上,虛擬機器對 sychronized 的效能進行了優化,效能不再是使用 ReentrantLock 替代 sychronized 的主要因素。

非阻塞同步

所謂非阻塞同步就是在實現同步的過程中無需將執行緒掛起,它是相對於互斥同步而言的。互斥同步本質上是一種悲觀的併發策略,而非阻塞同步是一種樂觀的併發策略。在 JUC 中的許多併發組建都是基於 CAS 原理實現的,所謂 CAS就是 Compare-And-Swape,類似於樂觀加鎖。但與我們熟知的樂觀鎖不同的是,它在判斷的時候會涉及到 3 個值:“新值”、“舊值” 和 “記憶體中的值”,在實現的時候會使用一個無限迴圈,每次拿 “舊值” 與 “記憶體中的值” 進行比較,如果兩個值一樣就說明 “記憶體中的值” 沒有被其他執行緒修改過;否則就被修改過,需要重新讀取記憶體中的值為 “舊值”,再拿 “舊值” 與 “記憶體中的值” 進行判斷。直到 “舊值” 與 “記憶體中的值” 一樣,就把 “新值” 更新到記憶體當中。

這裡要注意上面的 CAS 操作是分 3 個步驟的,但是這 3 個步驟必須一次性完成,因為不然的話,當判斷 “記憶體中的值” 與 “舊值” 相等之後,向記憶體寫入 “新值” 之間被其他執行緒修改就可能會得到錯誤的結果。JDK 中的sun.misc.Unsafe 中的 compareAndSwapInt 等一系列方法Native就是用來完成這種操作的。另外還要注意,上面的CAS操作存在一些問題:

  1. 一個典型的 ABA 的問題,也就是說當記憶體中的值被一個執行緒修改了,又改了回去,此時當前執行緒看到的值與期望的一樣,但實際上已經被其他執行緒修改過了。想要解決 ABA 的問題,則可以使用傳統的互斥同步策略。
  2. CAS 還有一個問題就是可能會自旋時間過長。因為 CAS 是非阻塞同步的,雖然不會將執行緒掛起,但會自旋(無非就是一個死迴圈)進行下一次嘗試,如果這裡自旋時間過長對效能是很大的消耗。
  3. 根據上面的描述也可以看出,CAS 只能保證一個共享變數的原子性,當存在多個變數的時候就無法保證。一種解決的方案是將多個共享變數打包成一個,也就是將它們整體定義成一個物件,並用 CAS 保證這個整體的原子性,比如AtomicReference

無同步方案

所謂無同步方案就是不需要同步。

  1. 比如一些集合屬於不可變集合,那麼就沒有必要對其進行同步。
  2. 有一些方法,它的作用就是一個函式,這在函數語言程式設計思想裡面比較常見,這種函式通過輸入就可以預知輸出,而且參與計算的變數都是區域性變數等,所以也沒必要進行同步。
  3. 還有一種就是執行緒區域性變數,比如ThreadLocal等。

2.3 鎖優化

自旋鎖和自適應自旋

自旋鎖用來解決互斥同步過程中執行緒切換的問題,因為執行緒切換本身是存在一定的開銷的。如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒“稍等一會”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只須讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖。

自旋鎖在 JDK 1.4.2 中就已經引入,只不過預設是關閉的,可以使用 -XX:+UseSpinning 引數來開啟,在 JDK 1.6 中就已經改為預設開啟了。自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的, 所以如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之如果鎖被佔用的時間很長,那麼自旋的執行緒只會白白消耗處理器資源,而不會做任何有用的工作, 反而會帶來效能的浪費。

我們可以通過引數 -XX:PreBlockSpin 來指定自旋的次數,預設值是 10 次。在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間, 比如 100 個迴圈。另一方面,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。

下面是自旋鎖的一種實現的例子:

    public class SpinLock {
        private AtomicReference<Thread> sign = new AtomicReference<>();

        public void lock() {
            Thread current = Thread.currentThread();
            while(!sign.compareAndSet(null, current)) ;
        }

        public void unlock() {
            Thread current = Thread.currentThread();
            sign.compareAndSet(current, null);
        }
    }
複製程式碼

從上面的例子我們可以看出,自旋鎖是通過 CAS 操作,通過比較期值是否符合預期來加鎖和釋放鎖的。在 lock 方法中如果 sign 中的值是 null,也就代標鎖被釋放了,否則鎖被其他執行緒佔用,需要通過迴圈來等待。在 unlock 方法中,通過將 sign 中的值設定為 null 來通知正在等待的執行緒鎖已經被釋放。

鎖粗化

鎖粗化的概念應該比較好理解,就是將多次連線在一起的加鎖、解鎖操作合併為一次,將多個連續的鎖擴充套件成一個範圍更大的鎖。

    public class StringBufferTest {
        StringBuffer sb = new StringBuffer();

        public void append(){
            sb.append("a");
            sb.append("b");
            sb.append("c");
        }
    }
複製程式碼

這裡每次呼叫 sb.append() 方法都需要加鎖和解鎖,如果虛擬機器檢測到有一系列連串的對同一個物件加鎖和解鎖操作,就會將其合併成一次範圍更大的加鎖和解鎖操作,即在第一次 append() 方法時進行加鎖,最後一次 append() 方法結束後進行解鎖。

輕量級鎖

輕量級鎖是用來解決重量級鎖在互斥過程中的效能消耗問題的,所謂的重量級鎖就是 sychronized 關鍵字實現的鎖。synchronized 是通過物件內部的一個叫做監視器鎖(monitor)來實現的。但是監視器鎖本質又依賴於底層的作業系統的 Mutex Lock 來實現的。而作業系統實現執行緒之間的切換就需要從使用者態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間。

首先,物件的物件頭中存在一個部分叫做 Mark word,其中儲存了物件的執行時資料,如雜湊碼、GC 年齡等,其中有 2bit 用於儲存鎖標誌位。

在程式碼進入同步塊的時候,如果物件鎖狀態為無鎖狀態(鎖標誌位為 “01” 狀態),虛擬機器首先將在當前執行緒的棧幀中建立一個名為 鎖記錄Lock Record) 的空間,用於儲存鎖物件目前的 Mark Word 的拷貝。拷貝成功後,虛擬機器將使用 CAS 操作嘗試將物件的 Mark Word 更新為指向 Lock Record 的指標,並將 Lock Record 裡的 owner 指標指向對的 Mark word。並且將物件的 Mark Word 的鎖標誌位變為 "00",表示該物件處於鎖定狀態。更新操作失敗了,虛擬機器首先會檢查物件的 Mark Word 是否指向當前執行緒的棧幀,如果是就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行。否則說明多個執行緒競爭鎖,輕量級鎖就要膨脹為重量級鎖,鎖標誌的變為 “10”,Mark Word 中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。 而當前執行緒便嘗試使用自旋來獲取鎖,自旋就是為了不讓執行緒阻塞,而採用迴圈去獲取鎖的過程。

從上面我們可以看出,實際上當一個執行緒獲取了一個物件的輕量級鎖之後,物件的 Mark Word 會指向執行緒的棧幀中的 Lock Record,而棧幀中的 Lock Record 也會指向物件的 Mark Word。棧幀中的 Lock Record 用於判斷當前執行緒已經持有了哪些物件的鎖,而物件的 Mark Word 用來判斷哪個執行緒持有了當前物件的鎖。當一個執行緒嘗試去獲取一個物件的鎖的時候,會先通過鎖標誌位判斷當前物件是否被加鎖,然後通過CAS操作來判斷當前獲取該物件鎖的執行緒是否是當前執行緒。

輕量級鎖不是設計用來取代重量級鎖的,因為它除了加鎖之外還增加了額外的CAS操作,因此在競爭激烈的情況下,輕量級鎖會比傳統的重量級鎖更慢。

偏向鎖

一個物件剛開始例項化的時候,沒有任何執行緒來訪問它的時候。它是可偏向的,意味著,它現在認為只可能有一個執行緒來訪問它,所以當第一個執行緒來訪問它的時候,它會偏向這個執行緒。此時,物件持有偏向鎖,偏向第一個執行緒。這個執行緒在修改物件頭成為偏向鎖的時候使用 CAS 操作,並將物件頭中的 ThreadID 改成自己的 ID,之後再次訪問這個物件時,只需要對比 ID,不需要再使用 CAS 在進行操作。

一旦有第二個執行緒訪問這個物件,因為偏向鎖不會主動釋放,所以第二個執行緒可以看到物件時偏向狀態,這時表明在這個物件上已經存在競爭了,檢查原來持有該物件鎖的執行緒是否依然存活,如果掛了,則可以將物件變為無鎖狀態,然後重新偏向新的執行緒,如果原來的執行緒依然存活,則馬上執行那個執行緒的操作棧,檢查該物件的使用情況,如果仍然需要持有偏向鎖,則偏向鎖升級為輕量級鎖,(偏向鎖就是這個時候升級為輕量級鎖的)。如果不存在使用了,則可以將物件回覆成無鎖狀態,然後重新偏向。

輕量級鎖認為競爭存在,但是競爭的程度很輕,一般兩個執行緒對於同一個鎖的操作都會錯開,或者說稍微等待一下(自旋),另一個執行緒就會釋放鎖。 但是當自旋超過一定的次數,或者一個執行緒在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖膨脹為重量級鎖,重量級鎖使除了擁有鎖的執行緒以外的執行緒都阻塞,防止 CPU 空轉。

如果大多數情況下鎖總是被多個不同的執行緒訪問,那麼偏向模式就是多餘的,可以通過 -XX:-UserBiaseLocking 禁止偏向鎖優化。

輕量級鎖和偏向鎖的提出是基於一個事實,就是大部分情況下獲取一個物件鎖的執行緒都是同一個執行緒,它在這種情形下的效率會比重量級鎖高,當鎖總是被多個不同的執行緒訪問它們的效率就不一定比重量級鎖高。因此,它們的提出不是用來取代重量級鎖的,但在一些場景中會比重量級鎖效率高,因此我們可以根據自己應用的場景通過虛擬機器引數來設定是否啟用它們。

總結

JMM 是 Java 實現併發的理論基礎,JMM 種規定了 8 種操作與8種規則,並對 voliate、long 和 double 型別做了特別的規定。

JVM 會對我們的程式碼進行重排序以優化效能,對於重排序,JMM 又提出了先行發生原則 (happens-before) 和 as-if-serial 語義,以保證程式的最終結果不會因為重排序而改變。

Java 的執行緒是通過一種輕量級進行對映到核心執行緒實現的。我們可以使用互斥同步、非阻塞同步和無同步三種方式來保證多執行緒情況下的執行緒安全。此外,Java 還提供了多種鎖優化的策咯來提升多執行緒情況下的程式碼效能。

這裡主要介紹 JMM 的內容,所以介紹的併發相關內容也僅介紹了與 JMM 相關的那一部分。但真正去研究併發和併發包的內容,還有許多的原始碼需要我們去閱讀,僅僅一篇文章的篇幅顯然無法全部覆蓋。

相關文章