1. 程式人生 > >JVM—虛擬機器記憶體模型與高效併發

JVM—虛擬機器記憶體模型與高效併發

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種操作,用來實現主記憶體與工作記憶體之間的具體互動協議:

lock
unlock
read
load
use
assign
store
write

如果要把一個變數從主記憶體中複製到工作記憶體,就需要按順尋地執行 read 和 load 操作,如果把變數從工作記憶體中同步回主記憶體中,就要按順序地執行 store 和 writ e操作。Java記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證必須是連續執行。也就是 read 和 load

 之間, store 和 write 之間是可以插入其他指令的,如對主記憶體中的變數 a 、 b 進行訪問時,可能的順序是 read a , read b , load b , load a 。

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

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

此外,虛擬機器還對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就可以對它們任意地排序。

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

程式次序規則(Program Order Rule)
管理鎖定規則(Monitor Lock Rule)
volatile變數規則(Volatile Variable Rule)
執行緒啟動規則(Thread Start Rule)
執行緒終止規則(Thread Termination Rule)
執行緒中斷規則(Thread Interruption Rule)
物件終結規則(Finilizer Rule)
傳遞性(Transitivity)

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

如果兩個操作訪問同一個變數,且這兩個操作有一個為寫操作,此時這兩個操作就存在資料依賴性這裡就存在三種情況: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的關係就稱為一對一的執行緒模型。

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

2.2 執行緒安全

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

互斥同步

在Java中最基本的使用同步方式是使用 sychronized 關鍵字,該關鍵字在被編譯之後會在同步程式碼塊前後形成 monitorenter 和 monitorexit 位元組碼指令。這兩個位元組碼都需要一個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操作存在一些問題:

AtomicReference

無同步方案

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

2.3 鎖優化

自旋鎖和自適應自旋

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

自旋鎖在JDK 1.4.2中就已經引入,只不過預設是關閉的,可以使用 -XX:+UseSpinnin g引數來開啟,在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相關的那一部分。但真正去研究併發和併發包的內容,還有許多的原始碼需要我們去閱讀,僅僅一篇文章的篇幅顯然無法全部覆蓋。

在此我向大家推薦一個Java高階群 :725633148裡面會分享一些資深架構師錄製的視訊錄影:(有Spring,MyBatis,Netty原始碼分析,高併發、高效能、分散式、微服務架構的原理,JVM效能優化、分散式架構)等這些成為架構師必備的知識體系 進群馬上免費領取,目前受益良多!