Java併發(3)- 聊聊Volatile

引言

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

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++是一個複合操做,他能夠簡單分爲:app

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

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

increaseI = var;//步驟3:將寄存器var的值寫入increaseI
複製代碼

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

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重排序:

線程2在步驟一時判斷instance不爲空的狀況下,實際上對象並無初始化,3.2並無執行。致使接下來使用對象發生錯誤。此時使用volatile修飾instance變量就能夠防止3.2和3.3重排序,這樣就保證了多線程訪問時代碼的正確性。

咱們能夠查看到彙編代碼中在使用volatile關鍵字後在步驟三中多了lock指令來保證當前執行的有序性: 不使用volatile:

使用volatile

volatile背後的原理

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

lock指令有兩個功能:

  1. 對CPU總線和高速緩存加鎖,加鎖以後執行後面的指令,而後釋放鎖時將高速緩存中的數據刷新回主內存。
  2. lock會讓其餘CPU高速緩存中的緩存行失效,其餘CPU讀取時必需要從主內存加載最新數據。

簡單來講就是lock指令能夠實現緩存一致性。經過lock指令的這兩個功能,咱們就能夠很簡單的理解當共享變量flag用volatile修飾後,每次更新flag的值都會致使緩存行的數據強制刷新最新值到主內存,volatile變量以前的數據也會被刷新回主內存。同時其餘線程必須到主內存讀取最新flag的值。這樣就實現了共享變量的可見性以及有序性。


參考資料:
  1. 《深刻理解Java虛擬機》
  2. 《Java併發編程的藝術》
相關文章
相關標籤/搜索