來了蔑滓!大廠面試(Java崗)常問線程與鎖機(jī)制知識點(diǎn)最全整合

再談多線程
在我們的操作系統(tǒng)之上,可以同時運(yùn)行很多個進(jìn)程燎窘,并且每個進(jìn)程之間相互隔離互不干擾蹄咖。

我們的CPU會通過時間片輪轉(zhuǎn)算法,為每一個進(jìn)程分配時間片蚜迅,并在時間片使用結(jié)束后切換下一個進(jìn)程繼續(xù)執(zhí)行俊抵,通過這種方式來實(shí)現(xiàn)宏觀上的多個程序同時運(yùn)行。

由于每個進(jìn)程都有一個自己的內(nèi)存空間拍谐,進(jìn)程之間的通信就變得非常麻煩(比如要共享某些數(shù)據(jù))而且執(zhí)行不同進(jìn)程會產(chǎn)生上下文切換馏段,非常耗時,那么有沒有一種更好地方案呢院喜?

后來,線程橫空出世砍濒,一個進(jìn)程可以有多個線程,線程是程序執(zhí)行中一個單一的順序控制流程爸邢,現(xiàn)在線程才是程序執(zhí)行流的最小單元杠河,各個線程之間共享程序的內(nèi)存空間(也就是所在進(jìn)程的內(nèi)存空間)浇辜,上下文切換速度也高于進(jìn)程。

現(xiàn)在有這樣一個問題:

public static void main(String[] args) {
    int[] arr = new int[]{3, 1, 5, 2, 4};
    //請將上面的數(shù)組按升序輸出
}

按照正常思維待诅,我們肯定是這樣:

public static void main(String[] args) {
    int[] arr = new int[]{3, 1, 5, 2, 4};
        //直接排序吧
    Arrays.sort(arr);
    for (int i : arr) {
        System.out.println(i);
    }
}

而我們學(xué)習(xí)了多線程之后跪解,可以換個思路來實(shí)現(xiàn):

