內存模型
在計算機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++;
}
}