要點提煉| 理解JVM之記憶體模型&執行緒

釐米姑娘發表於2018-07-18

本篇將介紹虛擬機器如何實現多執行緒、多執行緒之間由於共享和競爭資料而導致的一系列問題及解決方案。

  • 概述
  • Java記憶體模型
  • Java與執行緒

1.概述

a.多工處理的必要性:

  • 充分利用計算機處理器的能力,避免處理器在磁碟I/O、網路通訊或資料庫訪問時總是處於等待其他資源的狀態。
  • 便於一個服務端同時對多個客戶端提供服務。通過指標TPS(Transactions Per Second)可衡量一個服務效能的高低好壞,它表示每秒服務端平均能響應的請求總數,進而體現出程式的併發能力。

b.硬體的效率與一致性

為了更好的理解Java記憶體模型,先理解物理計算機中的併發問題,兩者有很高的可比性。

為了平衡計算機的儲存裝置與處理器的運算速度之間幾個數量級的差距,引入一層快取記憶體(Cache)來作為記憶體與處理器之間的緩衝:

  • 將運算需要使用到的資料複製到快取中,讓運算能快速進行;
  • 當運算結束後再從快取同步回記憶體之中,而無須讓處理器等待緩慢的記憶體讀寫。

但是基於快取記憶體的儲存互動在多處理器系統中會帶來快取一致性(Cache Coherence)的問題。這是因為每個處理器都有自己的快取記憶體,而它們又共享同一主記憶體(Main Memory),當多個處理器的運算任務都涉及同一塊主記憶體區域時,就可能導致各自的快取資料不一致。解決辦法就是需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作。如下圖。

要點提煉| 理解JVM之記憶體模型&執行緒

因此,這裡所說的記憶體模型可以理解為:在特定的操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象。


2.Java記憶體模型(Java Memory Model,JMM)

a.目的:遮蔽掉各種硬體和作業系統的記憶體訪問差異,實現Java程式在各種平臺下都能達到一致的記憶體訪問效果。

b.方法:通過定義程式中各個變數訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。

注意:這裡的變數與Java中說的變數不同,而指的是例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數,因為後者是執行緒私有的,不會被共享,自然就不會存在競爭問題。

c.結構:模型結構如圖,和上張圖進行類比。

要點提煉| 理解JVM之記憶體模型&執行緒

  • 主記憶體(Main Memory):所有變數的儲存位置。直接對應於物理硬體的記憶體。

注意:這裡的主記憶體、工作記憶體與要點提煉| 理解JVM之記憶體管理說的Java記憶體區域中的Java堆、棧、方法區等並不是同一個層次的記憶體劃分。

  • 工作記憶體(Working Memory):每條執行緒還有自己的工作記憶體,用於儲存被該執行緒使用到的變數的主記憶體副本拷貝。為了獲取更好的執行速度,虛擬機器可能會讓工作記憶體優先儲存於暫存器和快取記憶體中。

注意

  • 執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。
  • 不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞必須通過主記憶體來完成。
  • 互動協議:用於規定一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節。共有8種操作:
    • ①用於主記憶體變數:
    • 鎖定lock):把變數標識為一條執行緒獨佔的狀態。
    • 解鎖unlock):把處於鎖定狀態的變數釋放出來。
    • 讀取read):把變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用。
    • 載入load):把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
    • ②用於工作記憶體變數:
    • 使用use):把工作記憶體中一個變數的值傳遞給執行引擎。
    • 賦值assign):把從執行引擎接收到的值賦給工作記憶體的變數。
    • 儲存store):把工作記憶體中變數的值傳送到主記憶體中,以便隨後的write操作使用。
    • 寫入write):把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

結論:注意是順序非連續

  • 如果要把變數從主記憶體複製到工作記憶體,那就要順序地執行readload
  • 如果要把變數從工作記憶體同步回主記憶體,就要順序地執行storewrite

d.確保併發操作安全的原則

