volatile和synchronized到底啥區(qū)別?多圖文講解告訴你
- 你有一個(gè)思想,我有一個(gè)思想,我們交換后,一個(gè)人就有兩個(gè)思想
- If you can NOT explain it simply, you do NOT understand it well enough
現(xiàn)陸續(xù)將Demo代碼和技術(shù)文章整理在一起 Github實(shí)踐精選 ,方便大家閱讀查看,本文同樣收錄在此,覺得不錯(cuò),還請(qǐng)Star
之前寫了幾篇 Java并發(fā)編程的系列 文章,有個(gè)朋友微群里問我,還是不能理解 volatile 和 synchronized 二者的區(qū)別, 他的問題主要可以歸納為這幾個(gè):
- volatile 與 synchronized 在處理哪些問題是相對(duì)等價(jià)的?
- 為什么說 volatile 是 synchronized 弱同步的方式?
- volatile 除了可見性問題,還能解決什么問題?
- 二者我要如何選擇使用?
如果你不能回答上面的幾個(gè)問題,說明你對(duì)二者的區(qū)別還有一些含混。本文就通過圖文的方式好好說說他們微妙的關(guān)系
都聽過【天上一天,地下一年】,假設(shè) CPU 執(zhí)行一條普通指令需要一天,那么 CPU 讀寫內(nèi)存就得等待一年的時(shí)間。
受【木桶原理】的限制,在CPU眼里,程序的整體性能都被內(nèi)存的辦事效率拉低了,為了解決這個(gè)短板,硬件同學(xué)也使用了我們做軟件常用的提速策略——使用緩存Cache(實(shí)則是硬件同學(xué)給軟件同學(xué)挖的坑)
Java 內(nèi)存模型(JMM)
CPU 增加了緩存均衡了與內(nèi)存的速度差異,這一增加還是好幾層。
此時(shí)內(nèi)存的短板不再那么明顯,CPU甚喜。但隨之卻帶來很多問題
看上圖,每個(gè)核都有自己的一級(jí)緩存(L1 Cache),有的架構(gòu)里面還有所有核共用的二級(jí)緩存(L2 Cache)。使用緩存之后,當(dāng)線程要訪問共享變量時(shí),如果 L1 中存在該共享變量,就不會(huì)再逐級(jí)訪問直至主內(nèi)存了。所以,通過這種方式,就補(bǔ)上了訪問內(nèi)存慢的短板
具體來說,線程讀/寫共享變量的步驟是這樣:
- 從主內(nèi)存復(fù)制共享變量到自己的工作內(nèi)存
- 在工作內(nèi)存中對(duì)變量進(jìn)行處理
- 處理完后,將變量值更新回主內(nèi)存
假設(shè)現(xiàn)在主內(nèi)存中有共享變量 X, 其初始值為 0
線程1先訪問變量 X, 套用上面的步驟就是這樣:
- L1 和 L2 中都沒有發(fā)現(xiàn)變量 X,直到在主內(nèi)存中找到
- 拷貝變量 X 到 L1 和 L2 中
- 在 L1 中將 X 的值修改為1,并逐層寫回到主內(nèi)存中
此時(shí),在線程 1 眼中,X 的值是這樣的:
接下來,線程 2 同樣按照上面的步驟訪問變量 X
- L1 中沒有發(fā)現(xiàn)變量 X
- L2 中發(fā)現(xiàn)了變量X
- 從L2中拷貝變量到L1中
在L1中將X 的值修改為2,并逐層寫回到主內(nèi)存中
此時(shí),線程 2 眼中,X 的值是這樣的:
結(jié)合剛剛的兩次操作,當(dāng)線程1再訪問變量x,我們看看有什么問題:
此刻,如果線程 1 再次將 x=1回寫,就會(huì)覆蓋線程2 x=2 的結(jié)果,同樣的共享變量,線程拿到的結(jié)果卻不一樣(線程1眼中x=1;線程2眼中x=2),這就是共享變量?jī)?nèi)存不可見的問題。
怎么補(bǔ)坑呢?今天的兩位主角閃亮登場(chǎng),不過在說明 volatile關(guān)鍵字之前,我們先來說說你最熟悉的 synchronized 關(guān)鍵字
synchronized
遇到線程不安全的問題,習(xí)慣性的會(huì)想到用 synchronized 關(guān)鍵字來解決問題,暫且先不論該辦法是否合理,我們來看 synchronized 關(guān)鍵字是怎么解決上面提到的共享變量?jī)?nèi)存可見性問題的
- 【進(jìn)入】synchronized 塊的內(nèi)存語(yǔ)義是把在 synchronized 塊內(nèi)使用的變量從線程的工作內(nèi)存中清除,從主內(nèi)存中讀取
- 【退出】synchronized 塊的內(nèi)存語(yǔ)義事把在 synchronized 塊內(nèi)對(duì)共享變量的修改刷新到主內(nèi)存中
二話不說,無情向下看 volatile
volatile
當(dāng)一個(gè)變量被聲明為 volatile 時(shí):
- 線程在【讀取】共享變量時(shí),會(huì)先清空本地內(nèi)存變量值,再?gòu)闹鲀?nèi)存獲取最新值
- 線程在【寫入】共享變量時(shí),不會(huì)把值緩存在寄存器或其他地方(就是剛剛說的所謂的「工作內(nèi)存」),而是會(huì)把值刷新回主內(nèi)存
有種換湯不換藥的感覺,你看的一點(diǎn)都沒錯(cuò)
所以,當(dāng)使用 synchronized 或 volatile 后,多線程操作共享變量的步驟就變成了這樣:
簡(jiǎn)單點(diǎn)來說就是不再參考 L1 和 L2 中共享變量的值,而是直接訪問主內(nèi)存
來點(diǎn)踏實(shí)的,上例子
- public class ThreadNotSafeInteger {
- /**
- * 共享變量 value
- */
- private int value;
- public int getValue() {
- return value;
- }
- public void setValue(int value) {
- this.value = value;
- }
- }
經(jīng)過前序分析鋪墊,很明顯,上面代碼中,共享變量 value 存在大大的隱患,嘗試對(duì)其作出一些改變
先使用 volatile 關(guān)鍵字改造:
- public class ThreadSafeInteger {
- /**
- * 共享變量 value
- */
- private volatile int value;
- public int getValue() {
- return value;
- }
- public void setValue(int value) {
- this.value = value;
- }
- }
再使用 synchronized 關(guān)鍵字改造
- public class ThreadSafeInteger {
- /**
- * 共享變量 value
- */
- private int value;
- public synchronized int getValue() {
- return value;
- }
- public synchronized void setValue(int value) {
- this.value = value;
- }
- }
這兩個(gè)結(jié)果是完全相同,在解決【當(dāng)前】共享變量數(shù)據(jù)可見性的問題上,二者算是等同的
如果說 synchronized 和 volatile 是完全等同的,那就沒必要設(shè)計(jì)兩個(gè)關(guān)鍵字了,繼續(xù)看個(gè)例子
- @Slf4j
- public class VisibilityIssue {
- private static final int TOTAL = 10000;
- // 即便像下面這樣加了 volatile 關(guān)鍵字修飾不會(huì)解決問題,因?yàn)椴]有解決原子性問題
- 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 ++;
- }
- }
- }
其實(shí)就是將上面setValue 簡(jiǎn)單賦值操作 (this.value = value;)變成了 (this.count ++;)形式,如果你運(yùn)行代碼,你會(huì)發(fā)現(xiàn),count的值始終是處于1w和2w之間的
將上面方法再以 synchronized 的形式做改動(dòng)
- @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 ++;
- }
- }
- }
再次運(yùn)行代碼,count 結(jié)果就是 2w
兩組代碼,都通過 volatile 和 synchronized 關(guān)鍵字以同樣形式修飾,怎么有的可以帶來相同結(jié)果,有的卻不能呢?
這就要說說二者的不同了
count++ 程序代碼是一行,但是翻譯成 CPU 指令確是三行( 不信你用 javap -c 命令試試)
synchronized 是獨(dú)占鎖/排他鎖(就是有你沒我的意思),同時(shí)只能有一個(gè)線程調(diào)用 add10KCount 方法,其他調(diào)用線程會(huì)被阻塞。所以三行 CPU 指令都是同一個(gè)線程執(zhí)行完之后別的線程才能繼續(xù)執(zhí)行,這就是通常說說的 原子性 (線程執(zhí)行多條指令不被中斷)
但 volatile 是非阻塞算法(也就是不排他),當(dāng)遇到三行 CPU 指令自然就不能保證別的線程不插足了,這就是通常所說的,volatile 能保證內(nèi)存可見性,但是不能保證原子性
一句話,那什么時(shí)候才能用volatile關(guān)鍵字呢?(千萬記住了,重要事情說三遍,感覺這句話過時(shí)了)
如果寫入變量值不依賴變量當(dāng)前值,那么就可以用 volatile
如果寫入變量值不依賴變量當(dāng)前值,那么就可以用 volatile
如果寫入變量值不依賴變量當(dāng)前值,那么就可以用 volatile
比如上面 count++ ,是獲取-計(jì)算-寫入三步操作,也就是依賴當(dāng)前值的,所以不能靠volatile 解決問題
到這里,文章開頭第一個(gè)問題【volatile 與 synchronized 在處理哪些問題是相對(duì)等價(jià)的?】答案已經(jīng)揭曉了
先自己腦補(bǔ)一下,如果讓你同一段時(shí)間內(nèi)【寫幾行代碼】就要去【數(shù)錢】,數(shù)幾下錢就要去【唱歌】,唱完歌又要去【寫代碼】,反復(fù)頻繁這樣操作,還要接上上一次的操作(代碼接著寫,錢累加著數(shù),歌接著唱)還需要保證不出錯(cuò),你累不累?
synchronized 是排他的,線程排隊(duì)就要有切換,這個(gè)切換就好比上面的例子,要完成切換,還得記準(zhǔn)線程上一次的操作,很累CPU大腦,這就是通常說的上下文切換會(huì)帶來很大開銷
volatile 就不一樣了,它是非阻塞的方式,所以在解決共享變量可見性問題的時(shí)候,volatile 就是 synchronized 的弱同步體現(xiàn)了
到這,文章的第二個(gè)問題【為什么說 volatile 是 synchronized 弱同步的方式?】你也應(yīng)該明白了吧
volatile 除了還能解決可見性問題,還能解決編譯優(yōu)化重排序問題,之前的文章已經(jīng)介紹過,請(qǐng)大家點(diǎn)擊鏈接自行查看就好(面試常問的雙重檢查鎖單例模式為什么不是線程安全的也可以在里面找到答案哦):
看完這兩篇文章,相信第三個(gè)問題也就迎刃而解了
了解了這些,相信你也就懂得如何使用了
精挑細(xì)選,終于整理完初版 Java 技術(shù)棧硬核資料,搶先看就私信回復(fù)【資料】/【666】吧
靈魂追問
- 你了解線程的生命周期嗎?不同的狀態(tài)流轉(zhuǎn)是什么樣的?
- 為什么線程有通知喚醒機(jī)制?
下一篇文章,我們來說說【喚醒線程為什么建議用notifyAll而不建議用notify呢?】
歡迎關(guān)注我的公眾號(hào) 「日拱一兵」,趣味原創(chuàng)解析Java技術(shù)棧問題,將復(fù)雜問題簡(jiǎn)單化,將抽象問題圖形化落地
如果對(duì)我的專題內(nèi)容感興趣,或搶先看更多內(nèi)容,歡迎訪問我的博客 dayarch.top