volatile和synchronized到底啥區別?多圖文講解告訴你

日拱一兵發表於2020-03-06
  • 你有一個思想,我有一個思想,我們交換後,一個人就有兩個思想

  • If you can NOT explain it simply, you do NOT understand it well enough

現陸續將Demo程式碼和技術文章整理在一起 Github實踐精選 ,方便大家閱讀檢視,本文同樣收錄在此,覺得不錯,還請Star

volatile和synchronized到底啥區別?多圖文講解告訴你

之前寫了幾篇 Java併發程式設計的系列 文章,有個朋友微群裡問我,還是不能理解 volatilesynchronized 二者的區別, 他的問題主要可以歸納為這幾個:

  • volatile 與 synchronized 在處理哪些問題是相對等價的?

  • 為什麼說 volatile 是 synchronized 弱同步的方式?

  • volatile 除了可見性問題,還能解決什麼問題?

  • 二者我要如何選擇使用?

如果你不能回答上面的幾個問題,說明你對二者的區別還有一些含混。本文就通過圖文的方式好好說說他們微妙的關係

都聽過【天上一天,地下一年】,假設 CPU 執行一條普通指令需要一天,那麼 CPU 讀寫記憶體就得等待一年的時間。

volatile和synchronized到底啥區別?多圖文講解告訴你

受【木桶原理】的限制,在CPU眼裡,程式的整體效能都被記憶體的辦事效率拉低了,為了解決這個短板,硬體同學也使用了我們做軟體常用的提速策略——使用快取Cache(實則是硬體同學給軟體同學挖的坑)

volatile和synchronized到底啥區別?多圖文講解告訴你

Java 記憶體模型(JMM)

CPU 增加了快取均衡了與記憶體的速度差異,這一增加還是好幾層。

volatile和synchronized到底啥區別?多圖文講解告訴你

此時記憶體的短板不再那麼明顯,CPU甚喜。但隨之卻帶來很多問題

volatile和synchronized到底啥區別?多圖文講解告訴你

看上圖,每個核都有自己的一級快取(L1 Cache),有的架構裡面還有所有核共用的二級快取(L2 Cache)。使用快取之後,當執行緒要訪問共享變數時,如果 L1 中存在該共享變數,就不會再逐級訪問直至主記憶體了。所以,通過這種方式,就補上了訪問記憶體慢的短板

具體來說,執行緒讀/寫共享變數的步驟是這樣:

  1. 從主記憶體複製共享變數到自己的工作記憶體
  2. 在工作記憶體中對變數進行處理
  3. 處理完後,將變數值更新回主記憶體

假設現在主記憶體中有共享變數 X, 其初始值為 0

執行緒1先訪問變數 X, 套用上面的步驟就是這樣:

  1. L1 和 L2 中都沒有發現變數 X,直到在主記憶體中找到
  2. 拷貝變數 X 到 L1 和 L2 中
  3. 在 L1 中將 X 的值修改為1,並逐層寫回到主記憶體中

此時,線上程 1 眼中,X 的值是這樣的:

volatile和synchronized到底啥區別?多圖文講解告訴你

接下來,執行緒 2 同樣按照上面的步驟訪問變數 X

  1. L1 中沒有發現變數 X
  2. L2 中發現了變數X
  3. 從L2中拷貝變數到L1中
  4. 在L1中將X 的值修改為2,並逐層寫回到主記憶體中

此時,執行緒 2 眼中,X 的值是這樣的:

volatile和synchronized到底啥區別?多圖文講解告訴你

結合剛剛的兩次操作,當執行緒1再訪問變數x,我們看看有什麼問題:

volatile和synchronized到底啥區別?多圖文講解告訴你

此刻,如果執行緒 1 再次將 x=1回寫,就會覆蓋執行緒2 x=2 的結果,同樣的共享變數,執行緒拿到的結果卻不一樣(執行緒1眼中x=1;執行緒2眼中x=2),這就是共享變數記憶體不可見的問題。

