03.06 面試synchronized和volatile,不要在細節上跌倒

你有一個思想,我有一個思想,我們交換後,一個人就有兩個思想

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

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

面試synchronized和volatile,不要在細節上跌倒

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

  • volatile 與 synchronized 在處理哪些問題是相對等價的?
  • 為什麼說 volatile 是 synchronized 弱同步的方式?
  • volatile 除了可見性問題,還能解決什麼問題?
  • 二者我要如何選擇使用?

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

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

面試synchronized和volatile,不要在細節上跌倒

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

面試synchronized和volatile,不要在細節上跌倒


Java 內存模型(JMM)

CPU 增加了緩存均衡了與內存的速度差異,這一增加還是好幾層。

面試synchronized和volatile,不要在細節上跌倒

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

面試synchronized和volatile,不要在細節上跌倒

看上圖,每個核都有自己的一級緩存(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 的值是這樣的:

面試synchronized和volatile,不要在細節上跌倒

接下來,線程 2 同樣按照上面的步驟訪問變量 X

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

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

面試synchronized和volatile,不要在細節上跌倒

結合剛剛的兩次操作,當線程1再訪問變量x,我們看看有什麼問題:

面試synchronized和volatile,不要在細節上跌倒

此刻,如果線程 1 再次將 x=1回寫,就會覆蓋線程2 x=2 的結果,同樣的共享變量,線程拿到的結果卻不一樣(線程1眼中x=1;線程2眼中x=2),這就是共享變量內存不可見的問題。

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

synchronized

遇到線程不安全的問題,習慣性的會想到用 synchronized 關鍵字來解決問題,暫且先不論該辦法是否合理,我們來看 synchronized 關鍵字是怎麼解決上面提到的共享變量內存可見性問題的

  • 【進入】synchronized 塊的內存語義是把在 synchronized 塊內使用的變量從線程的工作內存中清除,從主內存中讀取
  • 【退出】synchronized 塊的內存語義事把在 synchronized 塊內對共享變量的修改刷新到主內存中

二話不說,無情向下看 volatile

volatile

當一個變量被聲明為 volatile 時:

  • 線程在【讀取】共享變量時,會先清空本地內存變量值,再從主內存獲取最新值
  • 線程在【寫入】共享變量時,不會把值緩存在寄存器或其他地方(就是剛剛說的所謂的「工作內存」),而是會把值刷新回主內存

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

面試synchronized和volatile,不要在細節上跌倒

所以,當使用 synchronized 或 volatile 後,多線程操作共享變量的步驟就變成了這樣:

面試synchronized和volatile,不要在細節上跌倒

簡單點來說就是不再參考 L1 和 L2 中共享變量的值,而是直接訪問主內存

來點踏實的,上例子

<code>public class ThreadNotSafeInteger {
\t/**
\t * 共享變量 value
\t */
\tprivate int value;

\tpublic int getValue() {
\t\treturn value;
\t}

\tpublic void setValue(int value) {
\t\tthis.value = value;
\t}
}
/<code>

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

先使用 volatile 關鍵字改造:

<code>public class ThreadSafeInteger {
\t/**
\t * 共享變量 value
\t */
\tprivate volatile int value;

\tpublic int getValue() {
\t\treturn value;
\t}

\tpublic void setValue(int value) {
\t\tthis.value = value;
\t}

}
/<code>

再使用 synchronized 關鍵字改造

<code>public class ThreadSafeInteger {
\t/**
\t * 共享變量 value
\t */
\tprivate int value;

\tpublic synchronized int getValue() {
\t\treturn value;
\t}

\tpublic synchronized void setValue(int value) {
\t\tthis.value = value;
\t}
}
/<code>

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

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

<code>@Slf4j
public class VisibilityIssue {
\tprivate static final int TOTAL = 10000;

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

\tpublic static void main(String[] args) {
\t\tVisibilityIssue visibilityIssue = new VisibilityIssue();

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

\t\tthread1.start();

\t\tthread2.start();

\t\ttry {
\t\t\tthread1.join();
\t\t\tthread2.join();
\t\t} catch (InterruptedException e) {
\t\t\tlog.error(e.getMessage());
\t\t}

\t\tlog.info("count 值為:{}", visibilityIssue.count);

\t}

\tprivate void add10KCount(){
\t\tint start = 0;
\t\twhile (start ++ < TOTAL){
\t\t\tthis.count ++;
\t\t}
\t}

}
/<code>

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

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

<code>@Slf4j
public class VisibilityIssue {
\tprivate static final int TOTAL = 10000;
\tprivate int count;
\t
//... 同上

\tprivate synchronized void add10KCount(){
\t\tint start = 0;
\t\twhile (start ++ < TOTAL){
\t\t\tthis.count ++;
\t\t}
\t}

}
/<code>

再次運行代碼,count 結果就是 2w

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

面試synchronized和volatile,不要在細節上跌倒

這就要說說二者的不同了

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 除了還能解決可見性問題,還能解決編譯優化重排序問題,之前的文章已經介紹過,請大家點擊鏈接自行查看就好(面試常問的雙重檢查鎖單例模式為什麼不是線程安全的也可以在裡面找到答案哦):

  • 有序性可見性,Happens-before來搞定
  • 面試volatile關鍵字時,我們應該具備哪些談資?

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

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


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

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

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


靈魂追問

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

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


分享到:


相關文章: