Java併發(3)- 聊聊Volatile

knock_小新發表於2018-07-25

引言

談到volatile關鍵字,大多數開發者都有一定了解,可以說是開發者非常熟悉,深入之後又非常陌生的一個關鍵字。相當於輕量的synchronized,也叫輕量級鎖,與synchronized相比效能上開銷較少,同時又具備了可見性、有序性以及部分原子性,是Java併發需中非常重要的一個關鍵字。這篇文章我們將從volatile底層原理上來深入剖析他是怎麼保證可見性、有序性以及部分原子性的,同時也會總結一些volatile關鍵字的典型應用場景。

volatile的“部分”原子性

所謂原子性,就是說一個操作是一個完整的整體,在其他執行緒看來這個操作要麼未開始,要麼已完成,不會看到中間的操作過程,跟事務有點相似。

那為什麼說volatile只具有“部分”原子性,因為從本質上來說volatile是不具備原子性的,他修飾的只是單個變數,大部分情況下單個變數的讀取和賦值本身就具有原子性,但有一個例外,就是32位Java虛擬機器下的long/double型變數操作。

在32位Java虛擬機器下,long/double型變數的讀寫操作會分為兩部分,先讀寫高32位,在讀寫低32位,或者相反,這樣如果沒有將變數宣告為volatile變數,在多執行緒讀寫時就有可能導致結果不可預知,因為對單個long/double型變數的讀寫並不是一個整體,也就是不具備原子性,只有使用volatile修飾之後,對單個long/double型變數的讀寫才具備了原子性的特點。在64位Java虛擬機器下,long/double型變數讀寫本身就具有原子性,如果只是為了簡單的讀寫就不需要使用volatile修飾。

需要明白的是volatile僅僅只保證變數的讀和寫是原子性操作,並不能保證對變數的複合操作也是原子性的,這是需要注意的地方,最為經典的場景就是對單個變數進行自增和自減。

private volatile static int increaseI = 0;

public static void main(String[] args) {
	for (int i = 0; i < 100000; i++) {
		Thread thread = new Thread(new Runnable() {
			
			@Override
			public void run() {
				
				increaseI++;
			}
		}, String.valueOf(i));
		thread.start();
	}
	
	while(Thread.activeCount()>1)  
		Thread.yield();
	System.out.println(increaseI);
}
複製程式碼

如果大家經過測試,會發現很多時候,列印出來的結果不是100000。這就是因為volatile修飾的變數只能保證變數的讀寫是原子性的,而increaseI++是一個複合操作,他可以簡單分為:

var = increaseI; //步驟1:將increaseI的值載入到暫存器var

var = var + 1;//步驟2:將暫存器var的值增加1

increaseI = var;//步驟3:將暫存器var的值寫入increaseI
複製程式碼

volatile只能保證第一步和第三部單個操作的原子性,並不能保證整個自增和自減過程的原子性,也就是說volatile修飾的increaseI++並不是原子操作。下圖也可以說明這個問題:

Java併發(3)- 聊聊Volatile

volatile的可見性

關於可見性,在前面的《Java併發(2)- 聊聊happens-before》一文中說過,為了提高操作效率,共享變數的讀寫都是線上程的本地記憶體中進行的,當對變數進行更新後,並不會及時將變數的結果重新整理回主記憶體,在多執行緒環境下,其他執行緒就不會及時讀取到最新的變數值。我們可以從下面的程式碼來分析這一點。

private static boolean flag = false;
	
private static void refershFlag() throws InterruptedException {
	
	Thread threadA = new Thread(new Runnable() {
		
		@Override
		public void run() {
			while (!flag) {
				//do something
			}
		}
	});
	
	Thread threadB = new Thread(new Runnable() {
		
		@Override
		public void run() {
			
			flag = true;
		}
	});
	
	DateFormat dateFormat  = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
	
	System.out.println("threadA start" + dateFormat.format(new java.util.Date()));
	threadA.start();
	
	Thread.sleep(100);
	
	threadB.start();
	
	threadA.join();
	System.out.println("threadA end" + dateFormat.format(new java.util.Date()));
}

//threadA start2018/07/25 16:48:41
複製程式碼

按正常邏輯來說B執行緒更新變數flag後,A執行緒應該馬上退出,但實際上很多時候B執行緒並不會立刻退出,這是因為虛擬機器考慮到共享變數沒有采用volatile修飾,預設該變數不需要多執行緒訪問,於是做了優化,導致flag共享變數沒有及時重新整理回主記憶體,同時其他執行緒也沒有及時去主記憶體讀取的結果。那我們給flag變數加上volatile標示會怎麼樣呢?

private volatile static boolean flag = false;

//threadA start2018/07/25 16:48:59
//threadA end2018/07/25 16:48:59
複製程式碼

可以看到A執行緒馬上退出了,從這點可以看出volatile的可見性。

volatile的有序性

JMM在happens-before規則的基礎上保證了單執行緒和正確同步多執行緒的有序性,其中就有一條volatile變數規則:對一個volatile變數的寫操作happen—before後面對該變數的讀操作。

