想當皇帝小妾,先搞懂java內存模型 ☞— JMM(筆記)


JMM

\color{#34a853}{JMM}(Java Memory Model——Java內存模型)。什麼是JMM呢?JMM是一個抽象概念,它並不存在。Java虛擬機規範中試圖定義一種Java內存模型(JMM)來屏蔽掉各類硬件和操做系統的內存訪問差別,以實現讓Java程序在各類平臺下都能達到一致的內存訪問效果。在此以前,主流程序語言(如C/C++等)直接使用物理硬件和操做系統的內存模型,所以,會因爲不一樣平臺的內存模型的差別,有可能致使程序在一套平臺上併發徹底正常,而在另外一套平臺上併發訪問卻常常出錯,所以在某些場景就必須針對不一樣的平臺來編寫程序。html

\color{#34a853}{Java線程}之間的通訊由JMM來控制,JMM決定一個線程共享變量的寫入什麼時候對另外一個線程可見。JMM保證若是程序是正確同步的,那麼程序的執行將具備順序一致性。從抽象的角度看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量(實例域、靜態域和數據元素)存儲在主內存(Main Memory)中,每一個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀/寫共享變量的副本(局部變量、方法定義參數和異常處理參數是不會在線程之間共享,它們存儲在線程的本地內存中)。從物理角度上看,主內存僅僅是虛擬機內存的一部分,與物理硬件的主內存名字同樣,二者能夠互相類比;而本地內存,可與處理器高速緩存類比。Java內存模型的抽象示意圖如圖所示: 程序員

這裏介紹七個基礎概念: \color{#4285f4}{8種操做指令、}\color{#ea4335}{內存屏障、}\color{#fbbc05}{順序一致性模型、}\color{#4285f4}{as-if-serial、}\color{#34a853}{happens-before、}\color{#ea4335}{數據依賴性、}\color{purple}{重排序。}

8種操做指令

關於主內存與本地內存之間具體的交互協議,即一個變量如何從主內存拷貝到本地內存、如何從本地內存同步回主內存之類的實現細節,JMM中定義瞭如下8種操做來完成,虛擬機實現時必須保證下面說起的每種操做都是原子的、不可再分的(對於double和long類型的遍從來說,load、store、read和write操做在某些平臺上容許有例外):面試

  • \color{#34a853}{lock}(鎖定):做用於主內存的變量,它把一個變量標識爲一條線程獨立的狀態。
  • \color{#34a853}{unlock}(解鎖):做用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量才能夠被其餘線程鎖定。
  • \color{#34a853}{read}(讀取):做用於主內存的變量,它把一個變量的值從主內存傳輸到線程的本地內存中,以便隨後的load動做使用。
  • \color{#34a853}{load}(載入):做用於本地內存的變量,它把read操做從主內存中獲得變量值放入本地內存的變量副本中。
  • \color{#34a853}{use}(使用):做用於本地內存的變量,它把本地內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個須要使用到變量的值的字節碼指令時將會執行這個操做。
  • \color{#34a853}{assign}(賦值):做用於本地內存的變量,它把一個從執行引擎接收到的值賦給本地內存的變量,每當虛擬機遇到一個給變量賦值的字節碼指令時執行這個操做。
  • \color{#34a853}{store}(存儲):做用於本地內存的變量,它把本地內存中的一個變量的值傳送到主內存中,以便隨後的write操做使用。
  • \color{#34a853}{write}(寫入):做用於主內存的變量,它把store操做從本地內存中提到的變量的值放入到主內存的變量中。

\color{#34a853}{☞}若是要把一個變量從主內存模型複製到本地內存,那就要順序的執行read和load操做,若是要把變量從本地內存同步回主內存,就要順序的執行store和write操做。注意,Java內存模型只要求上述兩個操做必須按順序執行,而沒有保證是連續執行。也就是說read與load之間、store與write之間是可插入其餘指令的,如對主內存中的變量a、b進行訪問時,一種可能出現的順序是read a read b、load b、load a。編程

內存屏障

內存屏障是一組處理器指令(前面的8個操做指令),用於實現對內存操做的順序限制。包括LoadLoad, LoadStore, StoreLoad, StoreStore共4種內存屏障。內存屏障存在的意義是什麼呢?它是在Java編譯器生成指令序列的適當位置插入內存屏障指令來禁止特定類型的處理器重排序,從而讓程序按咱們預想的流程去執行,內存屏障是與相應的內存重排序相對應的。JMM把內存屏障指令分爲4類:緩存

StoreLoad Barriers是一個「全能型」的屏障,它同時具備其餘3個屏障的效果。如今的多數處理器大多支持該屏障(其餘類型的屏障不必定被全部處理器支持)。執行該屏障開銷會很昂貴,由於當前處理器一般要把寫緩衝區中的數據所有刷新到內存中。

數據依賴性

若是兩個操做訪問同一個變量,且這兩個操做中有一個爲寫操做,此時這兩個操做之間就存在數據依賴性。數據依賴性分3種類型:寫後讀、寫後寫、讀後寫。這3種狀況,只要重排序兩個操做的執行順序,程序的執行結果就會被改變。編譯器和處理器可能對操做進行重排序。而它們進行重排序時,會遵照數據依賴性,不會改變數據依賴關係的兩個操做的執行順序。bash

名稱 代碼 示例說明
寫後讀 a = 1;b = a; 寫一個變量以後,再讀這個位置。
寫後寫 a = 1;a = 2; 寫一個變量以後,再寫這個變量。
讀後寫 a = b;b = 1; 讀一個變量以後,再寫這個變量。

這裏所說的數據依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操做,不一樣處理器之間和不一樣線程之間的數據依賴性不被編譯器和處理器考慮。多線程

順序一致性內存模型

順序一致性內存模型是一個理論參考模型,在設計的時候,處理器的內存模型和編程語言的內存模型都會以順序一致性內存模型做爲參照。它有兩個特性:併發

  • 一個線程中的全部操做必須按照程序的順序來執行
  • (無論程序是否同步)全部線程都只能看到一個單一的操做執行順序。在順序一致性的內存模型中,每一個操做必須原子執行而且馬上對全部線程可見。

從順序一致性模型中,咱們能夠知道程序全部操做徹底按照程序的順序串行執行。而在JMM中,臨界區內的代碼能夠重排序(但JMM不容許臨界區內的代碼「逸出」到臨界區外,那樣就破壞監視器的語義)。app

假設這兩個線程使用監視器鎖來正確同步:A線程的3個操做執行後釋放監視器鎖,隨後B線程獲取同一個監視器鎖。 編程語言

假設這兩個線程沒有作同步:

JMM會在退出臨界區和進入臨界區這兩個關鍵時間點作一些特別處理,使得線程在這兩個時間點具備與順序一致性模型相同的內存視圖。雖然線程A在臨界區內作了重排序,但因爲監視器互斥執行的特性,這裏的線程B根本沒法「觀察」到線程A在臨界區內的重排序。這種重排序既提升了執行效率,又沒有改變程序的執行結果。像單例模型[靜態內部類模型]的類初始化解決方案就是採用了這個思想。

as-if-serial

as-if-serial的意思是無論怎麼重排序,(單線程)程序的執行結果不能改變。編譯器、runtime和處理器都必須遵照as-if-serial語義。爲了遵照as-if-serial語義,編譯器和處理器不會對存在數據依賴關係的操做作重排序。

as-if-serial語義把單線程程序保護了起來,遵照as-if-serial語義的編譯器、runtime和處理器共同爲編寫單線程程序的程序員建立了一個幻覺:單線程程序是按程序的順序來執行的。as-if-serial語義使單線程程序員無需擔憂重排序會干擾他們,也無需擔憂內存可見性問題。

happens-before

happens-before是JMM最核心的概念。從JDK5開始,Java使用新的JSR-133內存模型,JSR-133 使用happens-before的概念闡述操做之間的內存可見性,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必須存在happens-before關係。

happens-before規則以下:

  • 程序次序法則:線程中的每一個動做 A 都 happens-before 於該線程中的每個動做 B,其中,在程序中,全部的動做 B 都出如今動做 A 以後。(注:此法則只是要求遵循 as-if-serial語義)
  • 監視器鎖法則:對一個監視器鎖的解鎖 happens-before 於每個後續對同一監視器鎖的加鎖。(顯式鎖的加鎖和解鎖有着與內置鎖,即監視器鎖相同的存儲語意。)
  • volatile變量法則:對 volatile 域的寫入操做 happens-before 於每個後續對同一域的讀操做。(原子變量的讀寫操做有着與 volatile 變量相同的語意。)(volatile變量具備可見性和讀寫原子性。)
  • 線程啓動法則:在一個線程裏,對 Thread.start 的調用會 happens-before 於每個啓動線程中的動做。
  • 線程終止法則:線程中的任何動做都 happens-before 於其餘線程檢測到這個線程已終結,或者從 Thread.join 方法調用中成功返回,或者 Thread.isAlive 方法返回false。
  • 中斷法則法則:一個線程調用另外一個線程的 interrupt 方法 happens-before 於被中斷線程發現中斷(經過拋出InterruptedException, 或者調用 isInterrupted 方法和 interrupted 方法)。
  • 終結法則:一個對象的構造函數的結束 happens-before 於這個對象 finalizer 開始。
  • 傳遞性:若是 A happens-before 於 B,且 B happens-before 於 C,則 A happens-before 於 C。 happens-before與JMM的關係以下圖所示:

as-if-serial語義和happens-before本質上同樣,參考順序一致性內存模型的理論,在不改變程序執行結果的前提下,給編譯器和處理器以最大的自由度,提升並行度。

重排序

終於談到咱們反覆說起的重排序了,重排序是指編譯器和處理器爲了優化程序性能而對指令序列進行從新排序的一種手段。重排序分3種類型。

  • 編譯器優化的重排序。編譯器在不改變單線程程序語義(as-if-serial )的前提下,能夠從新安排語句的執行順序。
  • 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction Level Parallelism,ILP)來將多條指令重疊執行。若是不存在數據依賴性,處理器能夠改變語句對機器指令的執行順序。
  • 內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。 從Java源代碼到最終實際執行的指令序列,會分別經歷下面3種重排序

上述的1屬於編譯器重排序,2和3屬於處理器重排序。這些重排序可能會致使多線程程序出現內存可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定類型的編譯器重排序(不是全部的編譯器重排序都要禁止)。對於處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障指令,經過內存屏障指令來禁止特定類型的處理器重排序。

JMM屬於語言級的內存模型,它確保在不一樣的編譯器和不一樣的處理器平臺之上,經過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。

從JMM設計者的角度來講,在設計JMM時,須要考慮兩個關鍵因素:

  • 程序員對內存模型的使用。程序員但願內存模型易於理解,易於編程。程序員但願基於一個強內存模型(程序儘量的順序執行)來編寫代碼。
  • 編譯器和處理器對內存模型的實現。編譯器和處理器但願內存模型對它們的束縛越少越好,這樣它們就能夠作儘量多的優化(對程序重排序,作儘量多的併發)來提升性能。編譯器和處理器但願實現一個弱內存模型。

JMM設計就須要在這二者之間做出協調。JMM對程序採起了不一樣的策略:

  • 對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
  • 對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不做要求(JMM容許這種重排序)。

介紹完了這幾個基本概念,咱們不難推斷出JMM是圍繞着在併發過程當中如何處理原子性、可見性和有序性這三個特徵來創建的。

  • 原子性:由Java內存模型來直接保證的原子性操做就是咱們前面介紹的8個原子操做指令,其中lock(lock指令實際在處理器上原子操做體現對總線加鎖或對緩存加鎖)和unlock指令操做JVM並未直接開放給用戶使用,可是卻提供了更高層次的字節碼指令monitorenter和monitorexit來隱式使用這兩個操做,這兩個字節碼指令反映到Java代碼中就是同步塊——synchronize關鍵字,所以在synchronized塊之間的操做也具有原子性。除了synchronize,在Java中另外一個實現原子操做的重要方式是自旋CAS,它是利用處理器提供的cmpxchg指令實現的。至於自旋CAS後面J.U.C中會詳細介紹,它和volatile是整個J.U.C底層實現的核心。
  • 可見性:可見性是指一個線程修改了共享變量的值,其餘線程可以當即得知這個修改。而咱們上文談的happens-before原則禁止某些處理器和編譯器的重排序,來保證了JMM的可見性。而體如今程序上,實現可見性的關鍵字包含了volatile、synchronize和final。
  • 有序性:談到有序性就涉及到前面說的重排序和順序一致性內存模型。咱們也都知道了as-if-serial是針對單線程程序有序的,即便存在重排序,可是最終程序結果仍是不變的,而多線程程序的有序性則體如今JMM經過插入內存屏障指令,禁止了特定類型處理器的重排序。

經過前面8個操做指令和happens-before原則介紹,也不難推斷出,volatile和synchronized兩個關鍵字來保證線程之間的有序性,volatile自己就包含了禁止指令重排序的語義,而synchronized則是由監視器法則得到。

JUC

也許你對volatile和CAS的底層實現原理不是很瞭解,這裏簡單介紹下它們的底層實現:

volatile

Java語言規範第三版對volatile的定義爲:Java編程語言容許線程訪問共享變量,爲了確保共享變量能被準確和一致性的更新,線程應該確保經過排他鎖單獨得到這個變量。若是一個字段被聲明爲volatile,Java內存模型確保這個全部線程看到這個值的變量是一致的。

而volatile是如何來保證可見性的呢?若是對聲明瞭volatile的變量進行寫操做,JVM就會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存(Lock指令會在聲言該信號期間鎖總線/緩存,這樣就獨佔了系統內存)。

可是,就算是寫回到內存,若是其餘處理器緩存的值仍是舊的,再執行計算操做就會有問題。因此,在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每一個處理器經過嗅探在總線(注意處理器不直接跟系統內存交互,而是經過總線)上傳播的數據來檢查本身緩存的值是否是過時了,當處理器發現直接緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操做的時候,會從新從系統內存中把數據讀處處理器緩存裏。

CAS

CAS其實應用挺普遍的,咱們經常聽到的悲觀鎖樂觀鎖的概念,樂觀鎖(無鎖)指的就是CAS。

這裏只是簡單說下在併發的應用,所謂的樂觀併發策略,通俗的說,就是先進性操做,若是沒有其餘線程爭用共享數據,那操做就成功了,若是共享數據有爭用,產生了衝突,那就採起其餘的補償措施(最多見的補償措施就是不斷重試,治到成功爲止,這裏其實也就是自旋CAS的概念),這種樂觀的併發策略的許多實現都不須要把線程掛起,所以這種操做也被稱爲非阻塞同步。而CAS這種樂觀併發策略操做和衝突檢測這兩個步驟具有的原子性,是靠什麼保證的呢?硬件,硬件保證了一個從語義上看起來須要屢次操做的行爲只經過一條處理器指令就能完成。

也許你會存在疑問,爲何這種無鎖的方案通常會比直接加鎖效率更高呢?這裏其實涉及到線程的實現和線程的狀態轉換。實現線程主要有三種方式:使用內核線程實現、使用用戶線程實現和使用用戶線程加輕量級進程混合實現。而Java的線程實現則依賴於平臺使用的線程模型。至於狀態轉換,Java定義了6種線程狀態,在任意一個時間點,一個線程只能有且只有其中的一種狀態,這6種狀態分別是:新建、運行、無限期等待、限期等待、阻塞、結束

Java的線程是映射到操做系統的原生線程之上的,若是要阻塞或喚醒一個線程,都須要操做系統來幫忙完成,這就須要從用戶態轉換到核心態中,所以狀態轉換須要耗費不少的處理器時間。對於簡單的同步塊(被synchronized修飾的方法),狀態轉換消耗的時間可能比用戶代碼執行的時間還要長。因此出現了這種優化方案,在操做系統阻塞線程之間引入一段自旋過程或一直自旋直到成功爲止。避免頻繁的切入到核心態之中。 可是這種方案其實也並不完美,在這裏就說下CAS實現原子操做的三大問題:

  • ABA問題。由於CAS須要在操做值的時候,檢查值有沒有變化,若是沒有發生變化則更新,可是若是一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有變化,可是實際上發生變化了。ABA解決的思路是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加1。JDK的Atomic包裏提供了一個類AtomicStampedReference來解決ABA問題。不過目前來講這個類比較「雞肋」,大部分狀況下ABA問題不會影響程序併發的正確性,若是須要解決ABA問題,改用原來的互斥同步可能會比原子類更高效。
  • 循環時間長開銷大。自旋CAS若是長時間不成功,會給CPU帶來很是大的執行開銷。因此說若是是長時間佔用鎖執行的程序,這種方案並不適用於此。
  • 只能保證一個共享變量的原子操做。當對一個共享變量執行操做時,咱們可使用自旋CAS來保證原子性,可是對多個共享變量的操做時,自旋CAS就沒法保證操做的原子性,這個時候能夠用鎖。

final

  • final的內存語義 編譯器和處理器要遵照兩個重排序規則:

在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。 初次讀一個包含final域的對象的引用,與隨後初次讀這個final域,這兩個操做之間不能重排序。

  • final域爲引用類型:

增長了以下規則:在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。

  • final語義在處理器中的實現: 會要求編譯器在final域的寫以後,構造函數return以前插入一個StoreStore障屏。 讀final域的重排序規則要求編譯器在讀final域的操做前面插入一個LoadLoad屏障

一道面試題: [不使用volatile怎麼打破循環?]

public class TestThread implements Serializable {

    public static void main(String[] args) throws InterruptedException {

        Data data = new Data();
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            data.add();
        }).start();

        while (data.num == 0) {
            //怎麼打破 死循環
        }

        /**-----------------------無責任分割線1-----------------------------------------------*/
        int i = 1;
        while (data.num == 0) {
            i = i++; //未觸發致使死循環 
            i = ++i;
        }
        /**-----------------------無責任分割線2-----------------------------------------------*/
        while (data.num == 0) {
            synchronized (TestThread.class) {
                //同步鎖觸發線程切換 跳出循環
            }
        }
        /**-----------------------無責任分割線3-----------------------------------------------*/
        while (data.num == 0) {
            Thread.yield();//線程讓步 跳出循環
        }
        /**-----------------------無責任分割線4-----------------------------------------------*/
        while (data.num == 0) {
            try {
                Thread.sleep(0);//線程休眠讓出CPU 跳出循環
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        /**-----------------------無責任分割線5-----------------------------------------------*/
        while (data.num == 0) {
            System.out.println("");//println 有同步鎖 跳出循環
        }
        /**-----------------------無責任分割線6-----------------------------------------------*/
        LongAdder longAdder = new LongAdder();
        while (data.num == 0) {
            longAdder.decrement();//cas自旋鎖 跳出循環
        }
        /**-----------------------無責任分割線7-----------------------------------------------*/

        System.out.println("哈哈2");
    }

    static class Data {
      volatile   int num = 0;
        public void add() {
            this.num = 60;
        }
    }
}
複製代碼

本文摘(jie)抄(jian)自 鳴謝原文:從一個簡單的Java單例示例談談併發 JMM JUC

相關文章
相關標籤/搜索