並發編程-Java記憶體模型到底是什麼
- 2019 年 10 月 25 日
- 筆記
記憶體模型
在電腦CPU,記憶體,IO三者之間速度差異,為了提高系統性能,對這三者速度進行平衡。
- CPU 增加了快取,以均衡與記憶體的速度差異;
- 作業系統增加了進程、執行緒,以分時復用 CPU,進而均衡 CPU 與 I/O 設備的速度差異;
- 編譯程式優化指令執行次序,使得快取能夠得到更加合理地利用。
以上三種系統優化,對於硬體的效率有了顯著的提升,但是他們同時也帶來了可見性,原子性以及順序性等問題。基於Cpu高速快取的存儲交互很好得解決了CPU和記憶體得速度矛盾,但是也提高了電腦系統得複雜度,引入了新的問題:快取一致性(Cache Coherence)。
每個處理器都有自己獨享得高速快取,多個處理器共享系統主記憶體,當多個處理器運算任務涉及到同一塊主記憶體區域時,將可能會導致數據不一致,這時以誰的數據為準就成了問題。為了解決一致性問題,各個處理器需要遵守一些協議,根據這些協議來進行讀寫操作。所以記憶體模型可以理解為是為了解決快取一致性問題,在特定的操作協議下,對特定的記憶體或高速快取進行讀寫的過程的抽象。
Java記憶體模型
JMM的作用
Java虛擬機規範試圖定義一種Java記憶體模型(Java Memory Model, JMM),用來屏蔽掉硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平台都能達到一致的記憶體訪問效果。使得Java程式設計師可以忽略不同處理器平台的不同記憶體模型,而只需要關心JMM即可。
JMM抽象結構
JMM 抽象結構圖
JMM借鑒了處理器記憶體模型的思想,從抽象的角度來看,JMM定義了執行緒和主記憶體之間的抽象關係,它涵蓋了快取,寫緩衝區,暫存器以及其他硬體和編譯器優化。下圖是JMM的抽象結構示意圖。
JMM中執行緒間通訊
並發編程中需要考慮的兩個核心問題:執行緒之間如何通訊(可見性和有序性)以及執行緒之間如何同步(原子性)。通訊是指執行緒之間以何種方式進行資訊交換;同步是指程式中用於控制不同執行緒間操作發生的相對順序
JMM規定了程式中所有的變數(實例欄位,靜態欄位,構成數組對象的元素等)都存儲在主記憶體中;它的主要目標是定義程式種各個變數的訪問規則,既從虛擬機將變數存儲到記憶體和從記憶體種取出變數這樣的底層細節。每個執行緒都有自己的本地記憶體,執行緒之間在JMM控制協議的限制下通過主記憶體進行通訊。假設由兩個執行緒A和B,執行緒A要給執行緒B發送"hello"消息,下圖是兩個執行緒進行通訊的過程:
由圖可見,假設執行緒A要發消息給執行緒B,那麼它必須經過兩個步驟:
- 執行緒A把本地記憶體中的共享變數副本message更新後刷新到主記憶體中
- 執行緒B到主記憶體取讀取執行緒A更新的共享變數message
JMM的設計與實現
JMM相關的協議比較複雜,我們可以從編譯器或者JVM工程師,以及Java工程師來進行學習。本文僅從Java工程師角度來進行探討Java中通過那些協議來控制JMM,從而保證數據一致性。
JMM的實現可以分為兩部分,包括happen-before規則以及一系列的關鍵字。它的核心目標就是確保編譯器,各平台的處理器都能提供一致的行為,在記憶體中表現出一致性的結果。具體來講就是通過happens-before規則以及volatile,synchronized,final關鍵字解決可見性,原子性以及有序性問題,從而保證記憶體中數據的一致性。
Happens-Before規則
happens-before是JMM中最核心的概念,happens-before用來指定兩個操作之間的執行順序,這兩個操作可以在一個執行緒內,也可以在不同的執行緒內,因此JMM通過happen-before關係向程式設計師提供跨執行緒的記憶體可見性保證,JMM的具體定義如下:
- 如果一個操作happens-before另一個操作,那麼第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。
- 兩個操作存在著happen-before關係,並不意味著Java平台具體實現必須要按照happen-before關係指定的順序來執行。如果重排序之後的執行結果,與按照happen-before關係來執行的結果一致,那麼這種重排序不非法(也就是說,JMM允許這種重排序)
下面的示例程式碼,假設執行緒 A 執行 writer() 方法,執行緒 B 執行 reader() 方法,如果執行緒 B 看到 「v == true」 時,那麼執行緒 B 看到的變數 x 是多少呢?
class VolatileExample { int x = 0; volatile boolean v = false; public void writer() { x = 42; // 1 v = true; // 2 } public void reader() { if (v == true) { // 3 // 這裡 x 會是多少呢? // 4 } } }
1. 程式順序性規則
程式順序規則(Program Order Rule): 一個執行緒內的每個操作,按照程式碼先後順序,書寫在前面的程式碼先行發生於與寫在後面的操作。
2. volatile變數規則
volatile變數規則(Volatile Variable Rule):對於一個volatile修飾得變數得寫操作先行發生於後面對這個變數得讀操作。「後面」指得是時間上的順序
3. 傳遞性規則
傳遞性規則(Transitivity): 如果操作A先行發生於操作B, 操作B先行發生於操作C,那麼A先行發生於操作C。
針對上述的1,2,3項happens-before我們作出個總結,下圖是我們根據volatile讀寫建立的happens-before關係圖。
4. 程鎖定規則
管程鎖定規則(Monitor Lock Rule): 一個unlock操作先行發生於後面對這個鎖得lock操作。「後面」指得是時間上的順序
在之前文章並發問題的源頭中並發問題中count++的問題提到了執行緒切換導致計數出現問題,在此我們就可以嘗試利用happens-before規則解決這個原子性問題。
public class SafeCounter { private long count = 0L; public long get() { return cout; } public synchronized void addOne() { count++; } }
上述程式碼真的解決可以解決問題嗎?
4. 執行緒啟動規則
執行緒啟動規則(Thread Start Rule): Thread對象的start()方法,先行發生於此執行緒的每一個動作。
6.執行緒終止規則
執行緒終止規則(Thread Termination Rule): 執行緒中的所有操作都先行發生於對於此執行緒的終止檢測,我們可以通過Thread.join()方法結束,Thread.isAlive()返回值等手段來檢測執行緒是否執行完畢。
7. 執行緒中斷規則
執行緒中斷規則(Thread Interruption Rule): 對執行緒的interrupt()方法的調用先行發生於被中斷執行緒程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
8. 對象終結規則
對象終結規則(Finalizer Rule): 一個對象的初始化完成(構造函數執行完畢)先行發生於它的finalize()方法。
happens-before規則一共可分為以上8條,筆者只針對在並發編程中常見的前6項進行了詳細介紹,具體內容可以參考http://gee.cs.oswego.edu/dl/jmm/cookbook.html。在JMM中,我認為這些規則也是比較難以理解的概念。總結下來happens-before規則強調的是一種可見性關係,事件A happens-before B,意味著A事件對於B事件是可見的,無論事件A和事件B是否發生在一個執行緒里。
volatile關鍵字
volatile自身特性
- 可見性:對一個volatile變數的讀,總能看到(任意執行緒)對這個volatile變數最後的寫入。
- 原子性: 對單個volatile變數的讀/寫具有原子性,注意,對於類似於vaolatile ++ 這種操作不具有原子性,因為這個操作是個符合操作。
volatile在JMM中表現出的記憶體語義
- 當寫一個變數時,JMM會把該執行緒對應的本地記憶體中的共享變數刷新到主記憶體。
- 當讀一個volatile變數時,JMM會把該執行緒對應的本地記憶體置為無效。接下來將從主記憶體中讀取共享變數。
volatile是java中提供用來解決可見性問題得關鍵字,可以理解為jvm看見volatile關鍵字修飾的變數時,會「禁用快取」既執行緒的本地記憶體,每次對此類型變數的讀操作時都會從主記憶體中重新讀取到本地記憶體中,每次寫操作也會立刻同步到主記憶體中,這也正進一步詮釋了volatile變數規則中描述的,對於一個volatile修飾得變數得寫操作先行發生於後面對這個變數得讀操作;被volatile修飾的共享變數,會被禁用某些類型的指令重排序,來保證順序性問題。
synchronized-萬能的鎖
由管程鎖定規則,一個unlock操作先行發生於後面對這個鎖的lock操作。在Java中通過管程(Monitor)來解決原子性問題,具體的表現為Synchronized關鍵字。被synchronized修飾的程式碼塊在編譯時會在開始位置和結束位置插入monitorenter和monitorexit指令,JVM保證monitorenter和monitorexit與之與之配對,並且這段程式碼得原子性。synchronized中的lock和unlock操作是隱式進行的,在java中我們不僅可以使用synchronized關鍵字,同樣可以使用各種實現了Lock介面的鎖來實現。
synchronized的記憶體語義
- 當執行緒獲取鎖時,會把執行緒本地記憶體置為無效
- 當執行緒釋放鎖時,會將共享變數刷新到主記憶體中
final-默默無聞的優化
在並發編程中的原子性,可見性以及順序性的問題導致的根本就是共享變數的改變。final關鍵字解決並發問題的方式是從源頭下手,讓變數不可變,變數被final修飾表示當前變數不會發生改變,編譯器可以放心進行優化。
總結
- JMM是用來屏蔽掉硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平台都能達到一致的記憶體訪問效果
- 站在稱序員角度來看JMM是一系列的協議(hanppens-before規則)和一些關鍵字,Synchronized,volatile和final
- volatile通過禁用快取和編譯優化保證了順序性和可見性
- synchronzed能保證程式執行的原子性,可見性和有序性,是並發中的萬能要是
- final關鍵字修飾的變數 不可變
Q&A
上文中嘗試用synchronized解決count++的問題,為了方便觀察將程式碼copy到此處,這段程式碼有沒有什麼不對勁呢?可以在留言區說出你的想法,我們一起來學習!
public class SafeCounter { private long count = 0L; public long get() { return cout; } public synchronized void addOne() { count++; } }