這其中有兩點要注意:第一點,針對同一個volatile變數的寫、讀操作之間才有happens-before關係;第二點,有時間上的先後順序,必須是寫操作happen—before讀操作。在《Java併發(2)- 聊聊happens-before》重排序的例子中就很好的說明了volatile禁止重排序的特性。

public class AAndB {

	int x = 0;
	int y = 0;
	int a = 0;
	int b = 0;
	
	public void awrite() {

		a = 1;
		x = b;
	}
	
	public void bwrite() {

		b = 1;
		y = a;
	}
}

public class AThread extends Thread{

	private AAndB aAndB;
	
	public AThread(AAndB aAndB) {
		
		this.aAndB = aAndB;
	}
	
	@Override
	public void run() {
		super.run();
		
		this.aAndB.awrite();
	}
}

public class BThread extends Thread{

	private AAndB aAndB;
	
	public BThread(AAndB aAndB) {
		
		this.aAndB = aAndB;
	}
	
	@Override
	public void run() {
		super.run();
		
		this.aAndB.bwrite();
	}
}

private static void testReSort() throws InterruptedException {

	AAndB aAndB = new AAndB();

	for (int i = 0; i < 10000; i++) {
		AThread aThread = new AThread(aAndB);
		BThread bThread = new BThread(aAndB);

		aThread.start();
		bThread.start();

		aThread.join();
		bThread.join();

		if (aAndB.x == 0 && aAndB.y == 0) {
			System.out.println("resort");
		}

		aAndB.x = aAndB.y = aAndB.a = aAndB.b = 0;

	}

	System.out.println("end");
}
複製程式碼

當A執行緒和B執行緒都出現了重排序可能會列印出resort,但將變數都變為volatile變數後便不會再出現這種狀況。

volatile的兩個典型使用場景

1 用來標示狀態量。 狀態量標示就是通過一個boolean型別變數來判斷邏輯是否需要執行。就是上面volatile的可見性中的程式碼:

Thread threadA = new Thread(new Runnable() {
	
	@Override
	public void run() {
		while (!flag) {
			//do something
		}
	}
});

Thread threadB = new Thread(new Runnable() {
	
	@Override
	public void run() {
		
		flag = true;
	}
});
複製程式碼

如果使用synchronized或者鎖寫法上將會比較複雜,但如果用volatile來修飾變數就很好的解決了這個問題,保證了狀態量的及時重新整理回主記憶體同時其他執行緒也會強制更新。

2 double-check問題 double-check問題應該是volatile使用最多的場景了。如下程式碼所示:

public class DoubleCheck {

	private volatile static DoubleCheck instance = null;
	
	private DoubleCheck() {
		
	}
	
	public static DoubleCheck getInstance() {
		
		if (null == instance) {   //步驟一
			synchronized (DoubleCheck.class) {
				if (null == instance) {   //步驟二
					instance = new DoubleCheck();   //步驟三
				}
			}
		}
		return instance;
	}
	
	public static void main(String[] args) throws InterruptedException {

		DoubleCheck doubleCheck = DoubleCheck.getInstance();
	}
}
複製程式碼

程式碼中步驟三並不是原子性的,和之前的自增有點類似,可以分為三步:

3.1 為DoubleCheck分配記憶體地址 alloc memory address

3.2 初始化物件DoubleCheck init DoubleCheck

3.3 將引用地址指向instance instance > memory address

在CPU看來3.2和3.3並不存在依賴關係,是有可能會重排序的,如果將3.2和3.3重排序:

Java併發(3)- 聊聊Volatile

執行緒2在步驟一時判斷instance不為空的情況下,實際上物件並沒有初始化,3.2並沒有執行。導致接下來使用物件發生錯誤。此時使用volatile修飾instance變數就可以防止3.2和3.3重排序,這樣就保證了多執行緒訪問時程式碼的正確性。

我們可以檢視到彙編程式碼中在使用volatile關鍵字後在步驟三中多了lock指令來保證當前執行的有序性: 不使用volatile:

Java併發(3)- 聊聊Volatile

使用volatile

Java併發(3)- 聊聊Volatile

volatile背後的原理

在DoubleCheck的彙編程式碼中我們看到加了volatile關鍵字後彙編程式碼中多了一行lock指令,那麼這個指令代表什麼意思呢?

lock指令有兩個功能:

  1. 對CPU匯流排和快取記憶體加鎖,加鎖之後執行後面的指令,然後釋放鎖時將快取記憶體中的資料重新整理回主記憶體。
  2. lock會讓其他CPU快取記憶體中的快取行失效,其他CPU讀取時必須要從主記憶體載入最新資料。

簡單來說就是lock指令可以實現快取一致性。通過lock指令的這兩個功能,我們就可以很簡單的理解當共享變數flag用volatile修飾後,每次更新flag的值都會導致快取行的資料強制重新整理最新值到主記憶體,volatile變數之前的資料也會被重新整理回主記憶體。同時其他執行緒必須到主記憶體讀取最新flag的值。這樣就實現了共享變數的可見性以及有序性。


參考資料:
  1. 《深入理解Java虛擬機器》
  2. 《Java併發程式設計的藝術》

相關文章