1. 程式人生 > >volatile關鍵字詳解(從快取一致性談起)

volatile關鍵字詳解(從快取一致性談起)

在講解volatile關鍵字之前,我們先來看看作業系統中快取一致性的概念。

眾所周知,cpu的執行速度是遠高於主存的讀寫速度的,在執行過程中,為了交換資料,cpu必須頻繁的進行資料的讀寫操作,主存讀寫速度慢造成了cpu執行的吞吐量減少。為了解決這一問題,現在的機器都會在新增一層快取記憶體(其實不止一層,有多層).以後每次cpu進行讀寫操作時,都直接和快取記憶體互動,之後在將快取記憶體中的資料回刷到主存中。在單執行緒情況下這沒有任何問題,但在多執行緒環境下這會帶來一些隱患。因為這會造成每個執行緒更改了自己快取記憶體中的資料時(即使將這個更改的資料從快取中刷回主存),其他變數讀取的仍是最開始的自己快取記憶體中的資料,這就造成了資料的不可見性。為了預防資料不可見性,在硬體方面有一個快取一致性協議協議。其中最出名的MESI協議。

快取一致性協議(MESI):

當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時, 發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀。

在jvm中,所有變數都存在主存當中,每個執行緒都有自己的工作記憶體(也就是前面說的高速cache)。也就是說,當訪問一個共享變數時,多個java執行緒在自己的工作記憶體中都會有這個共享變數的一份副本。當某個執行緒更改了自己工作記憶體中更新了資料時,此時這個執行緒阻塞了或者其他原因,沒有及時將這個更新的資料刷回主存,那麼其他執行緒再從主存或自己的工作快取中讀取的資料還是原來舊值,也就是說,該資料的新值對其他執行緒來說是不可見的。為了防止共享變變數的不可見性,java提供了volatile關鍵字來保證。

當一個變數被volatile修飾時,其實就具備一下兩層含義:
     1.當某個執行緒對該變數進行修改後,會立即將修改後的新值刷回主存,保證主存中永遠都是最新的資料
     2.對該比變數施加了快取行一致協議。也就是說,當前執行緒對該變數進行修改後,系統會通知其他執行緒它們工作快取中資料已經無效,那麼其他執行緒要再次讀取該變數時,就
        會重新從主存中讀取該變數,然後複製一份在它的工作快取中。

首先我們來看一個沒有被volatile修飾的變數的多執行緒例子:

package thread.volatile_learn;

public class no_volatile {
	private boolean flag = false;  //標誌某個資源是否空閒
	public void doSomethind(){
		while(!flag){
			System.out.println("the resource is free ,let us do something");
		}
		if(flag){
			System.out.println("the resource is busy ,let us stop!");
		}
	}
	public static void main(String[] args) throws Exception {
		final no_volatile sharedObject = new no_volatile();
		
		new Thread(){
			public void run() {
				sharedObject.doSomethind();
			};
		}.start();
		
		Thread.sleep(3000);
		new Thread(){
			public void run() {
				sharedObject.flag=true;
			};
		}.start();
	}
}

這是一段用來講解併發程式設計中的經典程式碼,第一個執行緒(A執行緒)在執行開始執行,由於falg=false,因此執行緒中的while迴圈會一直進行,A執行一段時間後,第二個執行緒(B執行緒)開始執行,並將flag設定為true,這時A執行緒會繼續執行迴圈體,還是顯示"thre resource is busy , let us stop"呢。大部分情況下執行緒A會立即結束,因為前面我們說過,現在的jvm其實已經實現了快取一致性協議,也就是說當執行緒B修改了flag共享變數後,系統會盡可能將執行緒工作記憶體中的變量回刷到主存中。但這只是儘量,如果此時這個執行緒轉去做其他事情,還沒來得及講工作記憶體中的變量回刷到主存,那麼雖然此時執行緒A中的工作記憶體的flag快取已經失效,重新從主存讀取的,其實還是原來的那個值。這就造成了執行緒A會一直執行迴圈體。(其實這段程式碼進過我多次測試,想過在B執行緒設定flag之後,讓他阻塞,但最終結果還是A執行緒會退出,並不會一直執行迴圈體。這說明在我測試的這些例子中,其實每次B執行緒都將修改的值從工作記憶體中及時的回刷到主存中了。從這點也可以看出,現在的JVM其實已經對資料可見性問題本身提供了很好的支援)。

如果我們使用volatile來修飾flag,那麼每次進行修改後,執行緒都會立即將工作記憶體中修改了的變量回刷到主存中,這樣,其他執行緒讀取到的永遠是最新的值。這就保證了資料的可見性。

但保證了資料的可見性,volatile可以保證對被修飾的資料的操作是原子性的嗎?我們先來看下面這一段程式碼:

public class volatile_learn {
	private volatile int inc = 0;  //volatile保證修改一個共享變數時,立即將更改後的共享變數從工作快取刷回主存。
	public void increase(){
		this.inc++;
	}
	public static void main(String[] args) {
		final volatile_learn sharedObject = new volatile_learn();
		for(int i=0;i<10;i++){
			new Thread(){
				public void run() {
					for(int j=0;j<100;j++){
						sharedObject.increase();
					}
				};
			}.start();
			
		}
		while(Thread.activeCount()>1){
			Thread.yield();
		}
		System.out.println(sharedObject.inc);
	}
}
按照之前我們說的,每次修改volatile修飾的變數後,其他執行緒都能‘看見’本次修改,那麼所有的執行緒讀取到的值就是最新值,那麼執行這段程式碼,結果應該是1000. 然而答案並非如此,在我測試的10次中,只有兩次是1000,其他都是800~1000之間。其實這是因為自增操作的非原子性有關。其實,自增操作看起來很簡單一步,其實一共執行了三步:

1.讀取變數的初始值(如果是第一次,還要將該變數copy一份然後放入工作記憶體作為高速cache使用)

2.cpu進行加1操作

3.cpu將修改後的值寫入工作記憶體。

假如現線上程A讀取了inc(假設此時值為10)的值,此時執行緒A阻塞,執行緒B開始搶佔cpu資源,繼續讀取主存中Inc的值,並copy一份放入自己的工作記憶體中,然後進行加1操作,寫入工作記憶體後立即(如果不用volatile,很難保證系統什麼時候會回刷主存)回刷到主存中(此時volatile值為11)。此時A執行緒重新進入可執行狀態並獲得cpu資源開始執行,由於B執行緒已經修改了Inc的值,所以此時A執行緒的工作記憶體中的快取已經失效,但由於A執行緒在阻塞前已經執行了inc的讀取操作,所以線A繼續執行inc+1操作,此時執行完自增操作,寫入工作記憶體Inc的值是11,最後回刷到主存中。因此此時主存中的最終值是11,而不是12.  正是因為這樣,雖然使用volatile修飾,最終執行結果仍然不是我們所期待的。究其原因,還是因為volatile只保證了資料的可見性,並不能保證對被修飾的變數的操作的原子性。

如何修改上面的程式碼使我們得到1000呢,很簡單,在自增的方法前面加上synchronized修飾就行,這時該方法的執行就是一個原子操作。