public static void main(String[] args) {
    int[] arr = new int[]{3, 1, 5, 2, 4};

    for (int i : arr) {
        new Thread(() -> {
            try {
                Thread.sleep(i * 1000);   //越小的數(shù)休眠時間越短,優(yōu)先被打印
                System.out.println(i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

我們接觸過的很多框架都在使用多線程绪囱,比如Tomcat服務(wù)器,所有用戶的請求都是通過不同的線程來進(jìn)行處理的弛房,這樣我們的網(wǎng)站才可以同時響應(yīng)多個用戶的請求而柑,要是沒有多線程,可想而知服務(wù)器的處理效率會有多低媒咳。


在Java 5的時候涩澡,新增了java.util.concurrent(JUC)包,其中包括大量用于多線程編程的工具類,目的是為了更好的支持高并發(fā)任務(wù)膝迎,讓開發(fā)者進(jìn)行多線程編程時減少競爭條件和死鎖的問題胰耗!


并發(fā)與并行

我們經(jīng)常聽到并發(fā)編程,那么這個并發(fā)代表的是什么意思呢卖漫?而與之相似的并行又是什么意思赠群?它們之間有什么區(qū)別?

比如現(xiàn)在一共有三個工作需要我們?nèi)ネ瓿伞?/p>

image.png

順序執(zhí)行

順序執(zhí)行其實(shí)很好理解突委,就是我們依次去將這些任務(wù)完成了:

image.png

實(shí)際上就是我們同一時間只能處理一個任務(wù)鸯两,所以需要前一個任務(wù)完成之后长豁,才能繼續(xù)下一個任務(wù),依次完成所有任務(wù)钝侠。

并發(fā)執(zhí)行

并發(fā)執(zhí)行也是我們同一時間只能處理一個任務(wù)酸舍,但是我們可以每個任務(wù)輪著做(時間片輪轉(zhuǎn)):

image.png

而我們Java中的線程,正是這種機(jī)制忽舟,當(dāng)我們需要同時處理上百個上千個任務(wù)時淮阐,很明顯CPU的數(shù)量是不可能趕得上我們的線程數(shù)的,所以說這時就要求我們的程序有良好的并發(fā)性能浩姥,來應(yīng)對同一時間大量的任務(wù)處理状您。

學(xué)習(xí)Java并發(fā)編程兜挨,能夠讓我們在以后的實(shí)際場景中拌汇,知道該如何應(yīng)對高并發(fā)的情況颗搂。

并行執(zhí)行

并行執(zhí)行就突破了同一時間只能處理一個任務(wù)的限制幕垦,我們同一時間可以做多個任務(wù):


image.png

比如我們要進(jìn)行一些排序操作,就可以用到并行計(jì)算疚察,只需要等待所有子任務(wù)完成仇奶,最后將結(jié)果匯總即可。包括分布式計(jì)算模型MapReduce岛抄,也是采用的并行計(jì)算思路狈茉。


再談鎖機(jī)制

談到鎖機(jī)制,相信各位應(yīng)該并不陌生了氯庆,我們在JavaSE階段,通過使用synchronized關(guān)鍵字來實(shí)現(xiàn)鎖仁讨,

這樣就能夠很好地解決線程之間爭搶資源的情況实昨。

那么,synchronized底層到底是如何實(shí)現(xiàn)的呢丈挟?

我們知道锐墙,使用synchronized,一定是和某個對象相關(guān)聯(lián)的溪北,比如我們要對某一段代碼加鎖夺脾,那么我們就需要提供一個對象來作為鎖本身

public static void main(String[] args) {
    synchronized (Main.class) {
        //這里使用的是Main類的Class對象作為鎖
    }
}

我們來看看咧叭,它變成字節(jié)碼之后會用到哪些指令:


image.png

其中最關(guān)鍵的就是monitorenter指令了菲茬,可以看到之后也有monitorexit與之進(jìn)行匹配(注意這里有2個)派撕,monitorenter和monitorexit分別對應(yīng)加鎖和釋放鎖,在執(zhí)行monitorenter之前需要嘗試獲取鎖镀赌。

每個對象都有一個monitor監(jiān)視器與之對應(yīng)际跪,而這里正是去獲取對象監(jiān)視器的所有權(quán),一旦monitor所有權(quán)被某個線程持有姆打,那么其他線程將無法獲得(管程模型的一種實(shí)現(xiàn))。


在代碼執(zhí)行完成之后玛追,我們可以看到评抚,一共有兩個monitorexit在等著我們,那么為什么這里會有兩個呢邢笙?

按理說monitorenter和monitorexit不應(yīng)該一一對應(yīng)嗎侍匙,這里為什么要釋放鎖兩次呢?

首先我們來看第一個妇汗,這里在釋放鎖之后说莫,會馬上進(jìn)入到一個goto指令,

跳轉(zhuǎn)到15行互婿,而我們的15行對應(yīng)的指令就是方法的返回指令,其實(shí)正常情況下只會執(zhí)行第一個monitorexit釋放鎖慈参,在釋放鎖之后就接著同步代碼塊后面的內(nèi)容繼續(xù)向下執(zhí)行了。

而第二個娘扩,其實(shí)是用來處理異常的壮锻,可以看到,它的位置是在12行躯保,如果程序運(yùn)行發(fā)生異常,那么就會執(zhí)行第二個monitorexit,并且會繼續(xù)向下通過athrow指令拋出異常擅羞,而不是直接跳轉(zhuǎn)到15行正常運(yùn)行下去。

image.png

實(shí)際上synchronized使用的鎖就是存儲在Java對象頭中的召烂,我們知道娃承,對象是存放在堆內(nèi)存中的,而每個對象內(nèi)部酗昼,都有一部分空間用于存儲對象頭信息梳猪。

而對象頭信息中,則包含了Mark Word用于存放hashCode和對象的鎖信息呛哟,在不同狀態(tài)下匿沛,它存儲的數(shù)據(jù)結(jié)構(gòu)有一些不同。

image.png

重量級鎖

在JDK6之前鳖孤,synchronized一直被稱為重量級鎖,monitor依賴于底層操作系統(tǒng)的Lock實(shí)現(xiàn)淌铐。

Java的線程是映射到操作系統(tǒng)的原生線程上,切換成本較高际起。而在JDK6之后吐葱,鎖的實(shí)現(xiàn)得到了改進(jìn)。

我們先從最原始的重量級鎖開始:

我們說了灾前,每個對象都有一個monitor與之關(guān)聯(lián)孟辑,在Java虛擬機(jī)(HotSpot)中,monitor是由ObjectMonitor實(shí)現(xiàn)的:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //記錄個數(shù)
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //處于wait狀態(tài)的線程炭玫,會被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //處于等待鎖block狀態(tài)的線程貌虾,會被加入到該列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

每個等待鎖的線程都會被封裝成ObjectWaiter對象,進(jìn)入到如下機(jī)制:

image.png

設(shè)計(jì)思路

ObjectWaiter首先會進(jìn)入 Entry Set等著衔憨,

當(dāng)線程獲取到對象的monitor后進(jìn)入 The Owner 區(qū)域并把monitor中的owner變量設(shè)置為當(dāng)前線程袄膏,

同時monitor中的計(jì)數(shù)器count加1,若線程調(diào)用wait()方法平项,將釋放當(dāng)前持有的monitor悍及,owner變量恢復(fù)為null,count自減1扣讼,

同時該線程進(jìn)入 WaitSet集合中等待被喚醒缨叫。若當(dāng)前線程執(zhí)行完畢也將釋放monitor并復(fù)位變量的值荔燎,以便其他線程進(jìn)入獲取對象的monitor有咨。


雖然這樣的設(shè)計(jì)思路非常合理,但是在大多數(shù)應(yīng)用上座享,每一個線程占用同步代碼塊的時間并不是很長似忧,我們完全沒有必要將競爭中的線程掛起然后又喚醒,并且現(xiàn)代CPU基本都是多核心運(yùn)行的淳衙,我們可以采用一種新的思路來實(shí)現(xiàn)鎖饺著。


新思路

在JDK1.4.2時,引入了自旋鎖(JDK6之后默認(rèn)開啟)匠童,它不會將處于等待狀態(tài)的線程掛起塑顺,而是通過無限循環(huán)的方式严拒,不斷檢測是否能夠獲取鎖。

由于單個線程占用鎖的時間非常短裤唠,所以說循環(huán)次數(shù)不會太多莹痢,可能很快就能夠拿到鎖并運(yùn)行,這就是自旋鎖航瞭。

當(dāng)然坦辟,僅僅是在等待時間非常短的情況下,自旋鎖的表現(xiàn)會很好滨彻,但是如果等待時間太長,由于循環(huán)是需要處理器繼續(xù)運(yùn)算的亭饵,所以這樣只會浪費(fèi)處理器資源,因此自旋鎖的等待時間是有限制的辜羊,默認(rèn)情況下為10次,如果失敗庇麦,那么會進(jìn)而采用重量級鎖機(jī)制喜德。

image.png

在JDK6之后,自旋鎖得到了一次優(yōu)化航棱,自旋的次數(shù)限制不再是固定的萌衬,而是自適應(yīng)變化的。

比如在同一個鎖對象上朴艰,自旋等待剛剛成功獲得過鎖混移,并且持有鎖的線程正在運(yùn)行,那么這次自旋也是有可能成功的歌径,所以會允許自旋更多次。

當(dāng)然狗准,如果某個鎖經(jīng)常都自旋失敗茵肃,那么有可能會不再采用自旋策略,而是直接使用重量級鎖饼酿。

輕量級鎖

從JDK 1.6開始,為了減少獲得鎖和釋放鎖帶來的性能消耗想鹰,就引入了輕量級鎖药版。

輕量級鎖的目標(biāo)是,在無競爭情況下槽片,減少重量級鎖產(chǎn)生的性能消耗

(并不是為了代替重量級鎖,實(shí)際上就是賭一手同一時間只有一個線程在占用資源)碌廓,

包括系統(tǒng)調(diào)用引起的內(nèi)核態(tài)與用戶態(tài)切換剩盒、線程阻塞造成的線程切換等。

它不像是重量級鎖那樣纪挎,需要向操作系統(tǒng)申請互斥量跟匆。


運(yùn)作機(jī)制

在即將開始執(zhí)行同步代碼塊中的內(nèi)容時,會首先檢查對象的Mark Word玛臂,查看鎖對象是否被其他線程占用,

如果沒有任何線程占用玖绿,那么會在當(dāng)前線程中所處的棧幀中建立一個名為鎖記錄(Lock Record)的空間叁巨,用于復(fù)制并存儲對象目前的Mark Word信息(官方稱為Displaced Mark Word)锋勺。

接著狡蝶,虛擬機(jī)將使用CAS操作將對象的Mark Word更新為輕量級鎖狀態(tài)(數(shù)據(jù)結(jié)構(gòu)變?yōu)橹赶騆ock Record的指針,指向的是當(dāng)前的棧幀)

CAS(Compare And Swap)是一種無鎖算法苏章,

它并不會為對象加鎖,而是在執(zhí)行的時候枫绅,看看當(dāng)前數(shù)據(jù)的值是不是我們預(yù)期的那樣,

如果是寓搬,那就正常進(jìn)行替換县耽,

如果不是,那么就替換失敗唾琼。

比如有兩個線程都需要修改變量i的值澎剥,默認(rèn)為10,

現(xiàn)在一個線程要將其修改為20肴裙,另一個要修改為30,

如果他們都使用CAS算法甜癞,那么并不會加鎖訪問i宛乃,而是直接嘗試修改i的值,

但是在修改時析既,需要確認(rèn)i是不是10谆奥,

如果是,表示其他線程還沒對其進(jìn)行修改酸些,

如果不是,那么說明其他線程已經(jīng)將其修改沿侈,此時不能完成修改任務(wù)市栗,修改失敗咳短。

在CPU中咙好,CAS操作使用的是cmpxchg指令铣鹏,能夠從最底層硬件層面得到效率的提升。

如果CAS操作失敗了的話诚卸,那么說明可能這時有線程已經(jīng)進(jìn)入這個同步代碼塊了,

這時虛擬機(jī)會再次檢查對象的Mark Word卒密,是否指向當(dāng)前線程的棧幀棠赛,

如果是,說明不是其他線程鼎俘,而是當(dāng)前線程已經(jīng)有了這個對象的鎖辩涝,直接放心大膽進(jìn)同步代碼塊即可。

如果不是捉邢,那確實(shí)是被其他線程占用了商膊。

這時,輕量級鎖一開始的想法就是錯的(這時有對象在競爭資源晕拆,已經(jīng)賭輸了),所以說只能將鎖膨脹為重量級鎖阱高,按照重量級鎖的操作執(zhí)行(注意鎖的膨脹是不可逆的)

如圖所示:

image.png

所以茬缩,輕量級鎖 -> 失敗 -> 自適應(yīng)自旋鎖 -> 失敗 -> 重量級鎖

解鎖過程同樣采用CAS算法凰锡,如果對象的MarkWord仍然指向線程的鎖記錄,

那么就用CAS操作把對象的MarkWord和復(fù)制到棧幀中的Displaced Mark Word進(jìn)行交換裕膀。

如果替換失敗勇哗,說明其他線程嘗試過獲取該鎖,在釋放鎖的同時抄谐,需要喚醒被掛起的線程。

偏向鎖

偏向鎖相比輕量級鎖更純粹蛹含,干脆就把整個同步都消除掉塞颁,不需要再進(jìn)行CAS操作了。

它的出現(xiàn)主要是得益于人們發(fā)現(xiàn)某些情況下某個鎖頻繁地被同一個線程獲取酷窥,

這種情況下伴网,我們可以對輕量級鎖進(jìn)一步優(yōu)化。


偏向鎖實(shí)際上就是專門為單個線程而生的拳氢,當(dāng)某個線程第一次獲得鎖時蛋铆,如果接下來都沒有其他線程獲取此鎖,那么持有鎖的線程將不再需要進(jìn)行同步操作刺啦。

可以從之前的MarkWord結(jié)構(gòu)中看到,偏向鎖也會通過CAS操作記錄線程的ID蜕青,如果一直都是同一個線程獲取此鎖糊渊,那么完全沒有必要在進(jìn)行額外的CAS操作。

當(dāng)然贺喝,如果有其他線程來搶了,那么偏向鎖會根據(jù)當(dāng)前狀態(tài)躏鱼,決定是否要恢復(fù)到未鎖定或是膨脹為輕量級鎖。

如果我們需要使用偏向鎖染苛,可以添加-XX:+UseBiased參數(shù)來開啟。

所以躯概,最終的鎖等級為:未鎖定 < 偏向鎖 < 輕量級鎖 < 重量級鎖

值得注意的是拢军,如果對象通過調(diào)用hashCode()方法計(jì)算過對象的一致性哈希值,

那么它是不支持偏向鎖的固蛾,會直接進(jìn)入到輕量級鎖狀態(tài)度陆,

因?yàn)镠ash是需要被保存的,而偏向鎖的Mark Word數(shù)據(jù)結(jié)構(gòu)懂傀,無法保存Hash值;

如果對象已經(jīng)是偏向鎖狀態(tài)恃泪,再去調(diào)用hashCode()方法犀斋,那么會直接將鎖升級為重量級鎖,并將哈希值存放在monitor(有預(yù)留位置保存)中览效。


image.png

鎖消除和鎖粗化

鎖消除和鎖粗化都是在運(yùn)行時的一些優(yōu)化方案虫几。

比如我們某段代碼雖然加了鎖,但是在運(yùn)行時根本不可能出現(xiàn)各個線程之間資源爭奪的情況但校,

這種情況下啡氢,完全不需要任何加鎖機(jī)制州刽,所以鎖會被消除浪箭。

鎖粗化則是我們代碼中頻繁地出現(xiàn)互斥同步操作奶栖,比如在一個循環(huán)內(nèi)部加鎖门坷,這樣明顯是非常消耗性能的,所以虛擬機(jī)一旦檢測到這種操作冻晤,會將整個同步范圍進(jìn)行擴(kuò)展。


JMM內(nèi)存模型

注意這里提到的內(nèi)存模型和我們在JVM中介紹的內(nèi)存模型不在同一個層次鼻弧,

JVM中的內(nèi)存模型是虛擬機(jī)規(guī)范對整個內(nèi)存區(qū)域的規(guī)劃锦茁,

而Java內(nèi)存模型,是在JVM內(nèi)存模型之上的抽象模型度帮,具體實(shí)現(xiàn)依然是基于JVM內(nèi)存模型實(shí)現(xiàn)的稿存,以前的文章有介紹。

https://www.cnblogs.com/zwtblog/tag/率翅,側(cè)邊欄支持搜索拂苹。

Java內(nèi)存模型

我們在計(jì)算機(jī)組成原理中學(xué)習(xí)過,在我們的CPU中瓢棒,一般都會有高速緩存,而它的出現(xiàn)念颈,是為了解決內(nèi)存的速度跟不上處理器的處理速度的問題连霉。

所以CPU內(nèi)部會添加一級或多級高速緩存來提高處理器的數(shù)據(jù)獲取效率嗡靡,

但是這樣也會導(dǎo)致一個很明顯的問題讨彼,因?yàn)楝F(xiàn)在基本都是多核心處理器,每個處理器都有一個自己的高速緩存哈误,那么又該怎么去保證每個處理器的高速緩存內(nèi)容一致呢躏嚎?


image.png

為了解決緩存一致性的問題,需要各個處理器訪問緩存時都遵循一些協(xié)議重荠,在讀寫時要根據(jù)協(xié)議來進(jìn)行操作虚茶。

這類協(xié)議有MSI、MESI(Illinois Protocol)荞彼、MOSI待笑、Synapse、Firefly及Dragon Protocol等暮蹂。

而Java也采用了類似的模型來實(shí)現(xiàn)支持多線程的內(nèi)存模型:


image.png

JMM(Java Memory Model)內(nèi)存模型規(guī)定如下:

  • 所有的變量全部存儲在主內(nèi)存(注意這里包括下面提到的變量仰泻,指的都是會出現(xiàn)競爭的變量,包括成員變量集侯、靜態(tài)變量等,而局部變量這種屬于線程私有浓体,不包括在內(nèi))
  • 每條線程有著自己的工作內(nèi)存(可以類比CPU的高速緩存)線程對變量的所有操作辈讶,必須在工作內(nèi)存中進(jìn)行,不能直接操作主內(nèi)存中的數(shù)據(jù)生闲。
  • 不同線程之間的工作內(nèi)存相互隔離,如果需要在線程之間傳遞內(nèi)容碍讯,只能通過主內(nèi)存完成,無法直接訪問對方的工作內(nèi)存屯阀。

也就是說轴术,每一條線程如果要操作主內(nèi)存中的數(shù)據(jù)逗栽,那么得先拷貝到自己的工作內(nèi)存中失暂,并對工作內(nèi)存中數(shù)據(jù)的副本進(jìn)行操作,操作完成之后弟塞,也需要從工作副本中將結(jié)果拷貝回主內(nèi)存中,具體的操作就是Save(保存)和Load(加載)操作摧冀。

那么各位肯定會好奇系宫,這個內(nèi)存模型,結(jié)合之前JVM所講的內(nèi)容扩借,具體是怎么實(shí)現(xiàn)的呢?

  • 主內(nèi)存:對應(yīng)堆中存放對象的實(shí)例的部分康谆。
  • 工作內(nèi)存:對應(yīng)線程的虛擬機(jī)棧的部分區(qū)域嫉到,虛擬機(jī)可能會對這部分內(nèi)存進(jìn)行優(yōu)化,
  • 將其放在CPU的寄存器或是高速緩存中描睦。
  • 比如在訪問數(shù)組時导而,由于數(shù)組是一段連續(xù)的內(nèi)存空間隔崎,
  • 所以可以將一部分連續(xù)空間放入到CPU高速緩存中韵丑,那么之后如果我們順序讀取這個數(shù)組,那么大概率會直接緩存命中钓株。

前面我們提到陌僵,在CPU中可能會遇到緩存不一致的問題,而Java中碗短,也會遇到,比如下面這種情況:

public class Main {
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("線程1結(jié)束");
        }).start();
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("線程2結(jié)束");
        }).start();
        //等上面兩個線程結(jié)束
        Thread.sleep(1000);
        System.out.println(i);
    }
}

