天天看點

深入了解JVM—Java記憶體模型

計算機運算速度與記憶體讀寫存儲速度相差好幾個量級,大量的時間都浪費在了IO上,導緻性能降低。而JMM就是為了解決這類問題。

并發的一個場景:服務端同時對多個用戶端提供服務。

衡量一個服務性能的高低好壞,每秒事務處理數Transactions Per Scend,TPS,是最重要的名額之一,代表一秒内服務端平均響應的請求總數,TPS的值和并發能力聯系非常密切。

對于計算量相同的任務,程式線程并發協調得有條不紊,效率自然就會越高;反之,程式之間頻繁阻塞甚至死鎖,将會大大降低程式的并發能力。

服務端,Java最擅長的領域之一。

硬體的效率與一緻性

大多數運算中,處理器都要和記憶體進行互動,如讀取資料、存儲結果等。由于計算機儲存設備和處理器運算速度有幾個數量級的差距,是以現代計算機系統都不得不加入一層讀寫速度盡可能接近處理器運算速度的高緩存Cache來作為記憶體與處理器之間的緩沖:将運算需要使用的資料複制到緩存中,讓運算能快速進行,當運算結束後再從緩存中同步回記憶體,這樣處理器就無須等待緩慢的記憶體讀寫了。

以上為計算機系統帶來了更高的複雜度,因為引入了緩存的一緻性Cache Coherence的問題。在多處理器系統中,每個處理器都有自己的高速緩存,又共享同一主記憶體,可能導緻各自的緩存資料不一緻。需要各處理器通路緩存時遵循一些協定,在讀寫時根據協定來操作,如MSI,MESI,MOSI,Snapse,Firefly,dRAGON Protocol等。

記憶體模型,在特定的操作協定下對特定的記憶體或高速緩存進行讀寫通路的過程抽象。

處理器還可能會對輸入的代碼進行亂序執行優化,之後又将亂序結果重組,保證該結果與順序執行結果一緻,還有指令重排序優化。

Java記憶體模型

JMM,Java Memory Model,用來屏蔽掉各種硬體和作業系統之間的記憶體通路差異,以實作讓Java程式在各平台下都能達到一緻的記憶體通路效果。JDK1.5

主記憶體與工作記憶體

JMM主要目标:定義程式中各個變量的通路規則,即在虛拟機中将變量存儲到記憶體和從記憶體取出變量這樣的底層細節。

此處變量指執行個體字段。靜态字段和構成數組對象的元素,但不包括局部變量,因為其是線程私有的,不會被共享,也就不會有競争問題。

JMM規定所有變量均存儲在主記憶體(虛拟機記憶體的一部分)中。每條線程還有自己的工作記憶體,類比高速緩存,線程的工作記憶體中儲存了該線程使用到的變量的主記憶體副本拷貝。線程對變量的所有操作都在工作記憶體中,不能直接在主記憶體中讀寫操作。不同線程之間也不能直接通路對方的工作記憶體中的變量。隻能通過主記憶體來傳遞變量值。

主記憶體直接對應于實體硬體的記憶體,工作記憶體優先存儲于寄存器和高速緩存,因為程式運作時主要通路讀寫的是工作記憶體。

深入了解JVM—Java記憶體模型

記憶體間的互動操作

關于主記憶體與工作記憶體之間具體的互動協定,即一個變量如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體等的細節,Java記憶體模型定義了8種操作來完成,虛拟機實作時必須保證下面提及的每一種操作都是原子操作。long 和double在store,read,write,load在某些平台上允許有例外。

lock(鎖定):作用于主記憶體的變量,它把一個變量标志為一條線程獨占的狀态。

unlock(解鎖):作用于主記憶體中的變量,它把一個處于鎖定狀态的變量釋放出來,釋放後的變量才可以被其他線程鎖定。

read(讀取):作用于主記憶體的變量,它把一個變量的值從主記憶體傳輸到線程的工作記憶體中,以便随後的load動作使用。

load(載入):作用于工作記憶體的變量,它把read操作從主記憶體中得到的變量值放入工作記憶體的變量副本中。

use(使用):作用于工作記憶體的變量,它把工作記憶體中一個變量的值傳遞給執行引擎,每當虛拟機遇到一個需要使用到變量的位元組碼指令時将執行這個操作。

assign(指派):作用于工作記憶體的變量,它把一個從執行引擎接受到的值賦給工作記憶體的變量,遇到指派的位元組碼時執行。

store(存儲):作用于工作記憶體的變量,它把工作記憶體中一個變量的值傳送到主記憶體中,以便随後的write操作使用。

write(寫入):作用于主記憶體中的變量,它把store操作從主記憶體中得到的變量值放入主記憶體的變量中。

深入了解JVM—Java記憶體模型

以上操作,僅保持順序執行即可,不用保證連續執行。如 read a read b load a load b。

注意:

①不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主記憶體不接受的情況。

②不允許一個線程丢棄它的最近assign操作,即變量在工作記憶體中改變了之後必須把該變化同步回主記憶體。

③不允許一個線程無原因的(沒有發生過任何assign操作)把資料從線程的工作記憶體同步回主記憶體中。

