線程安全
線程安全浪册,耳熟能詳惠豺,但想準(zhǔn)確的描述并不容易挣轨。這里借用《Java Concurrency In Practice》作者Brian Goetz對(duì)其的一個(gè)定義:“當(dāng)多個(gè)線程訪問一個(gè)對(duì)象時(shí)抛寝,如果不用考慮這些線程在運(yùn)行時(shí)環(huán)境下的調(diào)度和交替執(zhí)行涨颜,也不需要額外的同步,或者再調(diào)用方法進(jìn)行任何其他的協(xié)調(diào)操作依沮,調(diào)用這個(gè)對(duì)象的行為就可以獲得正確的結(jié)果涯贞,那這個(gè)對(duì)象是線程安全的”
這個(gè)定義比較完整枪狂,它要求線程安全的代碼需要具備一個(gè)特征:代碼本身封裝了所有必要的正確性保障手段,令調(diào)用者無需關(guān)心多線程問題宋渔,更無須調(diào)用者自行采取任何措施來保證多線程環(huán)境下的正確使用
Java語言中的線程安全
上面是關(guān)于線程安全的一個(gè)抽象定義州疾,下面來看一下在Java語言中,線程安全是如何體現(xiàn)的皇拣。嚴(yán)格來說严蓖,線程安全并非一個(gè)非真即假的一個(gè)命題。按照線程安全的程度由強(qiáng)至弱氧急,可以分為5類:
(1)不可變
在上一章(Java內(nèi)存模型與線程)中颗胡,我們?cè)谟懻揻inal關(guān)鍵字時(shí)提到過這點(diǎn),只要一個(gè)不可變的對(duì)象被正確構(gòu)建出來吩坝,那其外部的可見狀態(tài)就不會(huì)改變毒姨,因此無論是對(duì)象方法的實(shí)現(xiàn)還是方法的調(diào)用者,都不需要再采取任何線程安全的保障措施
Java語言中钉寝,如果共享數(shù)據(jù)是一個(gè)基本數(shù)據(jù)類型弧呐,那么只要通過final來修飾就可以保證它不可變。如果共享數(shù)據(jù)是一個(gè)對(duì)象嵌纲,那就需要保證對(duì)象的行為不會(huì)對(duì)其狀態(tài)產(chǎn)生任何影響俘枫。保證對(duì)象的行為不會(huì)對(duì)其狀態(tài)產(chǎn)生影響的途徑有很多種,最簡(jiǎn)單的是將其內(nèi)部的狀態(tài)變量全部定義為final逮走,這樣在構(gòu)造方法結(jié)束之后鸠蚪,它就是不可變的。Java中最典型的不可變對(duì)象就是String言沐,還有Integer等基本數(shù)據(jù)的包裝類型
(2)絕對(duì)線程安全
絕對(duì)線程安全完全滿足本篇開頭Brian Goetz給出的線程安全的定義。這種定義十分嚴(yán)格酣栈,要達(dá)到這個(gè)要求通常需要付出很大代價(jià)险胰,有些時(shí)候甚至不切實(shí)際。Java中線程安全的類矿筝,大多數(shù)都不是絕對(duì)線程安全的起便,比如下面這個(gè)例子:
Vector大家都不陌生,是一個(gè)線程安全的容器窖维,它的add()榆综、remove()、size()和get()方法都是synchronized的铸史。即便如此鼻疮,并不等同于在任何適用場(chǎng)景下都不需要額外的同步手段了
上例中我們初始化一個(gè)Vector,之后通過不同的線程從Vector中移除元素并遍歷打印剩余的元素琳轿,運(yùn)行結(jié)果會(huì)拋出ArrayIndexOutOfBoundsException判沟。其中原因相信大家已經(jīng)看出耿芹,其中一種修復(fù)辦法就是分別將整個(gè)移除元素及整個(gè)打印剩余元素的動(dòng)作標(biāo)記為synchronized
(3)相對(duì)線程安全
相對(duì)線程安全就是我們“最常見”的線程安全,Java中大多數(shù)線程安全的類都屬于這種類型挪哄,它可以保證這個(gè)對(duì)象單獨(dú)的操作是線程安全的吧秕。但是如果對(duì)于一些特定的操作組合,仍然有可能需要調(diào)用方通過額外的同步手段進(jìn)行保障(比如上例)
(4)線程兼容
線程兼容是指對(duì)象本身并不是線程安全的迹炼,但是調(diào)用方通過額外的同步手段進(jìn)行保障砸彬,Java中大多數(shù)類都屬于這種類型。我們平常說的一個(gè)類不是線程安全的斯入,絕大多數(shù)情況都屬于這種情況
(5)線程對(duì)立
線程對(duì)立是指無論調(diào)用方是否采取了同步措施砂碉,都無法在多線程環(huán)境中并發(fā)使用的情況。Java中這種情況并不常見咱扣,比較典型的一個(gè)例子是Thread類的suspend()和resume()方法绽淘。這兩個(gè)方法早已被標(biāo)記為Deprecated,原因是會(huì)導(dǎo)致死鎖
線程安全的實(shí)現(xiàn)方法
(1)互斥同步(Mutual Exclusion & Synchronization)
同步只是多個(gè)線程并發(fā)訪問共享數(shù)據(jù)時(shí)闹伪,保證共享數(shù)據(jù)在同一時(shí)刻只被一個(gè)或者是一些(使用信號(hào)量時(shí))線程使用沪铭。互斥是實(shí)現(xiàn)同步的手段之一
Java中最基本的互斥同步手段是synchronized偏瓤,經(jīng)過編譯后杀怠,synchronized會(huì)被轉(zhuǎn)變?yōu)閙onitorenter和monitorexit字節(jié)碼指令,這兩條指令分別位于同步塊的前后
在執(zhí)行monitorenter指令時(shí)厅克,會(huì)嘗試獲取對(duì)象的鎖赔退,如果對(duì)象沒有被鎖定或者當(dāng)前線程已經(jīng)擁有了這個(gè)對(duì)象的鎖,那么就會(huì)把對(duì)應(yīng)鎖的計(jì)數(shù)器加1证舟。如果獲取鎖失敗硕旗,那么這個(gè)線程會(huì)被阻塞,直到鎖被釋放女责。相對(duì)應(yīng)的漆枚,在執(zhí)行monitorexit時(shí),會(huì)將計(jì)數(shù)器減1抵知,當(dāng)計(jì)數(shù)器值為0時(shí)墙基,鎖會(huì)被釋放
synchronized同步塊對(duì)于同一個(gè)線程是可重入的,另外在鎖釋放之前刷喜,會(huì)阻塞其他嘗試獲取相同鎖的線程残制。Java線程是映射到操作系統(tǒng)原生線程之上的,阻塞和喚醒線程都需要在用戶態(tài)和內(nèi)核態(tài)之間切換掖疮,這個(gè)過程需要耗費(fèi)處理器時(shí)間初茶。對(duì)于代碼簡(jiǎn)單的同步塊,狀態(tài)切換消耗的時(shí)間很可能比同步塊內(nèi)代碼執(zhí)行時(shí)間還要長(zhǎng)浊闪。針對(duì)這種情況纺蛆,虛擬機(jī)會(huì)做一些優(yōu)化吐葵,比如自旋等待
除了synchronized,ReentrantLock也可以實(shí)現(xiàn)同步桥氏,他們都具備可重入性温峭,但是ReentrantLock增加了一些高級(jí)特性:
等待可中斷:是指當(dāng)持有鎖的線程長(zhǎng)期不釋放鎖的時(shí)候,正在等待的線程可以選擇放棄等待改為處理其他事情字支。這個(gè)特性對(duì)于執(zhí)行時(shí)間非常長(zhǎng)的同步塊很有幫助
公平鎖:是指多個(gè)線程在等待同一個(gè)鎖時(shí)凤藏,必須按照按照申請(qǐng)鎖的時(shí)間順序依次獲得鎖。而非公平鎖在被釋放時(shí)堕伪,任何一個(gè)線程都有機(jī)會(huì)獲得該鎖揖庄。ReentrantLock默認(rèn)提供非公平方式,但是可以通過帶布爾值的構(gòu)造方法要求使用公平鎖
鎖可綁定多個(gè)條件:是指一個(gè)ReentrantLock對(duì)象可以同時(shí)綁定多個(gè)Condition對(duì)象欠雌。而synchronized僅有一個(gè)隱含的條件蹄梢,當(dāng)需要多條件時(shí),則需要額外添加鎖
如果需要使用到上述這些高級(jí)功能富俄,ReentrantLock是很好的選擇禁炒。很多人一直以來對(duì)synchronized的性能嗤之以鼻,但是在JDK 1.6之后霍比,synchronized得到了相當(dāng)多的針對(duì)性優(yōu)化幕袱,性能得到了可觀的提升。因此悠瞬,對(duì)于JDK 1.6及其之后的使用環(huán)境们豌,性能因素不再是選擇ReentrantLock的理由了。對(duì)于基本使用場(chǎng)景浅妆,相對(duì)于ReentrantLock在Java語法層面提供的鎖定語義望迎,synchronized在字節(jié)碼層面的支持顯然更易于使用(ReentrantLock需要使用者結(jié)合try finally顯式加解鎖,synchronized則無需這么做)
(2)非阻塞同步(Non-Blocking Synchronization)
互斥同步實(shí)際上是阻塞同步(Blocking Synchronization)凌外,屬于一種悲觀并發(fā)策略辩尊,認(rèn)為只要不做同步就一定會(huì)出問題,它最大的問題就是線程阻塞和喚醒所帶來的性能問題趴乡。但是隨著硬件的發(fā)展(操作和沖突檢測(cè)這兩個(gè)操作需要依賴底層硬件提供原子性)对省,我們有了另一種選擇:基于沖突檢測(cè)的樂觀并發(fā)策略蝗拿。通俗來講晾捏,就是先進(jìn)行操作,如果沒有發(fā)生數(shù)據(jù)爭(zhēng)用哀托,那么操作就成功了惦辛;如果有其他線程發(fā)生了數(shù)據(jù)爭(zhēng)用,產(chǎn)生了沖突仓手,那么就采取其他補(bǔ)救措施(最常見的一種方式就是不斷重試胖齐,直到成功為止)玻淑,這種樂觀并發(fā)策略通常都不需要把線程掛起,因此稱為非阻塞同步
CAS(Compare-And-Swap)指令就是非阻塞同步的一種實(shí)現(xiàn)呀伙,它需要三個(gè)操作數(shù)补履,分別是內(nèi)存地址V、舊的預(yù)期值A(chǔ)剿另、新值B箫锤。當(dāng)且僅當(dāng)V地址上的值等于A時(shí),才將該地址上的值設(shè)置為B雨女,否則不進(jìn)行更新谚攒。上述過程是一個(gè)原子操作。在Java中氛堕,CAS操作被封裝在sun.misc.Unsafe類中馏臭,該類僅允許啟動(dòng)類加載器(Bootstrap ClassLoader)加載的類才能使用,不過卻提供了更高級(jí)的API供開發(fā)者使用讼稚,這些原子類位于java.util.concurrent.atomic包中
上一篇系列文章(Java內(nèi)存模型與線程)中提到的并發(fā)下的a++操作括儒,就可以通過原子類很容易的實(shí)現(xiàn)
但是CAS操作有一個(gè)明顯的缺陷,就是著名的ABA問題乱灵。所謂ABA問題塑崖,就是當(dāng)變量V初始值為A,并且在準(zhǔn)備賦值時(shí)檢查當(dāng)前值依然是A痛倚,那么我們并不能得出“在此期間V的值沒有被改變過”這樣的結(jié)論规婆,因?yàn)樵诖似陂gV的值可能從A變成了B,之后又變回了A蝉稳。為了解決這個(gè)問題抒蚜,JDK提供了AtomicStampedReference,它可以通過控制變量值的版本來解決ABA問題耘戚。不過實(shí)際場(chǎng)景中嗡髓,大多數(shù)ABA問題,可能并不會(huì)影響程序的正確性
鎖優(yōu)化
針對(duì)高效并發(fā)收津,HotSpot虛擬機(jī)實(shí)現(xiàn)了很多鎖優(yōu)化技術(shù)饿这,下面對(duì)其中一些做簡(jiǎn)單介紹
(1)自旋鎖與自適應(yīng)自旋
前面提到的互斥同步,其性能最大的影響在于阻塞撞秋。掛起和恢復(fù)線程需要在用戶態(tài)和內(nèi)核態(tài)之間切換长捧。但是在大多數(shù)情形下,共享數(shù)據(jù)的鎖定狀態(tài)通常只會(huì)持續(xù)很短一段時(shí)間吻贿,為了這很短暫的鎖定時(shí)間去掛起和恢復(fù)線程并不值當(dāng)串结。因此,可以嘗試讓后面請(qǐng)求鎖的線程稍等一下,但是并不放棄CPU時(shí)間肌割,觀察持有鎖的線程是否很快會(huì)釋放鎖卧蜓,這項(xiàng)技術(shù)就是自旋鎖
自旋鎖的局限性也顯而易見,就是當(dāng)持有鎖的線程需要執(zhí)行很長(zhǎng)一段時(shí)間的時(shí)候把敞,后續(xù)嘗試獲取鎖的線程將陷入長(zhǎng)時(shí)間自旋弥奸,而自旋依然需要占用CPU時(shí)間。因此奋早,對(duì)于這種場(chǎng)景并不適用
針對(duì)這種情況其爵,引入了自適應(yīng)自旋技術(shù)。自適應(yīng)自旋意味著自旋時(shí)間不再是固定不變的伸蚯,而是由歷史數(shù)據(jù)來決定:如果在同一個(gè)鎖對(duì)象上摩渺,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運(yùn)行中剂邮,那么虛擬機(jī)就認(rèn)為這次自旋有很大概率再次成功摇幻,因此會(huì)適當(dāng)增加自旋等待時(shí)間。相反挥萌,如果對(duì)于某個(gè)鎖绰姻,自旋很少成功,那么后續(xù)的自旋過程將被去除引瀑。有了這項(xiàng)技術(shù)狂芋,隨著程序運(yùn)行時(shí)間的積累,性能監(jiān)控?cái)?shù)據(jù)將會(huì)不斷完善憨栽,預(yù)測(cè)也將越來越準(zhǔn)確
(2)鎖消除
鎖消除是指一些代碼雖然要求同步帜矾,但是實(shí)際上不可能存在共享數(shù)據(jù)爭(zhēng)用,針對(duì)這種情況對(duì)鎖進(jìn)行消除屑柔。鎖消除的判定依據(jù)主要來源于逃逸分析(關(guān)于逃逸分析屡萤,可以參看本系列文章:運(yùn)行期優(yōu)化)
那么為什么會(huì)存在明明沒有共享數(shù)據(jù)爭(zhēng)用卻又使用同步的情況呢?原因在于很多同步并非開發(fā)者自己加入掸宛,比較典型的一個(gè)事例出現(xiàn)在JDK 1.5之前:對(duì)于字符串拼接操作死陆,編譯器會(huì)轉(zhuǎn)化為StringBuffer的append操作,而append方法顯然是synchronized的
(3)鎖粗化
通常情況下唧瘾,我們?cè)诰幊踢^程中措译,會(huì)盡可能把加鎖的范圍縮小,目的是讓其他線程同步等待時(shí)間盡可能短饰序。但是如果一系列的操作都是對(duì)同一個(gè)對(duì)象反復(fù)加鎖解鎖领虹,甚至同步操作位于循環(huán)體中,那么在同一個(gè)對(duì)象上如此頻繁的同步操作也會(huì)帶來顯著的性能影響
鎖粗化就是為了解決這個(gè)問題菌羽,虛擬機(jī)在檢測(cè)到上述這種情況時(shí)掠械,會(huì)將加鎖同步的范圍擴(kuò)大(粗化)到整個(gè)操作序列之外,這樣只需要加一次鎖就可以了
(4)輕量級(jí)鎖
傳統(tǒng)使用互斥量實(shí)現(xiàn)的鎖稱為“重量級(jí)鎖”注祖,在JDK 1.6中引入另一種優(yōu)化機(jī)制猾蒂,它能夠在沒有多線程競(jìng)爭(zhēng)的時(shí)候,通過CAS操作降低傳統(tǒng)重量級(jí)鎖帶來的性能損耗是晨,這種優(yōu)化機(jī)制稱為“輕量級(jí)鎖”
輕量級(jí)鎖肚菠、偏向鎖,乃至重量級(jí)鎖都依賴對(duì)象頭(Object Header)實(shí)現(xiàn)罩缴,因此有必要對(duì)對(duì)象頭的內(nèi)存布局做下簡(jiǎn)單介紹蚊逢。Hot Spot虛擬機(jī)的對(duì)象頭主要分為兩部分:第一部分用于存儲(chǔ)對(duì)象自身的運(yùn)行時(shí)數(shù)據(jù),如HashCode箫章、GC分代年齡烙荷、鎖相關(guān)信息等,這部分稱為“Mark Word”檬寂,它是實(shí)現(xiàn)鎖的關(guān)鍵终抽;另一部分存儲(chǔ)對(duì)象類型的指針,如果是數(shù)組桶至,還會(huì)存儲(chǔ)數(shù)組長(zhǎng)度
而對(duì)于Mark Word昼伴,會(huì)根據(jù)對(duì)象的不同狀態(tài),復(fù)用空間存儲(chǔ)相應(yīng)的數(shù)據(jù)镣屹,如下圖:
下面介紹一下輕量級(jí)鎖的執(zhí)行過程:
在進(jìn)入同步塊的時(shí)候圃郊,如果此時(shí)同步對(duì)象沒有被鎖定,虛擬機(jī)首先會(huì)在當(dāng)前線程的棧幀中建立一個(gè)鎖記錄(Lock Record)用于存儲(chǔ)對(duì)象目前的Mark Word拷貝(稱為Displaced Mark Word)
隨后女蜈,通過CAS操作嘗試將對(duì)象的Mark Word更新為指向Lock Record的指針持舆。如果更新成功,那么線程就擁有了該對(duì)象的鎖伪窖,并且該對(duì)象的Mark Word標(biāo)志位被更新為00吏廉,此時(shí)即表示對(duì)象處于輕量級(jí)鎖定狀態(tài)
如果更新失敗,會(huì)首先檢查Mark Word是否指向當(dāng)前線程的棧幀惰许,如果是則說明當(dāng)前線程已經(jīng)擁有了該對(duì)象的鎖席覆,那么就直接進(jìn)入同步塊繼續(xù)執(zhí)行,否則說明鎖已經(jīng)被其他線程占有汹买。如果存在多個(gè)線程爭(zhēng)用同一個(gè)對(duì)象的鎖佩伤,那么輕量級(jí)鎖就不再適用,需要升級(jí)為重量級(jí)鎖晦毙,標(biāo)志位將更新為10生巡,Mark Word中存儲(chǔ)的就是重量級(jí)鎖的指針,其他等待鎖的線程將被阻塞
上面是加鎖過程见妒,解鎖過程也是通過CAS完成的:如果對(duì)象的Mark Word仍然指向線程的Lock Record孤荣,那就通過CAS把對(duì)象當(dāng)前的Mark Word和線程中的Displaced Mark Word替換回來,如果成功,同步過程就完成了盐股,如果失敗钱豁,說明有其他線程嘗試過獲取該鎖,那么在釋放鎖的同時(shí)疯汁,需要喚醒其他被掛起的線程
(5)偏向鎖
在數(shù)據(jù)無爭(zhēng)用的情況下牲尺,輕量級(jí)鎖通過CAS操作去除同步所使用的互斥量,而偏向鎖則更進(jìn)一步幌蚊,就是在數(shù)據(jù)無爭(zhēng)用的情況下把整個(gè)同步都消除掉谤碳。簡(jiǎn)單來說,偏向鎖溢豆,就是會(huì)偏向第一個(gè)獲取它的線程蜒简,如果在后續(xù)的執(zhí)行中,該鎖一直沒有被其他線程獲取漩仙,那么持有偏向鎖的這個(gè)線程將不再需要進(jìn)行同步
偏向鎖同樣依賴Mark Word實(shí)現(xiàn)臭蚁,大致原理是:
當(dāng)鎖對(duì)象第一次被線程獲取的時(shí)候,對(duì)象頭中標(biāo)志位將被設(shè)為01讯赏,同時(shí)嘗試把獲取到這個(gè)鎖的線程ID通過CAS操作記錄在Mark Word中垮兑,如果成功,持有偏向鎖的這個(gè)線程在后續(xù)進(jìn)入被該鎖保護(hù)的同步塊時(shí)將不再需要任何同步操作
當(dāng)有另外的線程嘗試獲取這個(gè)鎖時(shí)漱挎,偏向模式宣告結(jié)束系枪。根據(jù)鎖對(duì)象是否處于被鎖定狀態(tài),狀態(tài)將被恢復(fù)到未鎖定或者輕量級(jí)鎖
偏向鎖和輕量級(jí)鎖都有助于提高帶有同步但實(shí)際無爭(zhēng)用時(shí)程序的性能磕谅,它們有適用的場(chǎng)景私爷,但也都有局限性,不一定總是能夠起到正面作用
思維導(dǎo)圖:
筆記9結(jié)束
至此膊夹,本系列告一段落衬浑,按照計(jì)劃在2018春節(jié)前坎坷完成。好腦子不如爛筆頭放刨,幫助到自己的同時(shí)工秩,也希望可以對(duì)大家有所幫助。后續(xù)還有其他讀書計(jì)劃进统,屆時(shí)再開新篇助币。祝大家新年快樂,謝謝大家??