可以看到這里是兩個線程同時對變量i各自進(jìn)行100000次自增操作总滩,但是實(shí)際得到的結(jié)果并不是我們所期望的那樣巡雨。


那么為什么會這樣呢?

在之前學(xué)習(xí)了JVM之后冈涧,相信各位應(yīng)該已經(jīng)知道蝌以,自增操作實(shí)際上并不是由一條指令完成的(注意一定不要理解為一行代碼就是一個指令完成的):

image.png

包括變量i的獲取、修改咽筋、保存徊件,都是被拆分為一個一個的操作完成的,那么這個時候就有可能出現(xiàn)在修改完保存之前虱痕,另一條線程也保存了,但是當(dāng)前線程是毫不知情的硝训。

image.png

所以說,在JavaSE階段講解這個問題的時候赘风,是通過synchronized關(guān)鍵字添加同步代碼塊解決的纵刘,另外的解決方案(原子類)。

重排序

在編譯或執(zhí)行時假哎,為了優(yōu)化程序的執(zhí)行效率,編譯器或處理器常常會對指令進(jìn)行重排序肪虎,有以下情況:

  1. 編譯器重排序:Java編譯器通過對Java代碼語義的理解惧蛹,根據(jù)優(yōu)化規(guī)則對代碼指令進(jìn)行重排序。
  2. 機(jī)器指令級別的重排序:現(xiàn)代處理器很高級赊淑,能夠自主判斷和變更機(jī)器指令的執(zhí)行順序陶缺。

