1. 程式人生 > >深入理解Java虛擬機器學習筆記——四、Java記憶體模型與多執行緒

深入理解Java虛擬機器學習筆記——四、Java記憶體模型與多執行緒

一、Java記憶體模型

Java記憶體模型的意義:遮蔽掉各種硬體和作業系統的記憶體訪問差異,以實現Java程式在各種平臺上一致的記憶體訪問效果。

1、主記憶體與工作記憶體

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中把變數儲存到記憶體和從記憶體中取出變數。此處的變數是指例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數和方法引數(區域性變數和方法引數是執行緒私有的,所以不存在競爭問題)。
Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory)中(此處記憶體不是實體記憶體,而是虛擬機器記憶體的一部分)。每條執行緒還有自己的工作記憶體(Working Memory,類似於處理器的快取記憶體),執行緒的工作記憶體中儲存了該執行緒使用到的變數的主記憶體副本拷貝
。執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞都需要通過主記憶體來完成,執行緒、工作記憶體、主記憶體三者的互動關係如下圖所示:
①虛擬機器只會拷貝某個物件的引用、物件中某個線上程訪問到的欄位,而不會將整個物件拷貝到工作記憶體中。
② volatile依然有工作記憶體的拷貝,但是由於它特殊的操作順序性,所以看起來像直接在主記憶體中讀寫訪問一樣。

2、記憶體間互動操作

對於一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回住記憶體之類的實現細節,Java記憶體模型定義了以下8中操作來完成,虛擬機器實現必須保證下面提及的每一種操作都是原子的、不可再分的(對於long和double型別的變數來說,load、store、read和write操作在某些平臺上允許有例外):
  • lock(鎖定):作用於主記憶體的變數,把一個變數標識為一條執行緒獨佔的狀態。
  • unlock(解鎖):作用於主記憶體的變數,把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read(讀取):作用於主記憶體的變數,把一個變數的值從主記憶體傳輸到執行緒的工作記憶體,以便隨後的load動作使用。
  • load(載入):作用於工作記憶體的變數,把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use(使用):作用於工作記憶體的變數,把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。
  • assign(賦值):作用於工作記憶體的變數,把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作。
  • store(儲存):作用於工作記憶體,把工作記憶體中的一個變數的值傳送給主記憶體中,以便隨後的write操作使用。
  • write(寫入):作用於主記憶體的變數,把store操作從工作記憶體得到的變數的值放入主記憶體的變數中。
如果要把一個變數從主記憶體複製到工作區,就要順序地執行read和load操作;如果要把變數從工作記憶體同步回主記憶體,就要順序的執行store和write操作。
Java記憶體模型只要求上述兩個操作必須按順序執行,而不是連續執行。因此在read與load之間、store與write之間是可插入其他指令的。除此之外,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操作)。

3、volatile變數的特殊規則

volatile可以說是Java虛擬機器提供的最輕量級的同步機制。Java記憶體模型對volatile專門定義了一些特殊的訪問規則。 當一個變數被定義為volatile後,它將具備兩種特性:
(1)保證此變數對所有執行緒的可見性。 可見性是指當一個執行緒修改了這個變數的值後,新值對於其他執行緒來說是可以立即得知的。 由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景中,仍然要通過加鎖(synchronized和java.util.concurrent中的原子類)來保證原子性:
  • 運算結果並不依賴變數的當前值,或者能夠保證只有單一的執行緒修改變數的值。
  • 變數不需要與其他的狀態變數參與不變約束。
(2)禁止指令重排序優化。 普通變數僅僅能夠保證在該方法的執行過程中所有依賴賦值結果的地方都能獲取到正確的結果,而不能保證變數賦值操作的順序與程式碼中的執行順序一致。指令重排序是指CPU採用了允許將多條指令不按程式規定的順序分開發送給各相應電路單元處理。
假定T表示一個執行緒,V和W分別表示兩個volatile變數,那麼在進行read、load、use、assign、store和write操作時需要滿足如下規則:
  • 只有當執行緒T對變數V執行的前一個動作是load的時候,執行緒T才能對變數V執行use操作;並且,只有當執行緒T對變數V執行的後一個動作是use時,執行緒T才能對變數V執行load操作。執行緒T對變數V的use動作可以認為是和執行緒T對變數V的load、read動作相關聯,必須連續一起出現(該規則要求在工作記憶體中,每次使用變數V的值都必須先從主記憶體中重新整理最新的值,用於保證能夠看見其他執行緒修改變數後的值)。
  • 只有當執行緒T對變數V執行的前一個動作是assign時,執行緒T才能對變數V執行store動作;並且,只有當執行緒T對變數V執行的後一個動作是store時,執行緒T才能對變數V執行assign動作。執行緒T對變數V的assign動作可以認為是和執行緒T對變數V的store、write動作相關聯,必須連續一起出現(該規則要求在工作記憶體中,每次修改V後都必須立刻同步回主記憶體中,用於保證其它執行緒可以看到自己對變數V所做的修改)。
  • volatile修飾的變數不會被指令重排序優化,保證指令的執行順序與程式的順序相同。

