在Java併發編程中,synchronized和volatile 都扮演着重要的角色,volatile是輕量級的synchronzied,其在多處理器開發時保證了共享變量的"可見性".
問題引入:多個CPU的不可見性造成髒讀
我們知道CPU速度非常快,比內存快百倍以上,所以CPU更希望和速度相近的CPU cache打交道。
而一個多核的CPU本質上就是多個CPU共用一個外殼,每個核就是一個單核CPU,其都有屬於自己的cache。
當更改一個變量時,CPU將值寫入到對應的cache中,不一定寫入內存中,這個CPU認爲值已經改變了,但是其他CPU認爲值沒有改變。這樣就會出現髒讀。
解決方式:volatile 指令實現可見性
volatile 底層是使用Lock指令。
CPU讀取到lock指令後,會做以下操作。
1. 將對應的值先到CPU對應的cache中
2. 將對應的值寫入到內存中(就是內存條,我們稱其爲系統內存)
3. 第二部的寫操作會使在其他CPU裏緩存了該地址的數據無效。
4. 其他CPU當需要這個數值時,必須去重新讀取內存。
這樣就保證了多CPU之間的可見性。
問題引入:指令重排序造成線程安全問題
JVM會在不影響單線程運行結果的準則下,對代碼進行重排序。舉個例子,
public void writer() {
a = 1; //1
flag = true; //2
}
雖然你寫的代碼是按照,"1","2"排序,但是在JVM編譯後,可能順序就是"2","1"等.
JVM爲什麼這樣做?
public void writer() {
int i = 0;
a = 1; //1
while (i++ != 2){
flag = true; //2
a++;
}
}
很明顯,循環每次都會調用flag = true,完全不必要,可以將2插入到1前後.可以增加運行效率。
爲什麼會有線程安全問題?
簡單來說,代碼在多線程下本來就難以掌控,重排序往往會造成一些意想不到的問題。
因爲我們寫出來的多線程代碼可能會依賴於特定的代碼順序,如果更改,那麼會導致線程安全問題。
經典案例:基於雙重檢查的單例模式
單例模式就是保證一個類始終只有一個對象的一種編碼方法。我們可以很容易的寫出來一個單線程下的單例餓漢模式
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
// 可能同時有多個線程進入if中
instance = new Singleton();
}
return instance;
}
}
在多線程時,可以會new出多個實例.
我們可以通過synchronized關鍵子鎖住這個方法,保證其線程安全。
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
synchronized是把這個方法鎖成一個單線程方法,效率降低十分明顯。
我們通過volatile進行優化,將其synchronized鎖的粒度減小。
// 3 所在行其實有三句話
- 分配內存空間
- 將對象指向剛分配的內存空間
- 初始化對象
JVM會對代碼進行重排序(爲了優化速度)
Time | Thread A | Thread B |
---|---|---|
T1 | 檢查到uniqueSingleton 爲空 |
|
T2 | 獲取鎖 | |
T3 | 再次檢查到uniqueSingleton 爲空 |
|
T4 | 爲uniqueSingleton 分配內存空間 |
|
T5 | 將uniqueSingleton 指向內存空間 |
|
T6 | 檢查到uniqueSingleton 不爲空 |
|
T7 | 訪問uniqueSingleton (此時對象還未完成初始化) |
|
T8 | 初始化uniqueSingleton |
所以需要使用volatile關鍵字,防止指令重排序導致的線程安全問題。