令重排序能夠在不改變結(jié)果(單線程)的情況下洁灵,優(yōu)化程序的運(yùn)行效率,比如:

public static void main(String[] args) {
    int a = 10;
    int b = 20;
    System.out.println(a + b);
}
我們其實(shí)可以交換第一行和第二行:

public static void main(String[] args) {
    int b = 10;
    int a = 20;
    System.out.println(a + b);
}

即使發(fā)生交換苫费,但是我們程序最后的運(yùn)行結(jié)果是不會變的双抽,當(dāng)然這里只通過代碼的形式演示,實(shí)際上JVM在執(zhí)行字節(jié)碼指令時也會進(jìn)行優(yōu)化牍汹,可能兩個指令并不會按照原有的順序進(jìn)行。

雖然單線程下指令重排確實(shí)可以起到一定程度的優(yōu)化作用嫁蛇,但是在多線程下露该,似乎會導(dǎo)致一些問題:

public class Main {
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            if(b == 1) {
                if(a == 0) {
                    System.out.println("A");
                }else {
                    System.out.println("B");
                }   
            }
        }).start();
        new Thread(() -> {
            a = 1;
            b = 1;
        }).start();
    }
}

上面這段代碼,在正常情況下抑党,按照我們的正常思維,是不可能輸出A的新荤,因?yàn)橹灰猙等于1,那么a肯定也是1才對篱瞎,因?yàn)閍是在b之前完成的賦值痒芝。