怎麼補坑呢?今天的兩位主角閃亮登場,不過在說明 volatile關鍵字之前,我們先來說說你最熟悉的 synchronized 關鍵字

synchronized

遇到執行緒不安全的問題,習慣性的會想到用 synchronized 關鍵字來解決問題,暫且先不論該辦法是否合理,我們來看 synchronized 關鍵字是怎麼解決上面提到的共享變數記憶體可見性問題的

  • 【進入】synchronized 塊的記憶體語義是把在 synchronized 塊內使用的變數從執行緒的工作記憶體中清除,從主記憶體中讀取
  • 【退出】synchronized 塊的記憶體語義事把在 synchronized 塊內對共享變數的修改重新整理到主記憶體中

二話不說,無情向下看 volatile

volatile

當一個變數被宣告為 volatile 時:

  • 執行緒在【讀取】共享變數時,會先清空本地記憶體變數值,再從主記憶體獲取最新值
  • 執行緒在【寫入】共享變數時,不會把值快取在暫存器或其他地方(就是剛剛說的所謂的「工作記憶體」),而是會把值重新整理回主記憶體

有種換湯不換藥的感覺,你看的一點都沒錯

volatile和synchronized到底啥區別?多圖文講解告訴你

所以,當使用 synchronized 或 volatile 後,多執行緒操作共享變數的步驟就變成了這樣:

volatile和synchronized到底啥區別?多圖文講解告訴你

簡單點來說就是不再參考 L1 和 L2 中共享變數的值,而是直接訪問主記憶體

來點踏實的,上例子

public class ThreadNotSafeInteger {
	/**
	 * 共享變數 value
	 */
	private int value;

	public int getValue() {
		return value;
	}

	public void setValue(int value) {
		this.value = value;
	}
}
複製程式碼

經過前序分析鋪墊,很明顯,上面程式碼中,共享變數 value 存在大大的隱患,嘗試對其作出一些改變

先使用 volatile 關鍵字改造:

public class ThreadSafeInteger {
	/**
	 * 共享變數 value
	 */
	private volatile int value;

	public int getValue() {
		return value;
	}

	public void setValue(int value) {
		this.value = value;
	}
}
複製程式碼

再使用 synchronized 關鍵字改造

public class ThreadSafeInteger {
	/**
	 * 共享變數 value
	 */
	private int value;

	public synchronized int getValue() {
		return value;
	}

	public synchronized void setValue(int value) {
		this.value = value;
	}
}
複製程式碼

這兩個結果是完全相同,在解決【當前】共享變數資料可見性的問題上,二者算是等同的

如果說 synchronized 和 volatile 是完全等同的,那就沒必要設計兩個關鍵字了,繼續看個例子

@Slf4j
public class VisibilityIssue {
	private static final int TOTAL = 10000;

//	即便像下面這樣加了 volatile 關鍵字修飾不會解決問題,因為並沒有解決原子性問題
	private volatile int count;

	public static void main(String[] args) {
		VisibilityIssue visibilityIssue = new VisibilityIssue();

		Thread thread1 = new Thread(() -> visibilityIssue.add10KCount());
		Thread thread2 = new Thread(() -> visibilityIssue.add10KCount());

		thread1.start();
		thread2.start();

		try {
			thread1.join();
			thread2.join();
		} catch (InterruptedException e) {
			log.error(e.getMessage());
		}

		log.info("count 值為:{}", visibilityIssue.count);

	}

	private void add10KCount(){
		int start = 0;
		while (start ++ < TOTAL){
			this.count ++;
		}
	}

}
複製程式碼

其實就是將上面setValue 簡單賦值操作 (this.value = value;)變成了 (this.count ++;)形式,如果你執行程式碼,你會發現,count的值始終是處於1w和2w之間的

將上面方法再以 synchronized 的形式做改動

@Slf4j
public class VisibilityIssue {
	private static final int TOTAL = 10000;
	private int count;
	
  //... 同上