④一個新的變量隻能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變量,就是對一個變量執行use和store之前必須先執行過了assign和load操作。

⑤一個變量在同一個時刻隻允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,隻有執行相同次數的unlock操作,變量才會被解鎖。

⑥如果對一個變量執行lock操作,将會清空工作記憶體中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作初始化變量的值。

⑦如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定住的變量。

⑧對一個變量執行unlock操作之前,必須先把此變量同步回主記憶體中(執行store write)。

以上可以完全确定Java程式中哪些記憶體通路操作在并發下是安全的。

對于volatile型變量的特殊規則

volatile,Java虛拟機提供的最輕量級的同步機制。

volatile第一可以保證變量對所有線程的可見性,即一條線程修改了變量的值,新值對于其他線程來說是可以立即得知的。普通變量線上程間傳遞需要經過主記憶體來完成。

volatile變量在各個線程的工作記憶體中不存在一緻性問題(即時存在,由于每次使用之前都得重新整理,執行引擎看不到不一緻的情況,是以認為是 不存在一緻性問題),但如果運算操作不是原子操作,導緻volatile變量的運算在并發下一樣是不安全的。依然沒法保證volatile同步的正确性。如一個volatile變量,在某線程中值已更新,後續步驟還未開始時,另一些線程迅速更新了該值,使得這個值過期。

由于volatile變量隻能保證可見性,在不符合以下兩條規則的運算場景中,仍需要加鎖*synchronized或java.util.concurrent中的原子類來保證原子性(如果把一個事務可看作是一個程式,它要麼完整的被執行,要麼完全不執行。這種特性就叫原子性*):

①對變量的寫入操作不依賴于該變量的目前值(比如a=0;a=a+1的操作,整個流程為a初始化為0,将a的值在0的基礎之上加1,然後指派給a本身,很明顯依賴了目前值),或者確定隻有單一線程修改變量。

②該變量不會與其他狀态變量納入不變性條件中。(當變量本身是不可變時,volatile能保證安全通路,比如雙重判斷的單例模式。但一旦其他狀态變量參雜進來的時候,并發情況就無法預知,正确性也無法保障)。

/**
 * 基于雙重判斷的單例模式
 */
public class Singleton {

    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
            Singleton.getInstance();
    }
}
//volatile修飾的變量在指派後會多執行一個lock addl..的操作,相當于一個記憶體屏障(Memory Barrier/Memory Fence ,指重排序時不能把後面的指令重排序到記憶體屏障之前的位置,也意味着在該操作時,所有之前的指令操作都已經執行完畢),隻有一個CPU通路記憶體時并不需要,但如果有多個CPU通路同一記憶體,且其中一個在觀測另一個,就需要記憶體屏障來保證一緻性。
           

volatile還有個特性就是,可以禁止指令進行重排序優化。普通變量僅僅會保證在該方法的執行過程中所有依賴指派結果的地方都能擷取到正确的結果,而不能保證變量指派操作的順序與程式代碼中的執行順序一緻。如下例子:

Map configOptions;
char [] configText;

//此變量必須定義為volatile
volatile boolean initialized = false;

//假設一下代碼線上程A中執行,模拟讀取配置資訊,當讀取完成後,将initialized設定為true來通知其他線程配置可使用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;

//假設以下代碼線上程B中執行,等待initialized 為true,代表線程A已經把配置資訊初始化完成
while(!initialized ){
    sleep();
}
//使用線程A中初始化好的配置資訊
doSomethingWithConfig();
           

如果initialized 沒有使用volatile修飾,就可能由于指令重排的優化,導緻位于線程A中的最後一句代碼“initialized = true”被提前執行,這樣線上程B中使用配置資訊的代碼就可能出現錯誤。

某些情況下volatile的同步機制比鎖要好,但很難量化這種優勢。volatile自己和自己比較,它的讀操作的性能消耗和普通變量幾乎沒啥差別,但寫操作要慢一些,因為需要在本地代碼中插入許多記憶體屏障指令來保證處理器不發生亂序執行。即便如此,在大多數場景下,volatile總開銷仍然比鎖要低,根據volatile語義是否滿足場景來選擇。如果情況不合适,就使用傳統的synchronized關鍵字同步共享變量的通路,用來保證程式正确性(這個關鍵字的性能會随着jvm不斷完善而不斷提升,将來性能會慢慢逼近volatile)。

volatile變量V,W,線程T,進行read load use assign store write操作:

①對于T來說,必須保證對V的load和use連續一起出現,即在工作記憶體中,每次使用V前都必須先從主記憶體中重新整理最新值,用于保證能看到其他線程對V的修改後的值。

②T對V的assign和store必須連續一起出現。即在工作記憶體中,每次修改V後都必須立刻同步回主記憶體中,保證其他線程看到自己對V 的修改。

③假定A是T對V的use assign,F是A相關聯的load或store;

P是和F相應的對變量V的read或write;

同樣,B是T對W的use或assign,G是B相關聯的load或store,

Q是G相應的對變量W的read或write。