但是,如果進(jìn)行了重排序严衬,那么就有可能,a和b的賦值發(fā)生交換粱挡,b先被賦值為1俄精,而恰巧這個時候,線程1開始判定b是不是1了竖慧,這時a還沒來得及被賦值為1,可能線程1就已經(jīng)走到打印那里去了踱讨,所以砍的,是有可能輸出A的。

volatile關(guān)鍵字
關(guān)鍵字volatile廓鞠,開始之前我們先介紹三個詞語:

原子性:其實(shí)之前講過很多次了,就是要做什么事情要么做完翁锡,要么就不做夕土,不存在做一半的情況瘟判。
可見性:指當(dāng)多個線程訪問同一個變量時角溃,一個線程修改了這個變量的值,其他線程能夠立即看得到修改的值匆瓜。
有序性:即程序執(zhí)行的順序按照代碼的先后順序執(zhí)行未蝌。
我們之前說了,如果多線程訪問同一個變量萧吠,那么這個變量會被線程拷貝到自己的工作內(nèi)存中進(jìn)行操作,而不是直接對主內(nèi)存中的變量本體進(jìn)行操作拇砰。

下面這個操作看起來是一個有限循環(huán)狰腌,但是是無限的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("線程結(jié)束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;   //很明顯皂岔,按照我們的邏輯來說,a的值被修改那么另一個線程將不再循環(huán)
    }
}