	private synchronized void add10KCount(){
		int start = 0;
		while (start ++ < TOTAL){
			this.count ++;
		}
	}

}
複製程式碼

再次執行程式碼,count 結果就是 2w

兩組程式碼,都通過 volatile 和 synchronized 關鍵字以同樣形式修飾,怎麼有的可以帶來相同結果,有的卻不能呢?

volatile和synchronized到底啥區別?多圖文講解告訴你

這就要說說二者的不同了

count++ 程式程式碼是一行,但是翻譯成 CPU 指令確是三行( 不信你用 javap -c 命令試試)

synchronized 是獨佔鎖/排他鎖(就是有你沒我的意思),同時只能有一個執行緒呼叫 add10KCount 方法,其他呼叫執行緒會被阻塞。所以三行 CPU 指令都是同一個執行緒執行完之後別的執行緒才能繼續執行,這就是通常說說的 原子性 (執行緒執行多條指令不被中斷)

volatile 是非阻塞演算法(也就是不排他),當遇到三行 CPU 指令自然就不能保證別的執行緒不插足了,這就是通常所說的,volatile 能保證記憶體可見性,但是不能保證原子性

一句話,那什麼時候才能用volatile關鍵字呢?(千萬記住了,重要事情說三遍,感覺這句話過時了)

如果寫入變數值不依賴變數當前值,那麼就可以用 volatile

如果寫入變數值不依賴變數當前值,那麼就可以用 volatile

如果寫入變數值不依賴變數當前值,那麼就可以用 volatile

比如上面 count++ ,是獲取-計算-寫入三步操作,也就是依賴當前值的,所以不能靠volatile 解決問題

到這裡,文章開頭第一個問題【volatile 與 synchronized 在處理哪些問題是相對等價的?】答案已經揭曉了

先自己腦補一下,如果讓你同一段時間內【寫幾行程式碼】就要去【數錢】,數幾下錢就要去【唱歌】,唱完歌又要去【寫程式碼】,反覆頻繁這樣操作,還要接上上一次的操作(程式碼接著寫,錢累加著數,歌接著唱)還需要保證不出錯,你累不累?

synchronized 是排他的,執行緒排隊就要有切換,這個切換就好比上面的例子,要完成切換,還得記準執行緒上一次的操作,很累CPU大腦,這就是通常說的上下文切換會帶來很大開銷

volatile 就不一樣了,它是非阻塞的方式,所以在解決共享變數可見性問題的時候,volatile 就是 synchronized 的弱同步體現了

到這,文章的第二個問題【為什麼說 volatile 是 synchronized 弱同步的方式?】你也應該明白了吧

volatile 除了還能解決可見性問題,還能解決編譯優化重排序問題,之前的文章已經介紹過,請大家點選連結自行檢視就好(面試常問的雙重檢查鎖單例模式為什麼不是執行緒安全的也可以在裡面找到答案哦):

看完這兩篇文章,相信第三個問題也就迎刃而解了

瞭解了這些,相信你也就懂得如何使用了

精挑細選,終於整理完初版 Java 技術棧硬核資料,搶先看就公眾號回覆【資料】/【666】吧

靈魂追問

  1. 你瞭解執行緒的生命週期嗎?不同的狀態流轉是什麼樣的?
  2. 為什麼執行緒有通知喚醒機制?

下一篇文章,我們來說說【喚醒執行緒為什麼建議用notifyAll而不建議用notify呢?】

個人部落格:https://dayarch.top

加我微信好友, 進群娛樂學習交流,備註「進群」

歡迎持續關注公眾號:「日拱一兵」

  • 前沿 Java 技術乾貨分享
  • 高效工具彙總 | 回覆「工具」
  • 面試問題分析與解答
  • 技術資料領取 | 回覆「資料」

以讀偵探小說思維輕鬆趣味學習 Java 技術棧相關知識,本著將複雜問題簡單化,抽象問題具體化和圖形化原則逐步分解技術問題,技術持續更新,請持續關注......


volatile和synchronized到底啥區別?多圖文講解告訴你

相關文章