內部原理
JVM 中試圖定義一種 JMM 來遮蔽各種硬體和作業系統的記憶體訪問差異,以實現讓 Java 程式在各種平臺下都能達到一致的記憶體訪問效果。
JMM 的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。此處的變數與 Java 程式設計中的變數有所區別,它包括了例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數,因為後者是執行緒私有的,不會被共享,自然就不會存在競爭問題。為了獲得較好的執行效能,Java 記憶體模型並沒有限制執行引擎使用處理器的特定暫存器或快取來和主存進行互動,也沒有限制即使編譯器進行調整程式碼執行順序這類優化措施。
JMM 是圍繞著在併發過程中如何處理原子性、可見性和有序性這 3 個特徵來建立的。
JMM 是通過各種操作來定義的,包括對變數的讀寫操作,監視器的加鎖和釋放操作,以及執行緒的啟動和合並操作。
記憶體模型結構
Java 記憶體模型把 Java 虛擬機器內部劃分為執行緒棧和堆。
執行緒棧
每一個執行在 Java 虛擬機器裡的執行緒都擁有自己的執行緒棧。這個執行緒棧包含了這個執行緒呼叫的方法當前執行點相關的資訊。一個執行緒僅能訪問自己的執行緒棧。一個執行緒建立的本地變數對其它執行緒不可見,僅自己可見。即使兩個執行緒執行同樣的程式碼,這兩個執行緒任然在在自己的執行緒棧中的程式碼來建立本地變數。因此,每個執行緒擁有每個本地變數的獨有版本。
所有原始型別的本地變數都存放線上程棧上,因此對其它執行緒不可見。一個執行緒可能向另一個執行緒傳遞一個原始型別變數的拷貝,但是它不能共享這個原始型別變數自身。
堆
堆上包含在 Java 程式中建立的所有物件,無論是哪一個物件建立的。這包括原始型別的物件版本。如果一個物件被建立然後賦值給一個區域性變數,或者用來作為另一個物件的成員變數,這個物件任然是存放在堆上。
- 一個本地變數可能是原始型別,在這種情況下,它總是線上程棧上。
- 一個本地變數也可能是指向一個物件的一個引用。在這種情況下,引用(這個本地變數)存放線上程棧上,但是物件本身存放在堆上。
- 一個物件可能包含方法,這些方法可能包含本地變數。這些本地變數任然存放線上程棧上,即使這些方法所屬的物件存放在堆上。
- 一個物件的成員變數可能隨著這個物件自身存放在堆上。不管這個成員變數是原始型別還是引用型別。
- 靜態成員變數跟隨著類定義一起也存放在堆上。
- 存放在堆上的物件可以被所有持有對這個物件引用的執行緒訪問。當一個執行緒可以訪問一個物件時,它也可以訪問這個物件的成員變數。如果兩個執行緒同時呼叫同一個物件上的同一個方法,它們將會都訪問這個物件的成員變數,但是每一個執行緒都擁有這個本地變數的私有拷貝。
硬體記憶體架構
現代硬體記憶體模型與 Java 記憶體模型有一些不同。理解記憶體模型架構以及 Java 記憶體模型如何與它協同工作也是非常重要的。這部分描述了通用的硬體記憶體架構,下面的部分將會描述 Java 記憶體是如何與它“聯手”工作的。
一個現代計算機通常由兩個或者多個 CPU。其中一些 CPU 還有多核。從這一點可以看出,在一個有兩個或者多個 CPU 的現代計算機上同時執行多個執行緒是可能的。每個 CPU 在某一時刻執行一個執行緒是沒有問題的。這意味著,如果你的 Java 程式是多執行緒的,在你的 Java 程式中每個 CPU 上一個執行緒可能同時(併發)執行。
每個 CPU 都包含一系列的暫存器,它們是 CPU 內記憶體的基礎。CPU 在暫存器上執行操作的速度遠大於在主存上執行的速度。這是因為 CPU 訪問暫存器的速度遠大於主存。
每個 CPU 可能還有一個 CPU 快取層。實際上,絕大多數的現代 CPU 都有一定大小的快取層。CPU 訪問快取層的速度快於訪問主存的速度,但通常比訪問內部暫存器的速度還要慢一點。一些 CPU 還有多層快取,但這些對理解 Java 記憶體模型如何和記憶體互動不是那麼重要。只要知道 CPU 中可以有一個快取層就可以了。
一個計算機還包含一個主存。所有的 CPU 都可以訪問主存。主存通常比 CPU 中的快取大得多。
通常情況下,當一個 CPU 需要讀取主存時,它會將主存的部分讀到 CPU 快取中。它甚至可能將快取中的部分內容讀到它的內部暫存器中,然後在暫存器中執行操作。當 CPU 需要將結果寫回到主存中去時,它會將內部暫存器的值重新整理到快取中,然後在某個時間點將值重新整理回主存。
當 CPU 需要在快取層存放一些東西的時候,存放在快取中的內容通常會被重新整理回主存。CPU 快取可以在某一時刻將資料區域性寫到它的記憶體中,和在某一時刻區域性重新整理它的記憶體。它不會再某一時刻讀/寫整個快取。通常,在一個被稱作“cache lines”的更小的記憶體塊中快取被更新。一個或者多個快取行可能被讀到快取,一個或者多個快取行可能再被重新整理回主存。
JMM 和硬體記憶體架構之間的橋接
上面已經提到,Java 記憶體模型與硬體記憶體架構之間存在差異。硬體記憶體架構沒有區分執行緒棧和堆。對於硬體,所有的執行緒棧和堆都分佈在主內中。部分執行緒棧和堆可能有時候會出現在 CPU 快取中和 CPU 內部的暫存器中。如下圖所示:
當物件和變數被存放在計算機中各種不同的記憶體區域中時,就可能會出現一些具體的問題。主要包括如下兩個方面:
- 執行緒對共享變數修改的可見性
- 當讀,寫和檢查共享變數時出現 race conditions
共享物件可見性
如果兩個或者更多的執行緒在沒有正確的使用 volatile 宣告或者同步的情況下共享一個物件,一個執行緒更新這個共享物件可能對其它執行緒來說是不接見的。
想象一下,共享物件被初始化在主存中。跑在 CPU 上的一個執行緒將這個共享物件讀到 CPU 快取中。然後修改了這個物件。只要 CPU 快取沒有被重新整理會主存,物件修改後的版本對跑在其它 CPU 上的執行緒都是不可見的。這種方式可能導致每個執行緒擁有這個共享物件的私有拷貝,每個拷貝停留在不同的 CPU 快取中。
上圖示意了這種情形。跑在左邊 CPU 的執行緒拷貝這個共享物件到它的 CPU 快取中,然後將 count 變數的值修改為 2。這個修改對跑在右邊 CPU 上的其它執行緒是不可見的,因為修改後的 count 的值還沒有被重新整理回主存中去。
解決這個問題你可以使用 Java 中的 volatile 關鍵字。volatile 關鍵字可以保證直接從主存中讀取一個變數,如果這個變數被修改後,總是會被寫回到主存中去。
競態條件
如果兩個或者更多的執行緒共享一個物件,多個執行緒在這個共享物件上更新變數,就有可能發生 race conditions。
想象一下,如果執行緒 A 讀一個共享物件的變數 count 到它的 CPU 快取中。再想象一下,執行緒 B 也做了同樣的事情,但是往一個不同的 CPU 快取中。現線上程 A 將 count 加 1,執行緒 B 也做了同樣的事情。現在 count 已經被增在了兩個,每個 CPU 快取中一次。
如果這些增加操作被順序的執行,變數 count 應該被增加兩次,然後原值+2 被寫回到主存中去。
然而,兩次增加都是在沒有適當的同步下併發執行的。無論是執行緒 A 還是執行緒 B 將 count 修改後的版本寫回到主存中取,修改後的值僅會被原值大 1,儘管增加了兩次。
解決這個問題可以使用 Java 同步塊。一個同步塊可以保證在同一時刻僅有一個執行緒可以進入程式碼的臨界區。同步塊還可以保證程式碼塊中所有被訪問的變數將會從主存中讀入,當執行緒退出同步程式碼塊時,所有被更新的變數都會被重新整理回主存中去,不管這個變數是否被宣告為 volatile。
Happens-Before
JMM 為程式中所有的操作定義了一個偏序關係,稱之為 Happens-Before。
- 程式順序規則:如果程式中操作 A 在操作 B 之前,那麼線上程中操作 A 將在操作 B 之前執行。
- 監視器鎖規則:在監視器鎖上的解鎖操作必須在同一個監視器鎖上的加鎖操作之前執行。
- volatile 變數規則:對 volatile 變數的寫入操作必須在對該變數的讀操作之前執行。
- 執行緒啟動規則:線上程上對 Thread.start 的呼叫必須在該執行緒中執行任何操作之前執行。
- 執行緒結束規則:執行緒中的任何操作都必須在其他執行緒檢測到該執行緒已經結束之前執行,或者從 Thread.join 中成功返回,或者在呼叫 Thread.isAlive 時返回 false。
- 中斷規則:當一個執行緒在另一個執行緒上呼叫 interrupt 時,必須在被中斷執行緒檢測到 interrupt 呼叫之前執行(通過丟擲 InterruptException,或者呼叫 isInterrupted 和 interrupted)。
- 終結器規則:物件的建構函式必須在啟動該物件的終結器之前執行完成。
- 傳遞性:如果操作 A 在操作 B 之前執行,並且操作 B 在操作 C 之前執行,那麼操作 A 必須在操作 C 之前執行。
免費Java資料需要自己領取,涵蓋了Java、Redis、MongoDB、MySQL、Zookeeper、Spring Cloud、Dubbo/Kafka、Hadoop、Hbase、Flink等高併發分散式、大資料、機器學習等技術。
傳送門:mp.weixin.qq.com/s/JzddfH-7y…