實(shí)際上這就是我們之前說的,雖然我們主線程中修改了a的值剖毯,但是另一個線程并不知道a的值發(fā)生了改變,所以循環(huán)中依然是使用舊值在進(jìn)行判斷擂达,因此胶滋,普通變量是不具有可見性的。

要解決這種問題究恤,我們第一個想到的肯定是加鎖,同一時間只能有一個線程使用抄腔,這樣總行了吧,確實(shí)赫蛇,這樣的話肯定是可以解決問題的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0) {
                synchronized (Main.class){}
            }
            System.out.println("線程結(jié)束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        synchronized (Main.class){
            a = 1;
        }
    }
}

但是落蝙,除了硬加一把鎖的方案,我們也可以使用volatile關(guān)鍵字來解決筏勒。

此關(guān)鍵字的第一個作用粟誓,就是保證變量的可見性。

當(dāng)寫一個volatile變量時病瞳,JMM會把該線程本地內(nèi)存中的變量強(qiáng)制刷新到主內(nèi)存中去,并且這個寫會操作會導(dǎo)致其他線程中的volatile變量緩存無效套菜。

這樣设易,另一個線程修改了這個變時,當(dāng)前線程會立即得知戏溺,并將工作內(nèi)存中的變量更新為最新的版本。

那么我們就來試試看:

public class Main {
    //添加volatile關(guān)鍵字
    private static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("線程結(jié)束旷祸!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;
    }
}

結(jié)果還真的如我們所說的那樣讼昆,當(dāng)a發(fā)生改變時,循環(huán)立即結(jié)束闰围。

當(dāng)然既峡,雖然說volatile能夠保證可見性,但是不能保證原子性运敢,要解決我們上面的i++的問題么夫,可以使用加鎖來完成:

public class Main {
    private static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable r = () -> {
            for (int i = 0; i < 10000; i++) a++;
            System.out.println("任務(wù)完成档痪!");
        };
        new Thread(r).start();
        new Thread(r).start();

        //等待線程執(zhí)行完成
        Thread.sleep(1000);
        System.out.println(a);
    }
}

volatile不是能在改變變量的時候其他線程可見嗎邢滑,那為什么還是不能保證原子性呢?

還是那句話困后,自增操作是被瓜分為了多個步驟完成的,雖然保證了可見性汽绢,但是只要手速夠快,依然會出現(xiàn)兩個線程同時寫同一個值的問題(比如線程1剛剛將a的值更新為100宁昭,這時線程2可能也已經(jīng)執(zhí)行到更新a的值這條指令了酗宋,已經(jīng)剎不住車了,所以依然會將a的值再更新為一次100)

那要是真的遇到這種情況寂曹,那么我們不可能都去寫個鎖吧?后面隆圆,我們會介紹原子類來專門解決這種問題翔烁。

最后一個功能就是volatile會禁止指令重排,也就是說,如果我們操作的是一個volatile變量颊糜,它將不會出現(xiàn)重排序的情況.

那么它是怎么解決的重排序問題呢?

若用volatile修飾共享變量衬鱼,在編譯時,會在指令序列中插入內(nèi)存屏障來禁止特定類型的處理器重排序

內(nèi)存屏障(Memory Barrier)又稱內(nèi)存柵欄蒜胖,是一個CPU指令,它的作用有兩個:

保證特定操作的順序保證某些變量的內(nèi)存可見性(volatile的內(nèi)存可見性台谢,其實(shí)就是依靠這個實(shí)現(xiàn)的)

由于編譯器和處理器都能執(zhí)行指令重排的優(yōu)化,

如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU朋沮,

不管什么指令都不能和這條Memory Barrier指令重排序。

屏障類型指令示例說明LoadLoadLoad1;LoadLoad;Load2保證Load1的讀取操作在Load2及后續(xù)讀取操作之前執(zhí)行StoreStoreStore1;StoreStore;Store2在Store2及其后的寫操作執(zhí)行前纠亚,保證Store1的寫操作已刷新到主內(nèi)存LoadStoreLoad1;LoadStore;Store2在Store2及其后的寫操作執(zhí)行前筋夏,保證Load1的讀操作已讀取結(jié)束StoreLoadStore1;StoreLoad;Load2保證load1的寫操作已刷新到主內(nèi)存之后,load2及其后的讀操作才能執(zhí)行

所以volatile能夠保證条篷,之前的指令一定全部執(zhí)行,之后的指令一定都沒有執(zhí)行蚊锹,并且前面語句的結(jié)果對后面的語句可見稚瘾。

最后我們來總結(jié)一下volatile關(guān)鍵字的三個特性:

保證可見性
不保證原子性
防止指令重排
在之后我們的設(shè)計(jì)模式系列視頻中,還會講解單例模式下volatile的運(yùn)用摊欠。

happens-before原則
經(jīng)過我們前面的講解,相信各位已經(jīng)了解了JMM內(nèi)存模型以及重排序等機(jī)制帶來的優(yōu)點(diǎn)和缺點(diǎn).

綜上播瞳,JMM提出了happens-before(先行發(fā)生)原則免糕,定義一些禁止編譯優(yōu)化的場景,來向各位程序員做一些保證石窑,只要我們是按照原則進(jìn)行編程,那么就能夠保持并發(fā)編程的正確性松逊。具體如下:

程序次序規(guī)則:同一個線程中,按照程序的順序犀暑,前面的操作happens-before后續(xù)的任何操作。
同一個線程內(nèi)耐亏,代碼的執(zhí)行結(jié)果是有序的。
其實(shí)就是苹熏,可能會發(fā)生指令重排,但是保證代碼的執(zhí)行結(jié)果一定是和按照順序執(zhí)行得到的一致袱耽,
程序前面對某一個變量的修改一定對后續(xù)操作可見的,不可能會出現(xiàn)前面才把a(bǔ)修改為1朱巨,
接著讀a居然是修改前的結(jié)果枉长,這也是程序運(yùn)行最基本的要求。
監(jiān)視器鎖規(guī)則:對一個鎖的解鎖操作洪唐,happens-before后續(xù)對這個鎖的加鎖操作。
就是無論是在單線程環(huán)境還是多線程環(huán)境凭需,
對于同一個鎖來說肝匆,一個線程對這個鎖解鎖之后,另一個線程獲取了這個鎖都能看到前一個線程的操作結(jié)果旗国。
比如前一個線程將變量x的值修改為了12并解鎖,
之后另一個線程拿到了這把鎖度硝,對之前線程的操作是可見的寿冕,可以得到x是前一個線程修改后的結(jié)果12(所以synchronized是有happens-before規(guī)則的)
volatile變量規(guī)則:對一個volatile變量的寫操作happens-before后續(xù)對這個變量的讀操作。
就是如果一個線程先去寫一個volatile變量蚂斤,緊接著另一個線程去讀這個變量槐沼,那么這個寫操作的結(jié)果一定對讀的這個變量的線程可見捌治。
線程啟動規(guī)則:主線程A啟動線程B纽窟,線程B中可以看到主線程啟動B之前的操作。
在主線程A執(zhí)行過程中森枪,啟動子線程B楷扬,那么線程A在啟動子線程B之前對共享變量的修改結(jié)果對線程B可見享扔。
線程加入規(guī)則:如果線程A執(zhí)行操作join()線程B并成功返回式散,那么線程B中的任意操作happens-before線程Ajoin()操作成功返回打颤。
傳遞性規(guī)則:如果A happens-before B,B happens-before C编饺,那么A happens-before C。
那么我們來從happens-before原則的角度撕蔼,來解釋一下下面的程序結(jié)果:

public class Main {
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        a = 10;
        b = a + 1;
        new Thread(() -> {
          if(b > 10) System.out.println(a); 
        }).start();
    }
}

首先我們定義以上出現(xiàn)的操作:

A:將變量a的值修改為10
B:將變量b的值修改為a + 1
C:主線程啟動了一個新的線程石蔗,并在新的線程中獲取b,進(jìn)行判斷诉探,如果為true那么就打印a
首先我們來分析,由于是同一個線程肾胯,并且B是一個賦值操作且讀取了A耘纱,

那么按照程序次序規(guī)則,A happens-before B束析,接著在B之后,馬上執(zhí)行了C弄慰,按照線程啟動規(guī)則,

在新的線程啟動之前陆爽,當(dāng)前線程之前的所有操作對新的線程是可見的,

所以 B happens-before C别威,

最后根據(jù)傳遞性規(guī)則驴剔,由于A happens-before B,B happens-before C丧失,所以A happens-before C,因此在新的線程中會輸出a修改后的結(jié)果10科侈。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末炒事,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子挠乳,更是在濱河造成了極大的恐慌权薯,老刑警劉巖盟蚣,帶你破解...
    沈念sama閱讀 216,372評論 6 498
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件卖怜,死亡現(xiàn)場離奇詭異,居然都是意外死亡奄抽,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,368評論 3 392
  • 文/潘曉璐 我一進(jìn)店門逞度,熙熙樓的掌柜王于貴愁眉苦臉地迎上來妙啃,“玉大人,你說我怎么就攤上這事馆匿。” “怎么了甜熔?”我有些...
    開封第一講書人閱讀 162,415評論 0 353
  • 文/不壞的土叔 我叫張陵腔稀,是天一觀的道長羽历。 經(jīng)常有香客問我,道長秕磷,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,157評論 1 292
  • 正文 為了忘掉前任疏尿,我火速辦了婚禮易桃,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘晤郑。我一直安慰自己,他們只是感情好磕洪,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,171評論 6 388
  • 文/花漫 我一把揭開白布诫龙。 她就那樣靜靜地躺著,像睡著了一般签赃。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上晰绎,一...
    開封第一講書人閱讀 51,125評論 1 297
  • 那天括丁,我揣著相機(jī)與錄音,去河邊找鬼。 笑死仰税,一個胖子當(dāng)著我的面吹牛抽诉,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播迹淌,決...
    沈念sama閱讀 40,028評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼耙饰!你這毒婦竟也來了纹份?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,887評論 0 274
  • 序言:老撾萬榮一對情侶失蹤蔓涧,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后拨齐,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,310評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡瞻惋,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,533評論 2 332
  • 正文 我和宋清朗相戀三年歼狼,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片羽峰。...
    茶點(diǎn)故事閱讀 39,690評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡添瓷,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出鳞贷,到底是詐尸還是另有隱情,我是刑警寧澤惰聂,帶...
    沈念sama閱讀 35,411評論 5 343
  • 正文 年R本政府宣布,位于F島的核電站搓幌,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏溉愁。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,004評論 3 325
  • 文/蒙蒙 一撤蟆、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦爵川、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽碟案。三九已至颇蜡,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間风秤,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,812評論 1 268
  • 我被黑心中介騙來泰國打工领迈, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人狸捅。 一個月前我還...
    沈念sama閱讀 47,693評論 2 368
  • 正文 我出身青樓累提,卻偏偏與公主長得像,于是被迫代替她去往敵國和親斋陪。 傳聞我的和親對象是個殘疾皇子扯夭,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,577評論 2 353

推薦閱讀更多精彩內(nèi)容