Java內存模型與線程

閱讀《深入理解Java虛擬機-JVM高級特性與最佳實踐》.周志明 筆記

Java內存模型

主內存與工作內存

Java內存模型的主要目的是定義程序中各個變量的訪問規則,即虛擬機中變量存儲到內存和從內存中取出變量這樣的底層細節。此處的變量包括了實例字段、靜態字段和構成數組對象的元素,但不包括局部變量與方法參數,因爲後者是線程私有的,不會被共享,自然不會存在競爭的問題。

Java內存模型規定了所有變量都存儲在主內存(Main Memory)中。每條線程還有自己的工作內存(Working Memory),線程的工作內存中保存了被該線程使用到的變量的主內存副本拷貝,線程對變量的所有操作(讀寫)都必須在工作內存當中進行,而不能直接讀寫主內存中的變量。不同線程之間也無法直接訪問對方工作內存中的變量,線程間變量值的傳遞均需要通過主內存來完成,線程、主內存、工作內存之間的關係如下圖:

線程、主內存、工作內存三者的交互關係

 這裏所講的主內存、工作內存與JVM運行時數據區中的Java堆、棧、方法區並不是同一個層次的內存劃分,兩者基本沒有關係。如果兩者一定要勉強對應起來,那從變量、主內存、工作內存定義來看,主內存主要對應Java堆中的對象實例數據部分,而工作內存則對應於虛擬機棧中的部分區域。從更低層次上說,主內存就直接對應於物理硬件的內存,而爲了獲取更好的運行速度,虛擬機(甚至是硬件系統本身的優化措施)可能會讓工作內存有限存儲於寄存器和高速緩存中,因爲程序運行時主要訪問讀寫的是工作內存。

內存間交互操作

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

  • lock(鎖定):作用於住內存的變量,它吧一個變量表示爲一條線程獨佔的狀態;
  • unlock(解鎖):作用於住內存變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量纔可以被其他線程鎖定;
  • read(讀取):作用於主內存的變量,它把一個變量的值從主內存中傳輸到線程的工作內存當中,以便隨後的load動作使用;
  • load(載入):作用於工作內存的變量,它把read操作從主內存中得到的變量值放入到工作內存的變量副本中;
  • use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當虛擬機遇到一個需要使用到變量的值的字節碼指令時3將會執行這個操作;
  • assign(賦值):作用於工作內存的變量,它把一從執行引擎接收到的值付給工作內存變量,每當虛擬機遇到一個變量賦值字節碼指令時執行這個操作;
  • store(存儲):作用於工作內存的變量,它把工作內存中一個變量的值傳遞到主內存中,一遍隨後的write操作使用;
  • write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。

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操作)。

對於volatile型變量的特殊規則

瞭解volatile變量的語義對後面瞭解多線程操作的其他特性很有意義。

當一個變量定義爲volatile之後,它將具備兩種特性:

特性1:保證此變量對所有線程的可見性,這裏的“可見性”是指當一條線程修改了這個變量的值,新值對於其他線程來說可以立即得知的。而普通變量不能做到這一點,普通變量的值在線程之間傳遞需要通過主內存來完成,例如線程A修改一個普通變量的值,然後向內存回寫,另外一條線程B在線程A完成了之後再從主內存進行讀取操作,新變量值纔會對線程B可見。

但是,由於volatile變量只能保證可見性,在不符合以下兩條規則的運算情況下,我們仍然要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性:

  1. 運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量值;
  2. 變量不需要與其他的狀態變量共同參與變量約束。

特性2:使用volatile變量的第二個語義是禁止指令重排序優化,普通的比那輛僅僅會保證在該方法執行過程中,所有依賴值賦值結果的地方都會獲得到正確的結果,而不能保證賦值操作的順序與程序代碼中的執行順序一致。

原子性、可見性與有序性

瞭解過Java內存模型的相關操作和規則,再回顧一下這個模型的特徵,Java內存模型是圍繞着併發過程中如何處理原子性、可見性和有序性這三個特徵來建立的,下面來看看三個特性:

原子性:由Java內存模型直接保證的原子性變量操作包括read、load、assign、use、store和write,我們大致可以認爲基本數據類型的訪問讀寫是具備原子性的(例外就是long和double的非原子性協定,知道有這麼回事兒就行,不必深究。但是現在的商用虛擬機基本都是支持long和double的原子性操作的)。如果是更大範圍的原子性保證(經常會遇到),Java內存模型還提供了lock和unlock操作來滿足這種需求,儘管虛擬機未把lock和unlock操作直接開放給用戶使用,但是卻提供了更高層次的字節碼指令monitorenter和monitorexit來隱式地使用這兩個操作,這兩個字節碼指令反映到了Java代碼中就是同步塊——synchronized關鍵字,因此在synchronized塊之間的操作也具備原子性。

