一、基本概念
1.1 記憶體模型
在程式的執行過程中,涉及到兩個方面:指令的執行和資料的讀寫。其中指令的執行通過處理器來完成,而資料的讀寫則要依賴於系統記憶體,但是處理器的執行速度要遠大於記憶體資料的讀寫,因此在處理器中加入了快取記憶體。在程式的執行過程中,會 先將資料拷貝到處理器的快取記憶體中,待運算結束後再回寫到系統記憶體當中。
在單執行緒的情況下不會有什麼問題,但是如果在多執行緒情況下就可能會出現異常的情況,以下面這段程式碼為例,i
是放在堆記憶體的共享變數:
i = i + 1; //i 的初始值為0。
複製程式碼
假如執行緒A
和執行緒B
都執行這段程式碼,那麼就可能出現下面兩種情況:
- 第一種情況:執行緒
A
先執行+1
操作,然後將i
的值寫回到系統記憶體中;執行緒B
從系統記憶體中拷貝i
的值1
到快取記憶體中,執行完+1
操作再回寫到系統記憶體中,最終的結果是i=2
。 - 第二種情況:執行緒
A
和執行緒B
首先都將i
的值0
拷貝到各自處理器的快取記憶體當中,執行緒A
首先執行+1
操作,之後i
的值為1
,然後寫回到系統記憶體中;但是對於執行緒B
而言,它並不知道這一過程,在執行該執行緒的處理器的快取記憶體中i
的值仍然為0
,因此在它執行+1
操作後,再將i
的值寫回到系統記憶體中,最終的結果是i=1
。
這種不確定性就稱為 快取不一致。
1.2 併發程式設計中的三個概念
在併發程式設計中,有三個關鍵的概念:可見性、原子性和有序性,只有保證了這三點才能使得程式在多執行緒情況下獲得預期的執行結果。
1.2.1 可見性
可見性:是指執行緒之間的可見性,一個執行緒修改的狀態對另一個執行緒是可見的。也就是一個執行緒修改的結果,另一個執行緒馬上就能看到。在1.1
所舉的例子就存在可見性的問題。
在Java
中volatile
、synchronized
和final
實現可見性。
1.2.2 原子性
原子性:即一個操作或者多個操作,要麼全部執行並且執行的過程不會被任何因素打斷,要麼就都不執行。
再比如a++
,這個操作實際是a=a+1
,是可分割的,所以它不是一個原子操作。非原子操作都會存線上程安全問題,需要我們使用同步技術來讓它變成一個原子操作。一個操作是原子操作,那麼我們稱它具有原子性。
在Java
中synchronized
和在lock
、unlock
中操作或者原子操作類來保證原子性。
1.2.3 有序性
有序性:即程式執行的順序按照程式碼的先後順序執行。以下面的程式碼為例:
int i = 0;
boolean flag = false;
i = 1; //語句1
flag = true; //語句2
複製程式碼
在上面的程式碼中定義了一個整形和Boolean
型變數,並通過語句1
和語句2
對這兩個變數賦值,但是JVM
在執行這段程式碼的時候並不保證語句1
在語句2
之前執行,也就是說可能會發生 指令重排序。
指令重排序指的是在 保證程式最終執行結果和程式碼順序執行的結果一致的前提 下,改變語句執行的順序來優化輸入程式碼,提高程式執行效率。
但是這一前提條件在多執行緒的情況下就有可能出現問題,以下面的程式碼為例:
//執行緒1:
context = loadContext(); //語句1
inited = true; //語句2
//執行緒2:
while (!inited) {
sleep()
}
doSomethingWithConfig(context);
複製程式碼
對於執行緒1
來說,語句1
和語句2
沒有依賴關係,因此有可能會發生指令重排序的情況。但是對於執行緒2
來說,語句2
在語句1
之前執行,那麼就會導致進入doSomethingWithConfig
函式的時候context
沒有初始化。
Java
語言提供了volatile
和synchronized
兩個關鍵字來保證執行緒之間操作的有序性,volatile
是因為其 本身包含禁止指令重排序 的語義,synchronized
是由 一個變數在同一個時刻只允許一條執行緒對其進行 lock 操作 這條規則獲得的,此規則決定了持有同一個物件鎖的兩個同步塊只能序列執行。
二、volatile 詳解
2.1 定義
volatile
的定義如下:Java
程式語言允許執行緒訪問共享變數,為了確保共享變數能被準確和一致地更新,執行緒應該確保 通過排它鎖單獨地獲得這個變數。如果一個欄位被宣告成volatile
,Java
執行緒記憶體模型確保 所有執行緒看到這個變數的值是一致的。
一旦一個共享變數被volatile
修飾之後,那麼就具備了兩層語義:
- 保證了不同執行緒對這個變數進行操作時的可見性,即一個執行緒修改了某個變數的值,這新值對其他執行緒來說是立即可見的。
- 禁止進行指令重排序。
下面,我們用兩個小結解釋一下這兩層語義。
2.2 保證可見性
當我們在X86
處理器下通過工具獲取JIT
編譯器生成的彙編指令,來檢視對volatile
進行寫操作時,會發生下面的事情:
//Java 程式碼
instance = new Singleton(); //instance 是 volatile 變數
//轉變成彙編程式碼
0x01a3de1d: move $0 x 0, 0 x 1104800 (%esi);
0x01a3de24: lock add1 $ 0 x 0, (%esp);
複製程式碼
有volatile
變數修飾的共享變數 進行寫操作的時候 會多出兩行彙編程式碼,Lock
字首的指令在多核處理器下引發了兩件事情:
- 將當前處理器 內部快取 的資料寫回到 系統記憶體。
- 這個寫回記憶體的操作會使在其他處理器裡 快取了該記憶體地址的資料無效,當這些處理器對這個資料進行修改操作的時候,會重新從系統記憶體中把資料讀到處理器快取裡。
2.3 禁止指令重排序
volatile
關鍵字禁止指令重排序有兩層意思:
- 當程式執行到
volatile
變數的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行; - 在進行指令優化時,不能將在對
volatile
變數訪問的語句放在其後面執行,也不能把volatile
變數後面的語句放到其前面執行。
以下面的例子為例:
//flag 為 volatile 變數
x = 2; //語句1
y = 0; //語句2
flag = true; //語句3
x = 4; //語句4
y = -1; //語句5
複製程式碼
由於flag
為volatile
變數,因此,可以保證語句1/2
在語句3
之前執行,語句4/5
在其之後執行,但是並不保證語句1/2
之間或者語句4/5
之間的順序。
對於1.2.3
舉的有關Context
問題,我們就可以通過將inited
變數宣告為volatile
,這樣就會保證loadContext()
和inited
賦值語句之間的順序不被改變,避免出現inited=true
但是Context
沒有初始化的情況出現。
2.4 效能問題
volatile
相對於synchronized
的優勢主要原因是兩點:簡易和效能。如果從讀寫兩方便來考慮:
volatile
讀操作開銷非常低,幾乎和非volatile
讀操作一樣volatile
寫操作的開銷要比非volatile
寫操作多很多,因為要保證可見性需要實現 記憶體界定,即便如此,volatile
的總開銷仍然要比鎖獲取低。volatile
操作不會像鎖一樣 造成阻塞。
以上兩個條件表明,可以被寫入volatile
變數的這些有效值 獨立於任何程式的狀態,包括變數的當前狀態。大多數的程式設計情形都會與這兩個條件的其中之一衝突,使得volatile
不能像synchronized
那樣普遍適用於實現執行緒安全。
因此,在能夠安全使用volatile
的情況下,volatile
可以提供一些優於鎖的可伸縮特性。如果讀操作的次數要遠遠超過寫操作,與鎖相比,volatile
變數通常能夠減少同步的效能開銷。
2.5 應用場景
要使volatile
變數提供理想的執行緒安全,必須同時滿足以下兩個條件:
- 對變數的 寫操作不依賴於當前值。例如
x++
這樣的增量操作,它實際上是一個由讀取、修改、寫入操作序列組成的組合操作,必須以原子方式執行,而volatile
不能提供必須的原子特性。 - 該變數 沒有包含在其它變數的不變式中。
避免濫用volatile
最重要的準則就是:只有在 狀態真正獨立於程式內其它內容時 才能使用volatile
,下面,我們總結一些volatile
的應用場景。
2.5.1 狀態標誌
用volatile
來修飾一個Boolean
狀態標誌,用於指示發生了某一次的重要事件,例如完成初始化或者請求停機。
volatile boolean shutdownRequested;
...
public void shutdown() { shutdownRequested = true; }
public void doWork() {
while (!shutdownRequested) {
// do stuff
}
}
複製程式碼
2.5.2 一次性安全釋出
在解釋 一次性安全釋出 的含義之前,讓我們先來看一下 單例寫法 當中著名的 雙重檢查鎖定問題。
//使用 volatile 修飾。
private volatile static Singleton sInstance;
public static Singleton getInstance() {
if (sInstance == null) { //(0)
synchronized (Singleton.class) { //(1)
if (sInstance == null) { //(2)
sInstance = new Singleton(); //(3)
}
}
}
return sInstance;
}
複製程式碼
假如 沒有使用volatile
來修飾sInstance
變數,那麼有可能會發生下面的場景:
- 第一步:
Thread1
進入getInstance()
方法,由於sInstance
為空,Thread1
進入synchronized
程式碼塊。 - 第二步:
Thread1
前進到(3)
處,在建構函式執行之前使sInstance
物件成為非空,並設定sInstance
指向的記憶體空間。 - 第三步:
Thread2
執行,它在入口(0)
處檢查例項是否為空,由於sInstance
物件不為空,Thread2
將sInstance
引用返回,此時sInstance
物件並沒有初始化完成。 - 第四步:
Thread1
通過執行Singleton
物件的建構函式並將引用返回給它,來完成對該物件的初始化。
通過volatile
就可以禁止第二步和第四步的重排序,也就是使得 初始化物件在設定 sInstance 指向的記憶體空間之前完成。
2.5.3 volatile bean 模式
volatile bean
模式適用於將JavaBeans
作為“榮譽結構”使用的框架。在volatile bean
模式中,JavaBean
被用作一組具有getter
和/或setter
方法的獨立屬性的容器。
volatile bean
模式的基本原理是:很多框架為易變資料的持有者提供了容器,但是放入這些容器中的物件必須是執行緒安全的。
在volatile bean
模式中,JavaBean
的所有資料成員都是volatile
型別的,並且 getter
和setter
方法必須非常普通,除了獲取或設定相應的屬性外,不能包含任何邏輯。此外,對於物件引用的資料成員,引用的物件必須是有效不可變的。
public class Person {
private volatile String firstName;
private volatile String lastName;
private volatile int age;
public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }
public int getAge() { return age; }
public void setFirstName(String firstName) {
this.firstName = firstName;
}
public void setLastName(String lastName) {
this.lastName = lastName;
}
public void setAge(int age) {
this.age = age;
}
}
複製程式碼
2.5.4 開銷較低的讀/寫鎖策略
如果讀操作遠遠超過寫操作,您可以結合使用內部鎖和volatile
變數來減少公共程式碼路徑的開銷。下面的程式碼中使用synchronized
確保增量操作是原子的,並使用volatile
保證當前結果的可見性。如果更新不頻繁的話,該方法可實現更好的效能,因為讀路徑的開銷僅僅涉及volatile
讀操作,這通常要優於一個無競爭的鎖獲取的開銷。
public class CheesyCounter {
private volatile int value;
public int getValue() { return value; }
public synchronized int increment() {
return value++;
}
}
複製程式碼
三、參考文獻
(1) Java 併發程式設計:volatile 關鍵字解析
(2) Java 中 volatile 關鍵字詳解
(3) 正確使用 volatile 變數
(4) volatile 的使用