①在Java記憶體模型中規定了執行上述8種基本操作時需要滿足如下規則:

  • 不允許readloadstorewrite操作之一單獨出現,即不允許一個變數從主記憶體讀取了但工作記憶體不接受,或者從工作記憶體發起回寫了但主記憶體不接受的情況出現。
  • 不允許一個執行緒丟棄它的最近的assign操作,即變數在工作記憶體中改變了之後必須把該變化同步回主記憶體。
  • 不允許一個執行緒無原因地,即沒有發生過任何assign操作就把資料從執行緒的工作記憶體同步回主記憶體中。
  • 一個新的變數只能在主記憶體中“誕生”,不允許在工作記憶體中直接使用一個未被初始化(loadassign)的變數,即對一個變數實施usestore操作之前必須先執行過了assignload操作。
  • 一個變數在同一個時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖。
  • 如果對一個變數執行lock操作,那將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行loadassign操作初始化變數的值。
  • 如果一個變數事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他執行緒鎖定住的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中。

可見這麼多規則非常繁瑣,實踐也麻煩,下面再介紹一個等效判斷原則--先行發生原則。

先行發生原則:是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):一個物件的初始化完成先行發生於它的finalize()的開始。
  • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那麼操作A一定先行發生於操作C。

e.Java記憶體模型保證併發過程的原子性、可見性和有序性的措施:

  • 原子性(Atomicity):一個操作要麼都執行要麼都不執行。
    • 可直接保證的原子性變數操作有:readloadassignusestorewrite,因此可認為基本資料型別的訪問讀寫是具備原子性的。
    • 若需要保證更大範圍的原子性,可通過更高層次的位元組碼指令monitorentermonitorexit來隱式地使用lockunlock這兩個操作,反映到Java程式碼中就是同步程式碼塊synchronized關鍵字。
  • 可見性(Visibility):當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。
    • 通過在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現。
    • 提供三個關鍵字保證可見性:volatile能保證新值能立即同步到主記憶體,且每次使用前立即從主記憶體重新整理;synchronized對一個變數執行unlock操作之前可以先把此變數同步回主記憶體中;被final修飾的欄位在構造器中一旦初始化完成且構造器沒有把this的引用傳遞出去,就可以在其他執行緒中就能看見final欄位的值。
  • 有序性(Ordering):程式程式碼按照指令順序執行。
    • 如果在本執行緒內觀察,所有的操作都是有序的,指“執行緒內表現為序列的語義”;如果在一個執行緒中觀察另一個執行緒,所有的操作都是無序的,指“指令重排序”現象和“工作記憶體與主記憶體同步延遲”現象。
    • 提供兩個關鍵字保證有序性:volatile 本身就包含了禁止指令重排序的語義;synchronized保證一個變數在同一個時刻只允許一條執行緒對其進行lock操作,使得持有同一個鎖的兩個同步塊只能序列地進入。

3.Java與執行緒

a.執行緒實現的三種方式

①使用核心執行緒(Kernel-Level Thread,KLT)

  • 定義:直接由作業系統核心支援的執行緒。
  • 原理:由核心來完成執行緒切換,核心通過操縱排程器(Scheduler)對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身, 這樣作業系統就有能力同時處理多件事情。
  • 多執行緒核心(Multi-Threads Kernel):支援多執行緒的核心
  • 輕量級程式(Light Weight Process,LWP):核心執行緒的一種高階介面
    • 優點:每個輕量級程式都由一個核心執行緒支援,因此每個都成為一個獨立的排程單元,即使有一個輕量級程式在系統呼叫中阻塞,也不會影響整個程式繼續工作。
    • 缺點:由於基於核心執行緒實現,所以各種執行緒操作(建立、析構及同步)都需要進行系統呼叫,代價相對較高,需要在使用者態(User Mode)和核心態(Kernel Mode)中來回切換;另外,一個系統支援輕量級程式的數量是有限的。
    • 一對一執行緒模型:輕量級程式與核心執行緒之間1:1的關係,如圖所示

要點提煉| 理解JVM之記憶體模型&執行緒

②使用使用者執行緒(User Thread,UT)

  • 定義:廣義上認為一個執行緒不是核心執行緒就是使用者執行緒;狹義上認為使用者執行緒指的是完全建立在使用者空間的執行緒庫上,而系統核心不能感知執行緒存在的實現。
  • 優點:由於使用者執行緒的建立、同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助,甚至可以不需要切換到核心態,所以操作非常快速且低消耗的,且可以支援規模更大的執行緒數量。
  • 缺點:由於沒有系統核心的支援,所有的執行緒操作都需要使用者程式自己處理,執行緒的建立、切換和排程都是需要考慮的問題,實現較複雜。
  • 一對多的執行緒模型程式:程式與使用者執行緒之間1:N的關係,如圖所示

要點提煉| 理解JVM之記憶體模型&執行緒

③使用使用者執行緒加輕量級程式混合

  • 定義:既存在使用者執行緒,也存在輕量級程式。
  • 優點:使用者執行緒完全建立在使用者空間中,因此使用者執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發;作業系統提供支援的輕量級程式作為使用者執行緒和核心執行緒之間的橋樑,可以使用核心提供的執行緒排程功能及處理器對映,且使用者執行緒的系統呼叫要通過輕量級執行緒來完成,大大降低了整個程式被完全阻塞的風險。
  • 多對多的執行緒模型:使用者執行緒與輕量級程式的數量比不定,即使用者執行緒與輕量級程式之間N:M的關係,如圖所示

要點提煉| 理解JVM之記憶體模型&執行緒

那麼Java執行緒的實現是選擇哪一種呢?答案是不確定的。作業系統支援怎樣的執行緒模型,在很大程度上決定了Java虛擬機器的執行緒是怎樣對映的。執行緒模型只對執行緒的併發規模和操作成本產生影響,而對Java程式的編碼和執行過程來說,這些差異都是透明的。

b.Java執行緒排程的兩種方式

執行緒排程:指系統為執行緒分配處理器使用權的過程。

協同式執行緒排程(Cooperative Threads-Scheduling)

  • 執行緒本身來控制執行緒的執行時間。執行緒把自己的工作執行完後,要主動通知系統切換到另外一個執行緒上。
  • 好處:實現簡單;切換操作自己可知,不存線上程同步的問題。
  • 壞處:執行緒執行時間不可控,假如一個執行緒編寫有問題一直不告知系統進行執行緒切換,那麼程式就會一直被阻塞。

搶佔式執行緒排程(Preemptive Threads-Scheduling)

  • 系統來分配每個執行緒的執行時間。
  • 好處:執行緒執行時間是系統可控的,不存在一個執行緒導致整個程式阻塞的問題。
  • 可以通過設定執行緒優先順序,優先順序越高的執行緒越容易被系統選擇執行。

但是執行緒優先順序並不是太靠譜,一方面因為Java的執行緒是通過對映到系統的原生執行緒上來實現的,所以執行緒排程最終還是取決於作業系統,在一些平臺上不同的優先順序實際會變得相同;另一方面優先順序可能會被系統自行改變。

c.執行緒的五種狀態

在任意一個時間點,一個執行緒只能有且只有其中的一種狀態:

  • 新建(New):執行緒建立後尚未啟動
  • 執行(Runable):包括正在執行(Running)和等待著CPU為它分配執行時間(Ready)兩種
  • 無限期等待(Waiting):該執行緒不會被分配CPU執行時間,要等待被其他執行緒顯式地喚醒。以下方法會讓執行緒陷入無限期等待狀態:
    • 沒有設定Timeout引數的Object.wait()
    • 沒有設定Timeout引數的Thread.join()
    • LockSupport.park()
  • 限期等待(Timed Waiting):該執行緒不會被分配CPU執行時間,但在一定時間後會被系統自動喚醒。以下方法會讓執行緒進入限期等待狀態:
    • Thread.sleep()
    • 設定了Timeout引數的Object.wai()
    • 設定了Timeout引數的Thread.join()
    • LockSupport.parkNanos()
    • LockSupport.parkUntil()
  • 阻塞(Blocked):執行緒被阻塞

注意區別

  • 阻塞狀態:在等待獲取到一個排他鎖,在另外一個執行緒放棄這個鎖的時候發生;
  • 等待狀態:在等待一段時間或者喚醒動作的發生,在程式等待進入同步區域的時候發生。
  • 結束(Terminated):執行緒已經結束執行

下圖是執行緒狀態之間的轉換:

要點提煉| 理解JVM之記憶體模型&執行緒

相關文章