如果A先于B,那麼P先于Q(volatile修飾的變量不會被指令重排序優化,保證代碼的執行順序與程式順序相同)。

對于long和double變量的特殊規則

記憶體模型的八個操作都具有原子性。

對于64位的資料類型(long和double),在模型中特别定義了一條寬松的規定:允許虛拟機将沒有被volatile修飾的64位資料的讀寫劃分為兩次32位的操作來進行,即允許虛拟機不保證64位資料類型的load、store、read和write這四個操作的原子性。虛拟機幾乎都選擇将64位資料的讀寫操作作為原子操作來對待,是以在編寫代碼時一般不需要用到将long和double變量專門聲明為volatile。

原子性、可見性與有序性

原子性

由Java記憶體模型來直接保證的原子性變量操作包括read、load、use、assign、store和write這六個,我們可以大緻的認為基本資料類型的通路讀寫是具備原子性的(long和double除外)。Java代碼中的同步塊即synchronized關鍵字,是以在synchronized塊之間的操作也具備原子性(lock和unlock未直接開放給使用者使用)。内部是通過位元組碼指令monitorenter和monitorexit來實作。

可見性

就是當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。Java記憶體模型是通過在變量修改後将新值同步回主記憶體,在變量讀取前從主記憶體重新整理變量值。關鍵字synchronized和final也能保證可見性。首先同步塊是因為對變量執行unlock操作之前,必須先把此變量同步回主記憶體中。而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦被初始化完成,并且構造器沒有把this指針傳遞出去,那麼在其他線程中就能看見final字段的值。

//如下所示,i,j都具備可見性,無需同步就能被其他線程正确通路
public static final int i;
public final int j;
static{
    i=;
    //...
}
{
    j=;
    //...
}
           

有序性

Java程式的天然有序性:在本線程内觀察,所有操作都是有序的;在一個線程中觀察另外一個線程,所有操作都是無序的。(前半句是指線程内表現為串行的語義,後半句是指令重排序現象和工作記憶體與主記憶體同步延遲現象)。

用synchronized和volatile關鍵字來保證線程操作之間的有序性。volatile本省就包含禁止指令重排序的語義,而synchronized則是因為:一個變量在同一時刻隻允許一條線程對齊進行lock操作。這個規則決定了持有同一個鎖的兩個同步塊隻能串行的進入。

大部分并發控制操作都能用synchronized來完成。

先行發生原則-記憶體模型中兩操作之間的偏序關系

如果Java記憶體模型中所有的有序性都隻靠volatile和synchronized來完成,那麼有一些操作将會變得很繁瑣。java記憶體模型中的一個重點原則——先行發生原則(Happens-Before),使用這個原則作為依據,來指導你判斷是否存線上程安全和競争問題。

以下先行發生關系無須任何同步器協助就已經存在,不在此列,且無法從下面的規則中推導出來,虛拟機可以對它們随意進行重排序:

程式次序規則:線上程内,按照程式代碼順序,書寫在前面的操作先行發生于書寫在後面的操作。如果A操作在B操作之前(比如A代碼在B代碼上面,或者由A程式調用B程式),那麼在這個線程中,A操作将在B操作之前執行。

管理鎖定規則:一個unlock操作先于後面(時間上的先後順序)對同一個鎖的lock操作。

volatile變量規則:對一個volatile變量的寫操作必須在後面(時間上的先後順序)對該變量的讀操作之前發生。

線程啟動規則:線程的Thread.start()必須在該線程所有其他操作之前發生。

線程終止規則:線程中所有操作都先行發生于該線程的終止檢測。可以通過Thread.join()方法結束、Thread.isAlive()的傳回值判斷線程是否終止。

線程中斷規則:對線程interrupt()方法的調用必須在被中斷線程的代碼檢測到interrupt事件發生之前執行。可以通過Thread.interrupted()方法檢測到是否發生中斷;

對象終結規則:對象的初始化(構造函數的調用)必須在該對象的finalize()方法發生之前完成。

傳遞性:如果A先行發生于B,B先行發生于C,那麼A先行發生于C。

接下來從下面這個例子感受一下“時間上的先後順序”“與”“先行發生”之間有什麼不同。

private int value = ;
public void setValue(int value){
    this.value = value;
}
public int getValue(){
    return value;
}
           

假設存線上程A和B,線程A先調用setValue(1),然後線程B調用了同一個對象的getValue(),那麼線程B傳回的值是什麼?

分析:兩個線程調用,不在一個線程中,是以程式次序規則不适用;沒有同步塊—管程鎖定規則不适用;value沒有被volatile修飾,是以volatile變量規則不适用;後面的線程啟動、終止、中斷規則和對象終結規則也扯不上關系,是以盡管在時間上A先于B,但無法确定B中getValue()傳回值結果,是以我們說這裡的操作時線程不安全的。

如何修複呢?可以為set、get方法定義為synchronized方法,這樣可以使用管程鎖定規則;或者把value設定為volatile變量,由于set方法對value的修改不依賴value的原值,滿足volatile關鍵字使用場景。

并發安全問題都必須以先行發生原則為準。

繼續閱讀