4、long和double型變數的特殊規則

Java記憶體模型要求lock、unlock、read、load、assign、use、store、write操作都具有原子性,但是對於64位的long和double允許虛擬機器將未被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機器不保證64位資料型別的read、load、store、write這4個操作的原子性,這就是所謂的long和double的非原子協定。

5、原子性、可見性和有序性

Java記憶體模型是圍繞著在併發過程中如何處理原子性、可見性和有序性這3個特徵來建立的。
  • 原子性(Atomicity)
由Java記憶體模型來直接保證的原子性操作包括:read、load、assign、use、store、write,可以大致的認為基本資料型別的讀寫都是具備原子性的。Java虛擬機器提供了更高層次的位元組碼指令monitorenter和monitorexit來隱式使用更大範圍的原子性操作lock和unlock,這兩個位元組碼指令反映到Java程式碼中就是synchronized,因此在synchronized程式碼塊之間的操作也具備原子性。
  • 可見性(Visibility)
可見性是指當一個執行緒修改了變數的值,其他的執行緒能夠立刻感知這個修改。Java記憶體模型是通過在修改過變數後將變數的值同步回主記憶體、在讀取變數前從主記憶體重新整理變數的值,這種依賴主記憶體作為傳遞媒介的方式來實現可見性的,無論是普通變數還是被volatile修飾的變數都是如此。普通變數與被volatile修飾的變數的區別是,被volatile修飾的變數能夠在變數值改變後立即將新值同步回主記憶體,以及每次使用前立即從主記憶體中重新整理。因此,volatile關鍵字保證了多執行緒環境下變數的可見性,而普通變數無法保證這一點。除了volatile外,Java中還有兩個關鍵字可以保證可見性:synchronized和final。synchronized的可見性是由“對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體”這條規則實現的。final的可見性是指被final修飾的的欄位在構造器中一旦初始化完成,並且構造器沒有將“this”的引用傳遞出去,那麼在其他執行緒中就能夠看到final欄位的值。
  • 有序性(Ordering)
Java中天然的有序性可以總結為一句話:在本執行緒內觀察,所有操作都是有序的;在一個執行緒中觀察另一個執行緒,所有操作都是無序的。前半句是指“執行緒內表現為序列的語義”,後半句是指“指令重排序”現象和“工作記憶體與主記憶體之間同步延遲”現象。Java語言提供了volatile和synchronized關鍵字來保證執行緒間操作的有序性,volatile關鍵字本身就包含了禁止指令重排序的語義,而synchronized則是由“一個變數在同一時刻只允許一個執行緒對其lock操作”這條規則獲得的。

6、先行發生(happens-before)原則

