1. 程式人生 > 實用技巧 >Java記憶體模型與執行緒

Java記憶體模型與執行緒

JVM規範試圖定義一種Java記憶體模型(Java Memory Model, JMM)來遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平臺下都能達到一致的記憶體訪問效果。

1 主記憶體與工作記憶體

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數存進記憶體和取出的底層細節。

這裡的變數(Variables)與Java變數有所區別,包括了例項欄位、靜態欄位、構成陣列物件的元素,不包括區域性變數與方法引數,因為後者是執行緒私有的,不會共享。

JMM規定了所有變數都儲存在主記憶體(Main Memory)中(這裡的主記憶體與物理硬體的主記憶體可以類比,但這裡只表示虛擬機器記憶體的一部分)。每條執行緒還有自己的工作記憶體(Working Memory,可與處理器快取記憶體類比)。執行緒的工作記憶體中儲存了被該執行緒使用到的變數主記憶體副本,執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒也無法直接訪問其他執行緒的工作記憶體中的變數,執行緒間的值傳遞需要通過主記憶體完成,執行緒、主記憶體、工作記憶體三者的互動關係如圖所示。

這裡的主記憶體、工作記憶體與Java記憶體區域中的Java堆、棧、方法區等並不是同一個層次的記憶體劃分,這兩者基本上是沒有關係的,如果一定要勉強對應起來,那從變數、主記憶體、工作記憶體的定義來看,主記憶體主要對應Java堆中的物件例項資料部分,工作記憶體對應虛擬機器棧的部分割槽域。更低的層次而言,主記憶體直接對應於物理硬體記憶體,為了獲取更好的執行速度,虛擬機器可能會讓工作記憶體優先儲存與暫存器和Cache中,因為程式執行時主要訪問讀寫工作記憶體。

2 記憶體間互動操作

關於一個變數在主記憶體與工作記憶體之間傳輸的實現細節,JMM定義了8種操作來完成,虛擬機器實現時必須保證每一種操作都是原子的、不可再分的。對於double、long來說,load、store、read、write可能有些例外。

