1. 三大性質簡介
在併發程式設計中分析執行緒安全的問題時往往需要切入點,那就是兩大核心:JMM抽象記憶體模型以及happens-before規則(在這篇文章中已經經過了),三條性質:原子性,有序性和可見性。關於synchronized和volatile已經討論過了,就想著將併發程式設計中這兩大神器在 原子性,有序性和可見性上做一個比較,當然這也是面試中的高頻考點,值得注意。
2. 原子性
原子性是指一個操作是不可中斷的,要麼全部執行成功要麼全部執行失敗,有著“同生共死”的感覺。及時在多個執行緒一起執行的時候,一個操作一旦開始,就不會被其他執行緒所干擾。我們先來看看哪些是原子操作,哪些不是原子操作,有一個直觀的印象:
int a = 10; //1
a++; //2
int b=a; //3
a = a+1; //4
上面這四個語句中只有第1個語句是原子操作,將10賦值給執行緒工作記憶體的變數a,而語句2(a++),實際上包含了三個操作:1. 讀取變數a的值;2:對a進行加一的操作;3.將計算後的值再賦值給變數a,而這三個操作無法構成原子操作。對語句3,4的分析同理可得這兩條語句不具備原子性。當然,java記憶體模型中定義了8中操作都是原子的,不可再分的。
- lock(鎖定):作用於主記憶體中的變數,它把一個變數標識為一個執行緒獨佔的狀態;
- unlock(解鎖):作用於主記憶體中的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定
- read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便後面的load動作使用;
- load(載入):作用於工作記憶體中的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體中的變數副本
- use(使用):作用於工作記憶體中的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作;
- assign(賦值):作用於工作記憶體中的變數,它把一個從執行引擎接收到的值賦給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作;
- store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送給主記憶體中以便隨後的write操作使用;
- write(操作):作用於主記憶體的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。
上面的這些指令操作是相當底層的,可以作為擴充套件知識面掌握下。那麼如何理解這些指令了?比如,把一個變數從主記憶體中複製到工作記憶體中就需要執行read,load操作,將工作記憶體同步到主記憶體中就需要執行store,write操作。注意的是:java記憶體模型只是要求上述兩個操作是順序執行的並不是連續執行的。也就是說read和load之間可以插入其他指令,store和writer可以插入其他指令。比如對主記憶體中的a,b進行訪問就可以出現這樣的操作順序:read a,read b, load b,load a。
由原子性變數操作read,load,use,assign,store,write,可以大致認為基本資料型別的訪問讀寫具備原子性(例外就是long和double的非原子性協定)
synchronized
上面一共有八條原子操作,其中六條可以滿足基本資料型別的訪問讀寫具備原子性,還剩下lock和unlock兩條原子操作。如果我們需要更大範圍的原子性操作就可以使用lock和unlock原子操作。儘管jvm沒有把lock和unlock開放給我們使用,但jvm以更高層次的指令monitorenter和monitorexit指令開放給我們使用,反應到java程式碼中就是---synchronized關鍵字,也就是說synchronized滿足原子性。
volatile 我們先來看這樣一個例子:
public class VolatileExample {
private static volatile int counter = 0;
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++)
counter++;
}
});
thread.start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter);
}
}
複製程式碼
開啟10個執行緒,每個執行緒都自加10000次,如果不出現執行緒安全的問題最終的結果應該就是:10*10000 = 100000;可是執行多次都是小於100000的結果,問題在於 volatile並不能保證原子性,在前面說過counter++這並不是一個原子操作,包含了三個步驟:1.讀取變數counter的值;2.對counter加一;3.將新值賦值給變數counter。如果執行緒A讀取counter到工作記憶體後,其他執行緒對這個值已經做了自增操作後,那麼執行緒A的這個值自然而然就是一個過期的值,因此,總結果必然會是小於100000的。
如果讓volatile保證原子性,必須符合以下兩條規則:
- 運算結果並不依賴於變數的當前值,或者能夠確保只有一個執行緒修改變數的值;
- 變數不需要與其他的狀態變數共同參與不變約束
3. 有序性
synchronized
synchronized語義表示鎖在同一時刻只能由一個執行緒進行獲取,當鎖被佔用後,其他執行緒只能等待。因此,synchronized語義就要求執行緒在訪問讀寫共享變數時只能“序列”執行,因此synchronized具有有序性。
volatile
在java記憶體模型中說過,為了效能優化,編譯器和處理器會進行指令重排序;也就是說java程式天然的有序性可以總結為:如果在本執行緒內觀察,所有的操作都是有序的;如果在一個執行緒觀察另一個執行緒,所有的操作都是無序的。在單例模式的實現上有一種雙重檢驗鎖定的方式(Double-checked Locking)。程式碼如下:
public class Singleton {
private Singleton() { }
private volatile static Singleton instance;
public Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
return instance;
}
}
複製程式碼
這裡為什麼要加volatile了?我們先來分析一下不加volatile的情況,有問題的語句是這條:
instance = new Singleton();
這條語句實際上包含了三個操作:1.分配物件的記憶體空間;2.初始化物件;3.設定instance指向剛分配的記憶體地址。但由於存在重排序的問題,可能有以下的執行順序:
如果2和3進行了重排序的話,執行緒B進行判斷if(instance==null)時就會為true,而實際上這個instance並沒有初始化成功,顯而易見對執行緒B來說之後的操作就會是錯得。而用volatile修飾的話就可以禁止2和3操作重排序,從而避免這種情況。volatile包含禁止指令重排序的語義,其具有有序性。
4. 可見性
可見性是指當一個執行緒修改了共享變數後,其他執行緒能夠立即得知這個修改。通過之前對synchronzed記憶體語義進行了分析,當執行緒獲取鎖時會從主記憶體中獲取共享變數的最新值,釋放鎖的時候會將共享變數同步到主記憶體中。從而,synchronized具有可見性。同樣的在volatile分析中,會通過在指令中新增lock指令,以實現記憶體可見性。因此, volatile具有可見性
5. 總結
通過這篇文章,主要是比較了synchronized和volatile在三條性質:原子性,可見性,以及有序性的情況,歸納如下:
synchronized: 具有原子性,有序性和可見性; volatile:具有有序性和可見性
參考文獻
《java併發程式設計的藝術》
《深入理解java虛擬機器》