該原則是判斷資料是否存在競爭、執行緒是否安全的主要依據。“先行發生”原則是指Java記憶體模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能夠被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。 下面是Java記憶體模型中一些“天然的”先行發生關係,這些先行發生關係不需要任何同步器協助,可以在程式碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來的話,它們就沒有順序性保障,虛擬機器可以隨意的對它們進行重排序。
  • 程式次序規則(Program Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確的說,應該是控制流順序而不是程式程式碼順序。
  • 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是同一個鎖,而“後面”是指時間上的先後順序。
  • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,“後面”同樣是指時間上的先後順序。
  • 執行緒啟動規則(Thread Start Rule):Thread的start方法先行發生於此執行緒的每一個操作。
  • 執行緒終止規則(Thread Termination Rule):執行緒的所有操作都先行發生於對此執行緒的終止檢測,可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測執行緒是否終止執行。
  • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finallize()方法的開始。
  • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那麼操作A先行發生於操作C。
一個操作“時間上的先發生”不代表這個操作是“先行發生”,“先行發生”同樣不是“時間上的先發生”。

二、Java與執行緒

1、執行緒的實現

執行緒是比程序更輕量級的排程執行單位,執行緒的引入,可以把一個程序的資源分配與執行排程分開,各個執行緒既可以共享程序資源(記憶體地址、檔案I/O等),又可以獨立排程(執行緒是CPU排程的基本單位)。 每個已經執行了start()方法且還未結束的java.lang.Thread類的例項就代表了一個執行緒。Thread類的所有關鍵方法都是宣告為Native的。在Java API中,一個Native方法往往意味著這個方法沒有使用或無法使用與平臺無關的手段實現。 實現執行緒主要有3中方式:
  • 使用核心執行緒實現
核心執行緒(Kernel-Level Thread,KLT)就是直接由作業系統核心(Kernel)支援的執行緒,這種執行緒由核心來完成執行緒切換,核心通過操縱排程器(Scheduler)對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。 程式一般不會直接使用核心執行緒,而是使用核心執行緒的一種高階介面——輕量級程序(Light Weight Process,LWP),輕量級程序就是通常意義上所講的執行緒。每個輕量級程序都是一個獨立的排程單元,所以即使有一個輕量級程序阻塞了,也不會影響到整個程序的工作。但是輕量級也有侷限性:由於輕量級程序是基於核心執行緒實現的,所以各種執行緒操作(建立、析構、同步)都需要進行系統呼叫,而系統呼叫的代價是很高昂的,需要在使用者態(User Mode)和核心態(Kernel Mode)來回切換。其次,每個輕量級程序都需要核心執行緒的支援,因此輕量級程序需要消耗掉一定的核心資源(如核心執行緒的棧空間),因此一個系統支援的輕量級程序數是有限的。 這種輕量級程序和核心執行緒一對一的關係被稱為一對一的執行緒模型。
  • 使用使用者執行緒實現
從廣義上講,一個執行緒只要不屬於核心執行緒,那麼就可以認為是使用者執行緒(User Thread,UT)。因此,從這個定義上看,輕量級程序也屬於使用者執行緒,只不過由於輕量級程序需要核心執行緒的支援,許多操作都需要進行系統排程,效率會受到限制。 而狹義上的使用者執行緒是指完全建立在使用者空間的執行緒庫上,系統核心無法感知執行緒存在的實現。使用者執行緒的建立、同步、銷燬都是在使用者態下完成的,不需要核心的幫助。這種程序與使用者執行緒一對多的關係被稱為一對多的執行緒模型。
  • 使用使用者執行緒加輕量級程序混合實現
這種實現,既存在使用者執行緒,也存在輕量級程序。使用者執行緒還是建立在使用者空間上,因此執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發。而作業系統提供的輕量級程序則作為使用者執行緒和核心執行緒之間的橋樑,這樣可以使用核心提供的執行緒排程功能及處理器對映,並且使用者執行緒的系統呼叫通過輕量級程序完成,大大降低了整個程序被阻塞的風險。這種方式中,使用者執行緒與輕量級程序的數量比例是不確定的,即多對多的執行緒模型。

2、Java執行緒的實現

在JDK 1.2之前,是基於“綠色執行緒”的使用者執行緒實現的;在JDK 1.2中,執行緒模型替換為基於作業系統原生執行緒模型來實現。Sun JDK在Windows和Linux系統中,都是使用一對一的執行緒模型,一條Java執行緒就對映到一個輕量級程序中。在Solaris中,由於作業系統的可以同時支援一對一(通過Bound Threads或Alternate Libthread實現)以及多對多(通過LWP/Thread Based Synchronization實現)的執行緒模型,所以Solaris版本的JDK提供了-XX:+UseLWPSynchronization(預設)和-XX:+UseBoundThreads來指定虛擬機器所使用的執行緒模型。

3、Java執行緒排程

執行緒排程是指系統為執行緒分配處理器使用權的過程,主要有兩種排程方式:協同式執行緒排程和搶佔式執行緒排程。在協同式執行緒呼叫方式中,執行緒的執行時間由執行緒本身控制,執行緒在執行完成後,主動通知系統切換執行緒。搶佔式執行緒排程中,每個執行緒由系統來分配執行時間,執行緒的切換不由執行緒本身決定。

4、狀態轉換

Java語言定義了5中執行緒狀態,在任一時刻,一個執行緒有且只有一種狀態:
  • 新建(New):建立後尚未啟動的執行緒處於該狀態。
  • 執行(Runnable):Runnable包括了作業系統執行緒狀態的Running和Ready,處於此狀態的執行緒有可能正在執行,也有可能在等待CPU分配時間。
  • 無限期等待(Waiting):處於該狀態的執行緒不會分配處理器執行時間,它們要等待被其他執行緒顯示的喚醒。以下方法會讓執行緒陷入無限期等待狀態:
沒有設定Timeout引數的Object.wait()方法。 沒有設定Timeout引數的Thread.join()方法。 LockSupport.park()方法。
  • 期限等待(Timed Waiting):處於該狀態的執行緒也不會被分配處理器時間,不過無需等待被其他執行緒顯示的喚醒,在一定時間後會被系統自動喚醒。以下方法會讓執行緒進入期限等待:
Thread.sleep()方法 設定了Timeout引數的Object.wait()方法。
設定了Timeout引數的Thread.join()方法。
LockSupport.parkNanos()方法。 LockSupport.parkUntil()方法。
  • 阻塞(Blocked):執行緒被阻塞了,“等待狀態”與“阻塞狀態”的區別:“阻塞狀態”在等待著獲得一個排他鎖,這個事件將會在另外一個執行緒放棄這個鎖是發生;而“等待狀態”則是在等待一段時間或者喚醒動作的發生
  • 結束(Terminated):已終止的執行緒的執行緒狀態,執行緒已經執行結束。
這5中狀態的轉換關係如下圖所示: