多執行緒之Java記憶體模型(JMM)(一)

karspb發表於2021-09-09

在未正確使用鎖的時候,多執行緒的程式可能變的很容易出錯,並且難以排查。而JMM則給我們一種規範,它描述了多執行緒程式如何與記憶體互動。
圖片描述

JMM大致描述:

  • JMM描述了執行緒如何與記憶體進行互動。Java虛擬機器規範檢視定義一種Java記憶體模型,來遮蔽掉各種作業系統記憶體訪問的差異,以實現Java程式在各種平臺下都能達到一致的訪問效果。
  • JMM描述了JVM如何與計算機的記憶體進行互動
  • JMM都是圍繞著原子性,有序性和可見性進行展開的

JMM的主要目標是定義程式中各個變數的訪問規則,虛擬機器將變數儲存到記憶體和從記憶體取出變數這樣的底層細節。此處的變數指在堆中儲存的元素。

多執行緒的時候為什麼容易出錯?

Java記憶體模型規定所有的共享變數都儲存在主記憶體中,而每條執行緒有自己的工作記憶體(本地記憶體),工作記憶體儲存了共享變數的副本,而不同記憶體又無法訪問對方的工作記憶體,所以如果執行緒在工作記憶體中修改了變數副本,其它執行緒是無從得知的。

執行緒的傳值均需要透過主記憶體來完成

圖片描述
圖片描述

主記憶體與工作記憶體如何互動?

Java記憶體模型定義了8種操作來完成主記憶體與工作記憶體的互動細節,虛擬機器必須保證這8種操作的每一個操作都是原子的,不可再分的。

  • lock: 作用於主記憶體的變數,把變數標識為執行緒獨佔的狀態
  • unlock: 與lock對應,把主記憶體中處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read: 作用於主記憶體的變數,把一個變數的值從主記憶體傳輸到執行緒的工作記憶體,便於隨後的load使用。
  • load:作用於工作記憶體的變數,把read讀取到的變數放入工作記憶體副本
  • use: 作用於工作記憶體,把工作記憶體的變數值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。
  • assign: 作用於工作記憶體,把執行引擎收到的值賦給工作記憶體的變數,虛擬機器遇到賦值位元組碼時候執行這個操作
  • store:作用於工作記憶體,把變數的值傳輸到住記憶體中,以便隨後的write使用
  • write:作用於主記憶體,把store操作從工作記憶體得到的值放入主記憶體的變數中。

圖片描述

執行上述8種基本操作的規則:

  • 不允許read和load,store和write操作之一單獨出現。
  • 不允許一個執行緒丟棄它最近的assign操作。即變數在工作記憶體中改變了賬號必須把變化同步回主記憶體
  • 一個新的變數只允許在主記憶體中誕生,不允許工作記憶體直接使用未初始化的變數。
  • 一個變數同一時刻只允許一條執行緒進行lock操作,但同一執行緒可以lock多次,lock多次之後必須執行同樣次數的unlock操作
  • 如果對一個變數進行lock操作,那麼將會清空工作記憶體中此變數的值。
  • 不允許對未lock的變數進行unlock操作,也不允許unlock一個被其它執行緒lock的變數
  • 如果一個變數執行unlock操作,必須先把次變了同步回主記憶體中。

這8種操作定義相當嚴禁,實踐起來又比較麻煩,但是可以有助於我們理解多執行緒的工作原理。有一個與此8種操作相等的Happen-before原則。

Happen-before原則

這個是Java記憶體模型下無需任何同步器協助就已經存在,可以直接在編碼中使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推匯出來的話,它們的順序就沒有保障,虛擬機器可以對他們進行任意的重排。

天然的happen-before

  • 程式順序原則:一個執行緒內包裝語義的序列性
  • volatile變數的寫,先發生於讀,這保證了volatile變數的可見性
  • 鎖規則:unlock先與lock
  • 傳遞性:A 先於B,B先於C,那麼A必然先於C
  • 執行緒的start先於執行緒的每一個動作
  • 執行緒的所有操作優先於執行緒的終結(Thread.join())
  • 執行緒的中斷(interupt)先於被中斷執行緒的程式碼
  • 物件的建構函式執行,先於finalize()方法

Java執行時資料區

JVM定義了一些程式執行時會使用到的執行時資料區,其中一些會隨著虛擬機器啟動而建立,隨著虛擬機器退出而銷燬。另外一些是與現場一一對應的,這些執行緒對應的資料區會隨著執行緒的開始和結束而建立和銷燬。
這部分參考JVM規範

1. pc暫存器

可以支援多條執行緒同時允許,每一條Java虛擬機器執行緒都有自己的pc暫存器。任意時刻,一條JVM執行緒之後執行一個方法的程式碼,這個方法被稱為當前方法(current method)
如果這個方法不是native的,那麼PC暫存器就儲存JVM正在執行的位元組碼指令地址。
如果是native的,那麼pc暫存器的值為undefined
pc暫存器的容量至少能保證一個returnAddress型別的資料或者一個平臺無關的本地指標的值。

2. JVM Stack(虛擬機器棧)
  • 每一個JVM執行緒都有自己的私有虛擬機器棧,這個棧與執行緒同時建立,用於儲存棧幀(Frame)。
  • 棧用來儲存區域性變數與一些過程結果的地方。在方法呼叫和返回中也扮演了很重要的角色。
  • 棧可以試固定分配的也可以動態調整
    • 如果請求執行緒分配的容量超過JVM棧允許的最大容量,丟擲StackOverflowError異常
    • 如果JVM棧可以動態擴充套件,擴充套件的動作也已經嘗試過,但是沒有申請到足夠的記憶體,則丟擲OutofMemoryError異常
3. Heap(堆)

堆是可以可供各個執行緒共享的執行時儲存區域,也是供所有類的例項和陣列物件分配記憶體的區域。堆在JVM啟動的時候建立。
堆所儲存的就是被GC所管理的各種物件。
堆也是可以固定大小和動態調整的:
實際所需的堆超過的GC所提供的最大容量,那麼JVM丟擲OutofMemoryError異常。

4. Method Area(方法區)

也是各個執行緒共享的執行時記憶體區,它儲存每一個類的例項資訊,執行時常量池,欄位和方法資料,建構函式和普通方法的位元組碼等內容。還有一些特殊方法。

方法區是堆的邏輯組成部分,也在JVM啟動時建立,簡單的JVM可以不實現這個區域的垃圾收集。

方法區也可固定大小和動態分配與堆一樣,記憶體空間不夠,那麼JVM丟擲OutofMemoryError異常。

5. Run-Time Constant Pool(執行時常量池)

在方法區中分配,在載入類和介面到虛擬機器之後,就建立對應的執行時常量池。

它是class檔案中每一個類或介面的常量池表的執行時表現形式。像字串。Java的主要型別。

儲存區域不夠用時候丟擲OutofMemoryError異常。

6. Native Method Stacks(原生方法棧或本地方法棧)

JDK中native的方法,System類和Thread類中有很多。使用C語言編寫的方法,這個也通常叫做C stack。

可以不支援本地方法棧,但是如果支援的時候,這個棧一般會線上程建立的時候按執行緒分配。

與棧的錯誤一樣,StackOverFlowError和OutOfMemeoryError.

一個案例

圖片描述

  • 一個本地變數可能是原始型別,在這種情況下,它總是“呆在”執行緒棧上。
  • 一個本地變數也可能是指向一個物件的一個引用。在這種情況下,引用(這個本地變數)存放線上程棧上,但是物件本身存放在堆上。
  • 一個物件可能包含方法,這些方法可能包含本地變數。這些本地變數任然存放線上程棧上,即使這些方法所屬的物件存放在堆上。
  • 一個物件的成員變數可能隨著這個物件自身存放在堆上。不管這個成員變數是原始型別還是引用型別。
  • 靜態成員變數跟隨著類定義一起也存放在堆上。
  • 存放在堆上的物件可以被所有持有對這個物件引用的執行緒訪問。當一個執行緒可以訪問一個物件時,它也可以訪問這個物件的成員變數。如果兩個執行緒同時呼叫同一個物件上的同一個方法,它們將會都訪問這個物件的成員變數,但是每一個執行緒都擁有這個本地變數的私有複製。

圖片描述

最後

這次主要講了一些規則相關的東西,及Java中執行時資料儲存的位置,建議看一下《深入理解JVM》最後一章。最好下載JSR-133規範對照著看。

參考:

  • 《深入理解Java虛擬機器》
  • 《Java高併發程式設計》
  • 《JVM specification》

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2001/viewspace-2800012/,如需轉載,請註明出處,否則將追究法律責任。

相關文章