再談多線程
在我們的操作系統(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>
順序執(zhí)行
順序執(zhí)行其實(shí)很好理解突委,就是我們依次去將這些任務(wù)完成了:
實(shí)際上就是我們同一時間只能處理一個任務(wù)鸯两,所以需要前一個任務(wù)完成之后长豁,才能繼續(xù)下一個任務(wù),依次完成所有任務(wù)钝侠。
并發(fā)執(zhí)行
并發(fā)執(zhí)行也是我們同一時間只能處理一個任務(wù)酸舍,但是我們可以每個任務(wù)輪著做(時間片輪轉(zhuǎn)):
而我們Java中的線程,正是這種機(jī)制忽舟,當(dāng)我們需要同時處理上百個上千個任務(wù)時淮阐,很明顯CPU的數(shù)量是不可能趕得上我們的線程數(shù)的,所以說這時就要求我們的程序有良好的并發(fā)性能浩姥,來應(yīng)對同一時間大量的任務(wù)處理状您。
學(xué)習(xí)Java并發(fā)編程兜挨,能夠讓我們在以后的實(shí)際場景中拌汇,知道該如何應(yīng)對高并發(fā)的情況颗搂。
并行執(zhí)行
并行執(zhí)行就突破了同一時間只能處理一個任務(wù)的限制幕垦,我們同一時間可以做多個任務(wù):
比如我們要進(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é)碼之后會用到哪些指令:
其中最關(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)行下去。
實(shí)際上synchronized使用的鎖就是存儲在Java對象頭中的召烂,我們知道娃承,對象是存放在堆內(nèi)存中的,而每個對象內(nèi)部酗昼,都有一部分空間用于存儲對象頭信息梳猪。
而對象頭信息中,則包含了Mark Word用于存放hashCode和對象的鎖信息呛哟,在不同狀態(tài)下匿沛,它存儲的數(shù)據(jù)結(jié)構(gòu)有一些不同。
重量級鎖
在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ī)制:
設(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ī)制喜德。
在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í)行(注意鎖的膨脹是不可逆的)
如圖所示:
所以茬缩,輕量級鎖 -> 失敗 -> 自適應(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ù)留位置保存)中览效。
鎖消除和鎖粗化
鎖消除和鎖粗化都是在運(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)容一致呢躏嚎?
為了解決緩存一致性的問題,需要各個處理器訪問緩存時都遵循一些協(xié)議重荠,在讀寫時要根據(jù)協(xié)議來進(jìn)行操作虚茶。
這類協(xié)議有MSI、MESI(Illinois Protocol)荞彼、MOSI待笑、Synapse、Firefly及Dragon Protocol等暮蹂。
而Java也采用了類似的模型來實(shí)現(xiàn)支持多線程的內(nèi)存模型:
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í)際上并不是由一條指令完成的(注意一定不要理解為一行代碼就是一個指令完成的):
包括變量i的獲取、修改咽筋、保存徊件,都是被拆分為一個一個的操作完成的,那么這個時候就有可能出現(xiàn)在修改完保存之前虱痕,另一條線程也保存了,但是當(dāng)前線程是毫不知情的硝训。
所以說,在JavaSE階段講解這個問題的時候赘风,是通過synchronized關(guān)鍵字添加同步代碼塊解決的纵刘,另外的解決方案(原子類)。
重排序
在編譯或執(zhí)行時假哎,為了優(yōu)化程序的執(zhí)行效率,編譯器或處理器常常會對指令進(jìn)行重排序肪虎,有以下情況:
- 編譯器重排序:Java編譯器通過對Java代碼語義的理解惧蛹,根據(jù)優(yōu)化規(guī)則對代碼指令進(jìn)行重排序。
- 機(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科侈。