可見性:可見性是指當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。上文在瞭解volatile的時候討論過這一點。Java內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前主內存刷新變量值這種依賴主內存作爲傳遞媒介的方式來實現可見性,無論是普通變量還是volatile變量都是如此,普通變量與volatile的區別是,volatile的特殊規則保證新值能立即同步到主內存,以及每次使用前立即從主內存刷新。因此,可以說volatile保證了多線程操作是時變量的可見性,而普通變量則不能保證這一點。除了volatile之外,Java還有兩個關鍵字能實現可見性,即synchronized和final。同步塊可見性是由對一個變量執行unlock操作之前,必須把此變量同步回主內存中(執行store和write操作)這條規則獲得的,而final關鍵字的可見性是指:被final修飾的字段在構造器中一旦完成初始化,並且構造器沒有把“this”的引用傳遞出去(this引用逃逸是一件很危險的事情,其他線程有可能通過這個引用訪問到“初始化了一般”的對象),那在其他線程中就能看見final字段的值。

有序性:Java內存模型的有序性在volatile的學習過程中有瞭解到。Java程序中天然的有序性我可以總結爲一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。前半句是指“線程內表現爲串行的語義”,後半句是指“指令重排序”現象和“工作內存與主內存同步延遲”現象。

Java語言提供了volatile和synchronized兩個關鍵字來保證線程之間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變量在同一時刻只允許一條線程對其進行lock操作”這條規則獲得的,這條規則決定了持有同一個鎖的兩個同步塊只能串行地進入。

先行發生原則

Java語言中有一個“先行發生”的原則(先行發生是java內存模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等),它是判斷數據是否存在競爭、線程是否安全的重要依據,依靠這個原則,可以通過幾條規則一攬子解決併發環境下兩個操作之間是否可能存在衝突問題。

Java內存模型中“天然的”先行發生關係,無需同步器操作:

  • 程序次序規則:在一個線程內,按照程序代碼順序,書寫在前面的操作先行發生在寫在後面的操作。準確的說,應該是控制流順序而不是程序代碼的順序,因爲考慮到分支和循環等結構;
  • 管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。這裏必須強調是同一個鎖,而“後面”是指時間上的順序;
  • volatile變量規則:對一個volatile變量的寫操作先行發生於後面對這個變量的讀操作,而“後面”是指時間上的順序;
  • 線程啓動規則(Thread Start Rule):Thread對象的start()方法先發執行發生在此線程的每一個動作。
  • 線程終止規則(Thread Termination Rule):線程中的所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。
  • 線程中斷規則(Thread Interrupt Rule):對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到的中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 對象終結規則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize方法的開始。
  • 傳遞性(Transitivity):如果操作先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

但是一個操作“時間上的先發生”不代表這個操作會“先行發生”。時間先後順序與先行發生原則基本沒有太大的關係,所以很亮併發安全問題的時候不要受時間順序的干擾,一切必須以先行發生原則爲準。

線程狀態轉換

Java語言定義了5種線程狀態,在任意一個時間點,一個線程只能有其中一種狀態,5種狀態分別爲:

新建(New):創建後尚未啓動的線程處於這種狀態(無限等待和限期等待都是一種狀態);

運行(Runable):Runable包括了操作系統線程狀態中的Running和Ready,也就是處於此狀態的線程有可能正在執行,也有可能正在等待CPU爲它分配執行時間;

無限等待(Waiting):處於這種狀態的線程不會被分配CPU執行時間,它們要等待被其他線程顯示地喚醒。以下方法會讓線程陷入無限的等待狀態:

  • 沒有設置Timeout參數的Object.wait()方法;
  • 沒有設置Timeout參數的Thread.join()方法;
  • LockSuppoort.park()方法;

限期等待(Timed Waiting):處於這種狀態的線程也不會分配CPU執行時間,不過無需等待被其他線程顯示地喚醒,在一定時間之後它會由系統自動喚醒。以下方法會讓線程進入限期等待狀態:

  • Thread.sleep()方法;
  • 設置了Timeout參數的Object.wait()方法;
  • 設置了TimeOut參數的Thread.join()方法;
  • LockSupport.parkNanos()方法;
  • LockSupport.parkUntil()方法。

阻塞(Blocked):線程被阻塞了,“阻塞狀態”與“等待狀態”的區別是:“阻塞狀態”在等待着獲取獲取一個排他鎖,這個事件將在另外一個線程放棄這個鎖的時候發生;而“等待狀態”則是在等待一段時間,或者喚醒動作的發生。在程序等待進入同步區域的時候,線程將進入這種狀態。

結束(Terminated):已終止線程的線程狀態,線程已經結束執行。

   線程狀態轉換關係

 

關於wait、notify、notify的使用說明:

這三個方法是Object的三個Native方法,調用一個Object的這三個方法,必須保證調用代碼對該Object是同步的,也就是說必須作用在等同於synchronized(obj){}的內部才能去調用obj的wait與notify/notifyAll三個方法。也就是說,在調用這三個方法的時候,當前線程必須獲得這個對象的鎖。而3個方法的作用:

wait:線程自動釋放其佔有的對象鎖,並等待notify,此方法執行完成之後,鎖立即被釋放,再次得到鎖之後,執行wait之後的程序;

notify:喚醒一個正在wait當前對象鎖的線程,並讓它拿到對象鎖(同步塊執行完之後釋放鎖);

notifyAll:喚醒所有正在wait當前對象鎖的線程(同步塊執行完之後釋放鎖)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章