JVM—虛擬機內存模型與高效併發

Java內存模型,即Java Memory Model,簡稱 JMM ,它是一種抽象的概念,或者是一種協議,用來解決在併發編程過程當中內存訪問的問題,同時又能夠兼容不一樣的硬件和操做系統,JMM的原理與硬件一致性的原理相似。在硬件一致性的實現中,每一個CPU會存在一個高速緩存,而且各個CPU經過與本身的高速緩存交互來向共享內存中讀寫數據。spring

以下圖所示,在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是如何防止指令重排序:app

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語義是虛擬機爲了保證執行結果不變的狀況下提供程序的並行度優化所遵循的原則,前者適用於多線程的情形,後者適用於單線程的環境。

二、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相關的那一部分。但真正去研究併發和併發包的內容,還有許多的源代碼須要咱們去閱讀,僅僅一篇文章的篇幅顯然沒法所有覆蓋。

注:關注做者微信公衆號,瞭解更多分佈式架構、微服務、netty、MySQL、spring、、性能優化、等知識點。

公衆號:《 Java大蝸牛 

相關文章
相關標籤/搜索