計算機系統的一致性
在現代計算機作業系統中,多工處理幾乎是一項必備的功能,因為嵌入了多核處理器,計算機系統真正做到了同一時間執行若干個任務,是名副其實的多核系統。在多核系統中,為了提升CPU與記憶體的互動效率,一般都設定了一層 “快取記憶體區” 作為記憶體與處理器之間的緩衝,使得CPU在運算的過程中直接從快取記憶體區讀取資料,一定程度上解決了效能的問題。但是,這樣也帶來了一個新問題,就是“快取一致性”的問題。比如,多核的情況下,每個處理器都有自己的快取區,資料如何保持一致性。針對這個問題,現代的計算機系統引入多處理器的資料一致性的協議,包括MOSI、Synapse、Firely、DragonProtocol等。
當處理器通過快取記憶體區與主記憶體發生互動時,對資料的讀寫必須遵循協議規定的標準,用一張關係圖表示的話大概如下:
而Java的記憶體模型 (JMM) 可以說與硬體的一致性模型很相似,採用的是共享記憶體的執行緒通訊機制。
Java記憶體模型
Java記憶體模型規定了所有的變數都儲存在主記憶體中,每個執行緒擁有自己的工作記憶體,工作記憶體中儲存了被該執行緒使用的變數的主記憶體副本拷貝,執行緒只能操作自己工作記憶體的變數副本,操作完變數後會更新到主記憶體,通過主記憶體來完成與其他執行緒間變數值的傳遞。此模型的互動關係如下圖所示:
然而,Java的記憶體模型只是反映了虛擬機器內部的執行緒處理機制,並不保證程式本身的併發安全性。
舉一個例子,在程式中對一個共享變數做自增操作:
i++;
假設初始化的時候i=0,當跑到此程式時,執行緒首先從主記憶體讀取i的值,然後複製到自己的工作記憶體,進行i++操作,最後將操作後的結果從工作記憶體複製到主記憶體中。如果是兩個執行緒執行i++的程式,預期的結果是2。但真的是這樣嗎?答案是否定的。
假設執行緒1讀取主記憶體的i=0,複製到自己的工作記憶體,在進行i++的操作後還沒來得及更新到主記憶體,這時執行緒2也讀取i=0,做了同樣的操作,那麼最終得到的結果為1,而不是2。
這是典型的關於多執行緒併發安全例子,也是Java併發程式設計中最值得探討的話題之一,一般來說,處理這種問題有兩種手段:
- 加鎖,比如同步程式碼塊的方式。保證同一時間只能有一個執行緒能執行i++這條程式。
- 利用執行緒間的通訊,比如使用物件的wait和notify方法來。
因為本文主要是探究 JMM 和 volatile 關鍵字的知識,具體怎麼實現併發處理就不做深入探討了,改天看看抽個時間再寫篇博文專門講解好了。
記憶體模型的3個重要特徵
初步瞭解完什麼是JMM後,我們來進一步瞭解它的重要特徵。值得說明的是,在Java多執行緒開發中,遵循著三個基本特性,分別是原子性、可見性和有序性,而Java的記憶體模型正是圍繞著在併發過程中如何處理這三個特徵建立的。
原子性
原子性是指操作是原子性的,不可中斷的。舉個例子:
String s="abc";
這個操作是直接賦值,是原子性操作。而類似下面這段程式碼就不是原子性了:
i++;
當執行i++時,需要先獲取i的值,然後再執行i+1,相當於包含了兩個操作,所以不是原子性。
可見性
可見性是指共享資料的時候,一個執行緒修改了資料,其他執行緒知道資料被修改,會重新讀取最新的主存的資料。就像前面說的兩個執行緒處理i++的問題,執行緒1改完後沒有更新到主記憶體,所以執行緒2是不知道的。
有序性
是指程式碼執行的有序性,對於一個執行緒執行的程式碼,我們可以認為程式碼是依次執行的,但併發中可能就會出現亂序,因為程式碼有可能發生指令重排序(Instruction Reorder),重排後的指令與原指令的順序未必一致。
指令重排序
編譯器能夠自由的以優化的名義去改變指令順序。在特定的環境下,處理器可能會次序顛倒的執行指令。是為指令的重排序,尤其是併發的情況下。
java提供了volatile和synchronized來保證執行緒之間操作的有序性。volatile含有禁止指令重排序的語義(即它的第二個語義),synchronized規定一個變數在同一時刻只允許一條執行緒對其lock操作,也就是說同一個鎖的兩個同步塊只能序列進入。禁止了指令的重排序。
volatile關鍵字
說到了volatile,我們就有必要了解一下這個關鍵字是做什麼的。
準確來說,volatile是java提供的輕量的同步機制。它有兩個特性:
- 保證修飾的變數對所有執行緒的可見性。
- 禁止指令的重排序優化。
保證可見性和防止指令重排
簡單寫段程式碼說明一下:
public class VolatileDemo {
private static boolean isReady;
private static int number;
private static class ReaderThread extends Thread{
@Override
public void run() {
while (!isReady);
System.out.println("number = "+number);
}
}
public static void main(String[] args) {
new ReaderThread().start();
try {
Thread.sleep(1000);
number = 42;
isReady = true;
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
在上面的程式碼中,ReaderThread
只有在isReady
為 true 時才會列印出 number
的值,然而,真實的情況有可能是列印不出來(可能性比較小,但還是有),因為執行緒ReaderThread執行緒無法看到主執行緒中對isReady
的修改,導致while迴圈永遠無法退出,同時,因為有可能發生指令重排,導致下面的程式碼不能按順序執行:
number = 42;
isReady = true;
也就是能列印的話,number值可能是0,不是42。如果在變數加上volatile關鍵字,告訴Java虛擬機器這兩個變數可能會被不同的執行緒修改,那麼就可以防止上述兩種不正常的情況的發生。
不能保證原子性
volatile能保證可見性和有序性,但無法保證原子性,比如下面的例子:
public class VolatileDemo {
public static volatile int i = 0;
public static void increase() {
i++;
}
public static void main(String[] args) throws InterruptedException {
VolatileDemo test = new VolatileDemo();
for (int i = 0; i < 10; i++) {
new Thread(() -> {
for (int j = 0; j < 1000; j++)
test.increase();
}).start();
}
Thread.sleep(1000);
System.out.println(test.i);
}
}
正常情況下,我們期望上面的main函式執行完後輸出的結果是10000,但你會發現,結果總是會小於10000,因為increase()方法中的i++
操作不是原子性的,分成了讀和寫兩個操作。假設當執行緒1讀取了 i 的值,還沒有修改,執行緒2這時也進行了讀取。然後,執行緒1修改完了,通知執行緒2重新讀取 i 的值,可這時它不需要讀取 i,它仍執行寫操作,然後賦值給主執行緒,這時資料就會出現問題。
所以,一般針對共享變數的讀寫操作,還是需要用鎖來保證結果,例如加上 synchronized關鍵字。
參考:
《Java高併發程式設計》
《深入理解Java虛擬機器》