1. 程式人生 > >Java 記憶體模型都不會,就敢在簡歷上寫熟悉併發程式設計嗎

Java 記憶體模型都不會,就敢在簡歷上寫熟悉併發程式設計嗎

# 從 PC 記憶體架構到 Java 記憶體模型 > 你知道 Java 記憶體模型 JMM 嗎?那你知道它的三大特性嗎? > > Java 是如何解決指令重排問題的? > > 既然CPU有快取一致性協議(MESI),為什麼 JMM 還需要volatile關鍵字? > 帶著問題,尤其是面試問題的學習才是最高效的。加油,奧利給! > > 文章收錄在 GitHub [JavaKeeper](https://github.com/Jstarfish/JavaKeeper) ,N線網際網路開發必備技能兵器譜 前兩天看到同學和我顯擺他們公司配的電腦多好多好,我默默打開了自己的電腦,` 酷睿 i7-4770 `,也不是不夠用嘛,4 核 8 執行緒的 CPU,也是槓槓的。 ![](https://tva1.sinaimg.cn/large/00831rSTly1gcw3ao6z60j30qi0g874v.jpg) 扯這玩意幹啥,Em~~~~ 介紹 Java 記憶體模型之前,先溫習下計算機硬體記憶體模型 ## 硬體記憶體架構 計算機在執行程式的時候,每條指令都是在 CPU 中執行的,而執行的時候,又免不了要和資料打交道。而計算機上面的資料,是存放在主存當中的,也就是計算機的實體記憶體。 計算機硬體架構簡易圖: ![](https://tva1.sinaimg.cn/large/00831rSTly1gcw2g16h50j31ho0tqq5w.jpg) 我們以多核 CPU 為例,每個CPU 核都包含**一組 「CPU 暫存器」**,這些暫存器本質上是在 CPU 記憶體中。CPU 在這些暫存器上執行操作的速度要比在主記憶體(RAM)中執行的速度快得多。 因為**CPU速率高, 記憶體速率慢,為了讓儲存體系可以跟上CPU的速度,所以中間又加上 Cache 層,就是我們說的 「CPU 快取記憶體」**。 ### CPU多級快取 由於CPU的運算速度遠遠超越了1級快取的資料I\O能力,CPU廠商又引入了多級的快取結構。通常L1、L2 是每個CPU 核有一個,L3 是多個核共用一個。 ### Cache Line Cache又是由很多個**「快取行」**(Cache line) 組成的。Cache line 是 Cache 和 RAM 交換資料的最小單位。 Cache 儲存資料是固定大小為單位的,稱為一個**Cache entry**,這個單位稱為**Cache line**或**Cache block**。給定Cache 容量大小和 Cache line size 的情況下,它能儲存的條目個數(number of cache entries)就是固定的。因為Cache 是固定大小的,所以它從主記憶體獲取資料也是固定大小。對於X86來講,是 64Bytes。對於ARM來講,較舊的架構的Cache line是32Bytes,但一次記憶體訪存只訪問一半的資料也不太合適,所以它經常是一次填兩個 Cache line,叫做 double fill。 ### 快取的工作原理 這裡的快取的工作原理和我們專案中用 memcached、redis 做常用資料的快取層是一個道理。 當 CPU 要讀取一個數據時,首先從快取中查詢,如果找到就立即讀取並送給CPU處理;如果沒有找到,就去記憶體中讀取並送給 CPU 處理,同時把這個資料所在的**資料塊**(就是我們上邊說的 Cache block)調入快取中,即把臨近的共 64 Byte 的資料也一同載入,因為臨近的資料在將來被訪問的可能性更大,可以使得以後對整塊資料的讀取都從快取中進行,不必再呼叫記憶體。 這就增加了CPU讀取快取的**命中率**(Cache hit)了。 ### 計算機層級儲存 計算機儲存系統是有層次結構的,類似一個金字塔,頂層的暫存器讀寫速度較高,但是空間較小。底層的讀寫速度較低,但是空間較大 ![](https://tva1.sinaimg.cn/large/00831rSTly1gcw2gd50fdj30vy0juq4k.jpg) ### 快取一致性 既然每個核中都有單獨的快取,那我的 4 核 8 執行緒 CPU 處理主記憶體資料的時候,不就會出現資料不一致問題了嗎? 為了解決這個問題,先後有過兩種方法:**匯流排鎖機制**和**快取鎖機制**。 匯流排鎖就是使用 CPU 提供的一個`LOCK#`訊號,當一個處理器在總線上輸出此訊號,其他處理器的請求將被阻塞,那麼該處理器就可以獨佔共享鎖。這樣就保證了資料一致性。 但是匯流排鎖開銷太大,我們需要控制鎖的粒度,所以又有了快取鎖,核心就是“**快取一致性協議**”,不同的 CPU 硬體廠商實現方式稍有不同,有MSI、MESI、MOSI等。 ### 程式碼亂序執行優化 為了使得處理器內部的運算單元儘量被充分利用,提高運算效率,處理器可能會對輸入的程式碼進行「亂序執行」**(Out-Of-Order Execution),處理器會在計算之後將亂序執行的結果重組,**亂序優化可以保證在單執行緒下該執行結果與順序執行的結果是一致的**,但不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致。 **亂序執行技術是處理器為提高運算速度而做出違背程式碼原有順序的優化**。在單核時代,處理器保證做出的優化不會導致執行結果遠離預期目標,但在多核環境下卻並非如此。 多核環境下, 如果存在一個核的計算任務依賴另一個核的計算任務的中間結果,而且對相關資料讀寫沒做任何防護措施,那麼其順序性並不能靠程式碼的先後順序來保證,處理器最終得出的結果和我們邏輯得到的結果可能會大不相同。 #### 編譯器指令重排 除了上述由處理器和快取引起的亂序之外,現代編譯器同樣提供了亂序優化。之所以出現編譯器亂序優化其根本原因在於處理器每次只能分析一小塊指令,但編譯器卻能在很大範圍內進行程式碼分析,從而做出更優的策略,充分利用處理器的亂序執行功能。 ### 記憶體屏障 儘管我們看到亂序執行初始目的是為了提高效率,但是它看來其好像在這多核時代不盡人意,其中的某些”自作聰明”的優化導致多執行緒程式產生各種各樣的意外。因此有必要存在一種機制來消除亂序執行帶來的壞影響,也就是說應該允許程式設計師顯式的告訴處理器對某些地方禁止亂序執行。這種機制就是所謂記憶體屏障。不同架構的處理器在其指令集中提供了不同的指令來發起記憶體屏障,對應在程式語言當中就是提供特殊的關鍵字來呼叫處理器相關的指令,JMM裡我們再探討。 ------ ## Java記憶體模型 Java 記憶體模型即 `Java Memory Model`,簡稱 **JMM**。 這裡的記憶體模型可不是 JVM 裡的執行時資料區。 「記憶體模型」可以理解為**在特定操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象**。 不同架構的物理計算機可以有不一樣的記憶體模型,Java虛擬機器也有自己的記憶體模型。 Java虛擬機器規範中試圖定義一種「 **Java 記憶體模型**」來**遮蔽掉各種硬體和作業系統的記憶體訪問差異**,以實現**讓 Java 程式在各種平臺下都能達到一致的記憶體訪問效果**,不必因為不同平臺上的物理機的記憶體模型的差異,對各平臺定製化開發程式。 Java 記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。這裡的變數與我們寫 Java 程式碼中的變數不同,它包括了例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數和方法引數,因為他們是執行緒私有的,不會被共享。 ### JMM 組成 - **主記憶體**:Java 記憶體模型規定了所有變數都儲存在主記憶體(Main Memory)中(此處的主記憶體與物理硬體的主記憶體RAM 名字一樣,兩者可以互相類比,但此處僅是虛擬機器記憶體的一部分)。 - **工作記憶體**:每條執行緒都有自己的工作記憶體(Working Memory,又稱本地記憶體,可與CPU快取記憶體類比),執行緒的工作記憶體中儲存了該執行緒使用到的主記憶體中的共享變數的副本拷貝。**執行緒對變數的所有操作都必須在工作記憶體進行,而不能直接讀寫主記憶體中的變數**。**工作記憶體是 JMM 的一個抽象概念,並不真實存在**。 ![](https://tva1.sinaimg.cn/large/00831rSTly1gcw2gtxpaej314o0lw0uc.jpg) ### JMM 與 JVM 記憶體結構 JMM 與 Java 記憶體區域中的堆、棧、方法區等並不是同一個層次的記憶體劃分,兩者基本沒有關係。如果一定要勉強對應,那從變數、主記憶體、工作記憶體的定義看,主記憶體主要對應 Java 堆中的物件例項資料部分,工作記憶體則對應虛擬機器棧的部分割槽域(與上圖對應著看哈)。 ![](https://tva1.sinaimg.cn/large/00831rSTly1gcw2h5qt9ij316b0qeq68.jpg) ### JMM 與計算機記憶體結構 Java 記憶體模型和硬體記憶體體系結構也沒有什麼關係。硬體記憶體體系結構不區分棧和堆。在硬體上,執行緒棧和堆都位於主記憶體中。執行緒棧和堆的一部分有時可能出現在快取記憶體和CPU暫存器中。如下圖所示: ![img](https://tva1.sinaimg.cn/large/00831rSTly1gcw2heypd6j31ee0kc76r.jpg) 當物件和變數可以儲存在計算機中不同的記憶體區域時,這就可能會出現某些問題。兩個主要問題是: - 執行緒更新(寫)到共享變數的可見性 - 讀取、檢查和寫入共享變數時的競爭條件 #### 可見性問題(Visibility of Shared Objects) 如果兩個或多個執行緒共享一個物件,則一個執行緒對共享物件的更新可能對其他執行緒不可見(當然可以用 Java 提供的關鍵字 volatile)。 假設共享物件最初儲存在主記憶體中。在 CPU 1上執行的執行緒將共享物件讀入它的CPU快取後修改,但是還沒來得及即重新整理回主記憶體,這時其他 CPU 上執行的執行緒就不會看到共享物件的更改。這樣,每個執行緒都可能以自己的執行緒結束,就出現了可見性問題,如下 ![](https://tva1.sinaimg.cn/large/00831rSTly1gcw2hs7adij30pu0hq0tr.jpg) #### 競爭條件(Race Conditions) 這個其實就是我們常說的原子問題。 如果兩個或多個執行緒共享一個物件,並且多個執行緒更新該共享物件中的變數,則可能出現競爭條件。 想象一下,如果執行緒A將一個共享物件的變數讀入到它的CPU快取中。此時,執行緒B執行相同的操作,但是進入不同的CPU快取。現線上程A執行 +1 操作,執行緒B也這樣做。現在該變數增加了兩次,在每個CPU快取中一次。 如果這些增量是按順序執行的,則變數結果會是3,並將原始值+ 2寫回主記憶體。但是,這兩個增量是同時執行的,沒有適當的同步。不管將哪個執行緒的結構寫回主記憶體,更新後的值只比原始值高1,顯然是有問題的。如下(當然可以用 Java 提供的關鍵字 Synchronized) ![](https://tva1.sinaimg.cn/large/00831rSTly1gcw2i23173j30pu0hqgml.jpg) ### JMM 特性 JMM 就是用來解決如上問題的。 **JMM是圍繞著併發過程中如何處理可見性、原子性和有序性這 3 個 特徵建立起來的** - **可見性**:可見性是指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。Java 中的 volatile、synchronzied、final 都可以實現可見性 - **原子性**:即一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。即使在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒所幹擾。 - **有序性**: 計算機在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排,一般分為以下 3 種 ![](https://tva1.sinaimg.cn/large/00831rSTly1gcrgrycnj0j31bs04k74y.jpg) 單執行緒環境裡確保程式最終執行結果和程式碼順序執行的結果一致; 處理器在進行重排序時必須要考慮指令之間的**資料依賴性**; 多執行緒環境中執行緒交替執行,由於編譯器優化重排的存在,兩個執行緒中使用的變數能否保證一致性是無法確定的,結果無法預測 ### 記憶體之間的互動操作 關於主記憶體和工作記憶體之間具體的互動協議,即一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節,Java 記憶體模型中定義了 8 種 操作來完成,虛擬機器實現必須保證每一種操作都是原子的、不可再拆分的(double和long型別例外) - **lock**(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。 - **unlock**(解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。 - **read**(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用。 - **load**(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。 - **use**(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。 - **assign**(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。 - **store**(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write操作使用。 - **write**(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。 如果需要把一個變數從主記憶體複製到工作記憶體,那就要順序地執行 **read 和 load 操作**,如果要把變數從工作記憶體同步回主記憶體,就要順序地執行 **store 和 write 操作**。注意,**Java 記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行**。也就是說 read 與 load 之間、store 與write 之間是可插入其他指令的,如對主記憶體中的變數 a、b 進行訪問時,一種可能出現順序是 read a、read b、load b、load a。 ![](https://tva1.sinaimg.cn/large/00831rSTly1gcw2ijaoarj30oq0nctae.jpg) 除此之外,Java 記憶體模型還規定了在執行上述 8 種基本操作時必須滿足如下規則 - 不允許 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 操作)。 #### long 和 double 型變數的特殊規則 Java 記憶體模型要求 lock,unlock,read,load,assign,use,store,write 這 8 個操作都具有原子性,但對於64 位的資料型別( long 或 double),在模型中定義了一條相對寬鬆的規定,允許虛擬機器將沒有被 volatile 修飾的 64 位資料的讀寫操作劃分為兩次 32 位的操作來進行,即允許虛擬機器實現選擇可以不保證 64 位資料型別的load,store,read,write 這 4 個操作的原子性,即 **long 和 double 的非原子性協定**。 如果多執行緒的情況下double 或 long 型別並未宣告為 volatile,可能會出現“半個變數”的數值,也就是既非原值,也非修改後的值。 雖然 Java 規範允許上面的實現,但商用虛擬機器中基本都採用了原子性的操作,因此在日常使用中幾乎不會出現讀取到“半個變數”的情況,so,這個瞭解下就行。 ### 先行發生原則 先行發生(happens-before)是 Java 記憶體模型中定義的兩項操作之間的偏序關係,**如果操作A 先行發生於操作B,那麼A的結果對B可見**。happens-before關係的分析需要分為**單執行緒和多執行緒**的情況: - **單執行緒下的 happens-before** 位元組碼的先後順序天然包含happens-before關係:因為單執行緒內共享一份工作記憶體,不存在資料一致性的問題。 在程式控制流路徑中靠前的位元組碼 happens-before 靠後的位元組碼,即靠前的位元組碼執行完之後操作結果對靠後的位元組碼可見。然而,這並不意味著前者一定在後者之前執行。實際上,如果後者不依賴前者的執行結果,那麼它們可能會被重排序。 - **多執行緒下的 happens-before** 多執行緒由於每個執行緒有共享變數的副本,如果沒有對共享變數做同步處理,執行緒1更新執行操作A共享變數的值之後,執行緒2開始執行操作B,此時操作A產生的結果對操作B不一定可見。 為了方便程式開發,Java 記憶體模型實現了下述的先行發生關係: - **程式次序規則:** 一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。 - **管程鎖定規則:** 一個unLock操作先行發生於後面對同一個鎖的lock操作。 - **volatile變數規則:** 對一個變數的寫操作 happens-before 後面對這個變數的讀操作。 - **傳遞規則:** 如果操作A 先行發生於操作B,而操作B又先行發生於操作C,則可以得出操作A 先行發生於操作C。 - **執行緒啟動規則:** Thread物件的start()方法先行發生於此執行緒的每個一個動作。 - **執行緒中斷規則:** 對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生。 - **執行緒終結規則:** 執行緒中所有的操作都先行發生於執行緒的終止檢測,我們可以通過`Thread.join()`方法結束、Thread.isAlive()的返回值手段檢測到執行緒已經終止執行。 - **物件終結規則:** 一個物件的初始化完成先行發生於它的 `finalize() `方法的開始 ### 記憶體屏障 上邊的一系列操作保證了資料一致性,Java中如何保證底層操作的有序性和可見性?可以通過記憶體屏障。 記憶體屏障是被插入兩個 CPU 指令之間的一種指令,用來禁止處理器指令發生重排序(像屏障一樣),從而保障**有序性**的。另外,為了達到屏障的效果,它也會使處理器寫入、讀取值之前,將主記憶體的值寫入快取記憶體,清空無效佇列,從而保障**可見性**。 eg: ``` Store1; Store2; Load1; StoreLoad; //記憶體屏障 Store3; Load2; Load3; ``` 對於上面的一組 CPU 指令(Store表示寫入指令,Load表示讀取指令),StoreLoad 屏障之前的 Store 指令無法與StoreLoad 屏障之後的 Load 指令進行交換位置,即**重排序**。但是 StoreLoad 屏障之前和之後的指令是可以互換位置的,即 Store1 可以和 Store2 互換,Load2 可以和 Load3 互換。 常見的 4 種屏障 - **LoadLoad** 屏障: 對於這樣的語句 Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢。 - **StoreStore** 屏障: 對於這樣的語句 Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見。 - **LoadStore** 屏障: 對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被執行前,保證Load1要讀取的資料被讀取完畢。 - **StoreLoad** 屏障: 對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見。它的開銷是四種屏障中最大的(沖刷寫緩衝器,清空無效化佇列)。在大多數處理器的實現中,這個屏障也被稱為**全能屏障**,兼具其它三種記憶體屏障的功能。 Java 中對記憶體屏障的使用在一般的程式碼中不太容易見到,常見的有 volatile 和 synchronized 關鍵字修飾的程式碼塊,還可以通過 Unsafe 這個類來使用記憶體屏障。(下一章扯扯這些) Java 記憶體模型就是通過定義的這些來解決可見性、原子性和有序性的。 ## 參考 《深入理解 Java 虛擬機器》第二版 http://tutorials.jenkov.com/java-concurrency/java-memory-model.html https://juejin.im/post/5bf2977751882505d840321d#heading-5 http://rsim.cs.uiuc.edu/Pubs/popl05.pdf http://ifeve.com/wp-content/uploads/2014/03/JSR133中文版.pdf ![](https://tva1.sinaimg.cn/large/00831rSTly1gd06gol40cj30ku0au