((JSR-133文件中,已經放棄採用這8種操作去定義Java記憶體模型的訪問協議)

  • lock(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。
  • unlock(解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用。
  • write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。
  • store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write操作使用。(store後write)
  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。(read以後load)
  • use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。
  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • 如果要把一個變數從主記憶體複製到工作記憶體,那就要順序地執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要順序地執行store和write操作。注意,Java記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證是連續執行。也就是說,read與load之間、store與write之間是可插入其他指令的,如對主記憶體中的變數a、 b進行訪問時,一種可能出現順序是read a、 read b、 load b、 load a。
  • Java記憶體模型還規定了在執行上述8種基本操作時必須滿足如下規則
    • 不允許read和load、store和write操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主記憶體不接受的情況出現。
    • 不允許一個執行緒丟棄它的最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。
    • 不允許一個執行緒無原因地(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步回主記憶體中。
    • 一個新的變數只能在主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數,換句話說,就是對一個變數實施use、store操作之前,必須先執行過了load和assign操作
    • 一個變數在同一個時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。
    • 如果對一個變數執行lock操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值。
    • 如果一個變數事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定住的變數。
    • 對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store、 write操
      作)。
  • 這8種記憶體訪問操作以及上述規則限定,再加上對volatile的一些特殊規定,就可以完全確定了Java程式中哪些記憶體訪問操作在併發下是安全的。 – 等價於”先行發生原則”

對於volatile型變數的特殊規則

  • 關鍵字volatile可以說是Java虛擬機器提供的最輕量級的同步機制;Java記憶體模型對volatile專門定義了一些特殊的訪問規則,一個變數定義為volatile之後,它將具備兩種特性:可見性,禁止指令重排序優化。

可見性

    • 保證此變數對所有執行緒的可見性,這裡的“可見性”是指當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即得知的。而普通變數不能做到這一點,普通變數的值線上程間傳遞均需要通過主記憶體來完成 – 執行緒A修改一個普通變數的值,然後向主記憶體進行回寫,另外一條執行緒B線上程A回寫完成了之後再從主記憶體進行讀取操作,新變數值才會對執行緒B可見。
    • 針對 volatile變數的可見性的誤解 – “volatile變數對所有執行緒是立即可見的,對volatile變數所有的寫操作都能立刻反應到其他執行緒之中,換句話說,volatile變數在各個執行緒中是一致的,所以基於volatile變數的運算在併發下是安全的”。這句話的論據部分並沒有錯,但是並不能得出“基於volatile變數的運算在併發下是安全的”這個結論。volatile變數在各個執行緒的工作記憶體中不存在一致性問題(在各個執行緒的工作記憶體中,volatile變數也可以存在不一致的情況,但由於每次使用之前都要先重新整理,執行引擎看不到不一致的情況,因此可以認為不存在一致性問題),但是Java裡面的運算並非原子操作,導致volatile變數的運算在併發下一樣是不安全的。

      比如兩個執行緒對volatile x = 1開始進行x++操作,A執行緒讀取了x,放入棧的本地變量表,這個時候B執行緒完成了x++操作,x變成了2。雖然A執行緒知道x變成了2,但是其運算是從區域性變量表中獲取原本的值1,而沒有重新獲取,這個時候執行下去就產生了問題,結果變成了2,實際上要是3才是正確的。

          volatile只保證可見性,不符合以下兩條規則的運算場景中,我們仍需要通過加鎖來保證原子性:1.運算結果並不依賴變數當前的值,或者保證只有一條執行緒可以對其修改。2.變數不需要與其他的狀態變數共同參與不變約束。

          第二個特性在於禁止指令重排序優化,普通變數只保證執行過程中所有依賴賦值結果的地方能夠獲得正確的結果,不保證賦值操作的順序與程式碼中的執行順序一致。因為在一個執行緒的方法執行過程中無法感知到這點,這就是所說的“執行緒內表現為序列語義”。指令重排很難理解,舉個例子就很清楚了。

A: boolean init = false

    // do something

    init = true

  B :  while(!init) {

      sleep()

    }

    // do something

A執行緒準備開啟一個服務,設定了一個boolean變數為flase,執行了一串操作,最終將這個變數設定成了true,開啟服務成功。B執行緒在此過程中一直在請求判斷這個變數是否是true,當判斷服務開啟開始執行其他的操作。這麼描述一看上去並沒有什麼問題,但如果這個變數不是volatile型別,就可能因為指令重排而出問題。原因就在於根據指令重排的規則,A執行緒的變數沒有被其他地方使用,所以其最後賦值為true可能被提前執行,也就是初始化動作沒做完就設定成true了,這個時候對B執行緒來說就麻煩大了,B執行緒必須等服務初始化啟動好了才能進行下面的操作,由於指令重排造成B執行緒的判斷失誤。

    另外volatile禁止指令重排是在JDK5才完全正確,之前的版本是有bug的。使用volatile會多執行一個lock addl $0x0, (%esp)操作,這個相當於一個記憶體屏障,重排序時不能將後面的操作,排在這個操作之前。addl $0x0 (%esp)是一個空操作,作用是使得本CPU的Cache寫入了記憶體,該寫入動作會引起別的CPU或者核心無效化其Cache,這個操作保證了volatile變數的修改對其他CPU立即可見。禁止指令重排的原理在於:指令重排指CPU採用了允許將多條指令不按程式規定的順序分開發送給各相應電路單元處理,但不是說任意重排,而是要正確處理指令依賴情況保證程式能夠得到正確的執行結果。比如x=x+10;x*=2;y-=3;這3條指令,前兩條的順序就不能改變,但是3可能在1前面執行或者12中間執行。結果看起來是一致的。所以通過lock指令將修改同步到記憶體時,意味著之前的所有操作都已經執行完畢了,就形成了指令重排無法越過的記憶體屏障了。

  最後一個問題,volatile比其他同步工具要快嗎?這個很難說,只能是某些場景會優於鎖,虛擬機器對鎖有很多的優化,volatile變數讀操作的效能消耗與普通變數基本沒什麼差別,但是寫會慢一些,因為需要在程式碼中插入許多記憶體屏障保證處理器不發生亂序執行。不過即便如此,大多數場景開銷還是比鎖低。選擇依據只是volatile語義是否滿足需求。

記憶體模型要求上述8個操作都是原子性的,但是對於64位的資料型別long和double來說,定義了一條寬鬆的規則:允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為2次32位操作。所以虛擬機器實現可以不保證64位資料的load、store、read和write操作的原子性。這就是long和double的非原子協定。

  如果有多個執行緒同時對這個進行讀取修改,可能會造成其中32位資料出現問題,這種情況非常罕見。雖然允許不把long和double變數的讀寫實現原子操作,但是強烈建議這麼做,所以商用虛擬機器基本將64位資料的讀寫操作作為原子操作對待,在編碼的時候不需要對long和double變數專門宣告為volatile。

2.5 原子性、可見性與有序性

  這3個特性之前陸陸續續提到過,這裡總結一下:

    原子性:由記憶體模型直接保證原子性變數操作包括read、load、assign、use、store、write,大致可以認為基本資料型別的訪問讀寫是具備原子性的。至於long和double的非原子協定,瞭解一下即可,實際上無須過多關注。如果需要更大範圍的原子性,可以使用lock和unlock,這兩個虛擬機器沒有直接開放給使用者使用,但是提供了位元組碼指令monitorenter和monitorexit來隱式地使用這兩個操作,反映到Java程式碼中就是synchronized關鍵字,所以synchronized塊是具備原子性的。

    可見性:指當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。詳情見上面的volatile說明。除了這個外,還有兩個關鍵字能實現可見性:synchronized和final。同步塊的可見性是通過規則”對一個變數執行unlock操作之前必須把這個變數同步回主記憶體中“實現的。final的可見性指,被final修飾的欄位在構造器中一旦初始化完成,並且構造器沒有把this的引用傳遞出去,那麼其他執行緒就能看見final欄位的值。

    有序性:volatile中提到的指令重排也說過。總結就是:在本執行緒中觀察,所有的操作都是有序的(即結果與正常順序是一致的),在其他執行緒中觀察,所有的操作都是無序的(即發生了重排)。前半句指的是“執行緒內表現為序列語義”,後面句指的是指令重排現象和工作記憶體與主記憶體同步延遲現象。Java提供了volatile和synchronized關鍵字保證兩個執行緒之間的操作有序,synchronized是由規則“一個變數在同一個時刻只允許一條執行緒對其進行lock操作”,決定了持有一個鎖的兩個塊只能序列進入。

2.6 happens-before原則(先行發生原則)

  有序性如果只靠volatile和synchronized,那麼併發程式設計會非常麻煩,但是實際上寫程式碼的時候卻並沒有這麼複雜。原因在於Java語言中有一個happens-before原則。這個原則很重要,是判斷資料是否存在競爭、執行緒是否安全的主要依據。

  先行發生指的是什麼呢?其實就是Java記憶體模型中定義的兩項操作之間的偏序關係,如果操作A先行發生於操作B,就是在說發生在B之前的操作A產生的影響能被B觀察到。舉個例子:

    A:i = 1; B:j = i; C:i = 2;

  如果A先行發生於B,則j的值一定等於1,原因有兩個,根據定義i=1可以被B觀察到,C沒有執行。如果C在A、B之間執行,但是並不是先行於B,j的值就無法確定了。因為C對B的影響可能被B觀察到了,也可能沒有。這樣就存線上程安全的問題。

  上面的例子簡單的說就是如果存在先行關係,就不用擔心指令重排對兩個執行緒的影響,不存在先行關係就要特別小心了。Java記憶體模型中有一些先天的先行發生關係,不需要藉助同步可以在編碼中直接使用:

    1.程式次序規則:在一個執行緒內,按照程式程式碼順序執行。準確的說是:控制流順序而不是程式程式碼順序(有選擇和迴圈)

    2.管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。必須是同一個鎖,後面指的是時間上的先後順序

    3.volatile變數規則:對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作。後面指的時間先後順序

    4.執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每一個動作

    5.執行緒終止規則:執行緒中所有操作都先行發生於對此執行緒的終止檢測。

    6.執行緒中斷規則:對執行緒的interrupt方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生。

    7.物件終結規則:一個物件的初始化完成,先行發生於它的finalize方法的開始

    8.傳遞性:如果操作A先行發生於B,B先行於C,那麼A先行於C

  Java語言無須任何同步手段保障就能成立的先行規則就只有上面這些。下面說明一下時間先後和先行的區別,比如有個共享變數x = 0,A執行緒在時間上先set其為1,執行緒B獲取這個值,這個時候B獲取的是1嗎?答案是否定的,應該是不知道。因為這個操作沒有任何先行規則匹配,雖然set操作先執行,但是不能確保get操作能獲得修改後的值。修改方法很簡單,加上synchronized或者定義成volatile。

  時間上先發生,不一定是先行發生,那麼先行發生,一定在時間上是先發生的嗎?不一定,因為指令重排。比如int i = 1; j = 2。指令重排後j可能先被執行,但是根據程式次序規則,在一個執行緒內i = 1是先行於j=2的。

3 Java與執行緒

  併發不一定要用多執行緒完成,比如PHP的多程序併發,但是在Java裡面併發和執行緒密切相關。

3.1 執行緒的實現

  執行緒是比程序更輕量級的排程執行單位,執行緒的引入可以把一個程序的資源分配和執行排程分開。各個執行緒既可以共享程序資源(記憶體地址,檔案IO等),又可以獨立排程(執行緒是CPU排程的基本單位)。

  主流的作業系統都提供了執行緒實現,Java提供了不同作業系統對執行緒的統一處理,每個執行了start方法的Thread例項,就開啟了一個執行緒。Thread的大部分API都是native,所以本章介紹的是執行緒的實現,而不關係Java是如何封裝的。

  基本的實現方式有3種:使用核心執行緒實現、使用使用者執行緒實現和使用使用者執行緒加輕量級程序混合實現。

3.1.1 使用核心執行緒實現

  核心執行緒KLT kernel-level thread就是直接由作業系統核心支援的執行緒,這種執行緒由核心來完成執行緒切換,核心通過操縱排程器對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身,這樣作業系統就有能力同時處理多件事情,支援多執行緒的核心就叫做多執行緒核心。

  程式一般是不會直接使用核心執行緒,而是去使用核心執行緒的一種高階介面——輕量級程序(Light Weight Process,LWP),輕量級程序就是我們通常意義上所講的執行緒,由於每個輕量級程序都由一個核心執行緒支援,因此只有先支援核心執行緒,才能有輕量級程序。

由於核心執行緒的支援,每個輕量級程序都是一個獨立的排程單元,即使有一個阻塞了,也不會影響整個程序的工作。但是侷限在於:基於核心執行緒實現,執行緒的操作,建立,析構及同步都需要進行系統呼叫,代價較高,在使用者態和系統態來回切換。輕量級程序需要一個對應的核心執行緒,會消耗核心資源,支援數量有限。

3.1.2 使用使用者執行緒實現

  廣義上一個執行緒只要不是核心執行緒,就可以認為是使用者執行緒。所以輕量級程序也算是使用者執行緒,但是實現是建立在核心之上。狹義上的使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知執行緒的存在。使用者執行緒的建立、同步、銷燬、排程都在使用者態完成,實現得當都不需要切換到核心態。所以低消耗,支援執行緒數大。

劣勢在於沒有核心的支援,所有動作都需要自己處理,由於作業系統只承擔了分配資源,那麼阻塞如何處理,多處理器系統中如何將執行緒對映到其他處理器上之類的問題解決會非常困難,甚至辦不到。所以程式複雜,現在使用使用者執行緒的程式越來越少了,Java、Ruby等語言曾經使用過,最終放棄了。

3.1.3 使用使用者執行緒加輕量級程序混合實現

  這是種混合實現的方式,使用者執行緒還是完全建立在使用者空間,所以執行緒的建立、切換、析構等操作代價小,並且可以支援大規模使用者執行緒併發。而作業系統提供支援的輕量級程序則作為使用者執行緒和核心執行緒之間的橋樑,可以使用核心提供的執行緒排程功能及處理對映,並且使用者執行緒的系統呼叫要通過輕量級執行緒來完成,大大降低了整個程序被完全阻塞的風險。許多Unix系列的作業系統,如Solaris、HP-UX等都提供了N:M的執行緒模型實現。

3.1.4 Java執行緒實現

  JDK1.2之前,是基於稱為“綠色執行緒”的使用者執行緒實現的。1.2中,執行緒模型替換成基於作業系統原生執行緒模型實現。因此,目前作業系統支援怎麼樣的執行緒模型,很大程度上決定了Java虛擬機器執行緒是怎麼樣對映的,這點在不同的平臺上無法達成一致。執行緒模型只對執行緒併發規模和操作成本產生影響,對Java的編碼和執行差異是透明的。

  對於Sun JDK而言,其Windows和Linux版本都是使用1對1的執行緒模型實現的,一條Java執行緒對應一條輕量級程序。

  在Solaris平臺中,作業系統支援1對1和多對多,所以Solaris版的JDK提供了平臺專有引數-XX:+UseLWPSynchronization(預設這個)和-XX:+UseBoundThreads決定使用哪種執行緒模型。

3.2 Java執行緒排程

  執行緒排程指系統為執行緒分配處理器使用權的過程,主要排程方式有兩種,分別是協同式執行緒排程和搶佔式執行緒排程。

  協同式排程的多執行緒系統中,執行緒的執行時間由執行緒本身控制,執行完了主動通知切換到另一個執行緒,最大的好處就是實現簡單。切換執行緒是可知的,沒什麼執行緒同步的問題。Lua中的協同例程就是這類實現。壞處是執行緒執行時間不可控制,如果程式有問題,一直不切換,就會發生阻塞,會導致整個系統崩潰。

  搶佔式排程的多執行緒系統中,執行緒由系統分配執行時間,Thread.yield可以讓出執行時間,但是獲取執行時間沒辦法。這種方式執行時間是系統可控的,不會出現一個執行緒阻塞導致整個系統崩潰。Java就是採取這種方式。雖然排程是自動完成的,但是還是可以通過設定優先順序給一些執行緒多一點執行時間,Java設定了10個優先順序1~10,數值越大越優先。不過要注意,執行緒優先順序並不靠譜,原因就是Java是通過對映到系統的原生執行緒上來實現的,所以執行緒排程最終還是取決於作業系統,雖然作業系統大部分提供了優先順序的概念,但是不見得能和Java執行緒的優先順序一一對應。比如windows只有7種,對應10種級別的劃分就坑了。此外,優先順序可能被系統自行改變,windows中存在一個優先順序推進器的功能,大致作用是當系統發現一個執行緒執行的很勤奮,就會越過執行緒的優先順序為它分配時間,所以不能通過執行緒優先順序來完全確定一組狀態都ready的執行緒哪個先執行。

3.3 狀態轉換

  Java語言定義了5種執行緒狀態,任意時間只能處於一個狀態。

    新建new:建立後未啟動

    執行runnable:包括running和ready,可能正在被執行,可能在等待CPU分配時間

    無限期等待waiting:這種狀態不會被分配執行時間,需要等待其他執行緒顯示喚醒,陷入waiting的方法:

      Object.wait();

      Thread.join();

      LockSupport.park();

    限期等待timed waiting:這種狀態也不會被分配時間,不過不需要喚醒,到時間就會自動喚醒。

      Thread.sleep()

      Object.wait(timeout);

      Thread.join(timeout);

      LockSupport.parkNanos();

      LockSupport.parkUnitl();

    阻塞blocked:執行緒被阻塞了,阻塞狀態與等待狀態的區別在於,阻塞狀態在等待一個排他鎖,這個事件將在另一個執行緒放棄這個鎖時發生。等待只是等待一段時間,或者是喚醒動作發生。在程式等待進入同步區域的時候,執行緒將進入這種狀態。

    結束terminated:已終止執行緒的執行緒狀態。

參考連結:

https://www.cnblogs.com/lighten/p/9335880.html

https://zhuanlan.zhihu.com/p/103232785

《深入理解Java虛擬機器》