溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

【基本功】不可不說(shuō)的Java“鎖”事

發(fā)布時(shí)間:2020-08-10 19:50:37 來(lái)源:ITPUB博客 閱讀:117 作者:美團(tuán)技術(shù)團(tuán)隊(duì) 欄目:編程語(yǔ)言

并發(fā)編程是Java程序員必備基本功,今天“基本功”專欄向大家推薦一篇深入解析Java鎖機(jī)制的文章。Enjoy!

前言

Java提供了種類豐富的鎖,每種鎖因其特性的不同,在適當(dāng)?shù)膱?chǎng)景下能夠展現(xiàn)出非常高的效率。本文旨在對(duì)鎖相關(guān)源碼(本文中的源碼來(lái)自JDK 8)、使用場(chǎng)景進(jìn)行舉例,為讀者介紹主流鎖的知識(shí)點(diǎn),以及不同的鎖的適用場(chǎng)景。

Java中往往是按照是否含有某一特性來(lái)定義鎖,我們通過(guò)特性將鎖進(jìn)行分組歸類,再使用對(duì)比的方式進(jìn)行介紹,幫助大家更快捷的理解相關(guān)知識(shí)。下面給出本文內(nèi)容的總體分類目錄:

【基本功】不可不說(shuō)的Java“鎖”事

1. 樂(lè)觀鎖 VS 悲觀鎖

樂(lè)觀鎖與悲觀鎖是一種廣義上的概念,體現(xiàn)了看待線程同步的不同角度。在Java和數(shù)據(jù)庫(kù)中都有此概念對(duì)應(yīng)的實(shí)際應(yīng)用。

先說(shuō)概念。對(duì)于同一個(gè)數(shù)據(jù)的并發(fā)操作,悲觀鎖認(rèn)為自己在使用數(shù)據(jù)的時(shí)候一定有別的線程來(lái)修改數(shù)據(jù),因此在獲取數(shù)據(jù)的時(shí)候會(huì)先加鎖,確保數(shù)據(jù)不會(huì)被別的線程修改。Java中,synchronized關(guān)鍵字和Lock的實(shí)現(xiàn)類都是悲觀鎖。

而樂(lè)觀鎖認(rèn)為自己在使用數(shù)據(jù)時(shí)不會(huì)有別的線程修改數(shù)據(jù),所以不會(huì)添加鎖,只是在更新數(shù)據(jù)的時(shí)候去判斷之前有沒(méi)有別的線程更新了這個(gè)數(shù)據(jù)。如果這個(gè)數(shù)據(jù)沒(méi)有被更新,當(dāng)前線程將自己修改的數(shù)據(jù)成功寫(xiě)入。如果數(shù)據(jù)已經(jīng)被其他線程更新,則根據(jù)不同的實(shí)現(xiàn)方式執(zhí)行不同的操作(例如報(bào)錯(cuò)或者自動(dòng)重試)。

樂(lè)觀鎖在Java中是通過(guò)使用無(wú)鎖編程來(lái)實(shí)現(xiàn),最常采用的是CAS算法,Java原子類中的遞增操作就通過(guò)CAS自旋實(shí)現(xiàn)的。

【基本功】不可不說(shuō)的Java“鎖”事

根據(jù)從上面的概念描述我們可以發(fā)現(xiàn):

  • 悲觀鎖適合寫(xiě)操作多的場(chǎng)景,先加鎖可以保證寫(xiě)操作時(shí)數(shù)據(jù)正確。

  • 樂(lè)觀鎖適合讀操作多的場(chǎng)景,不加鎖的特點(diǎn)能夠使其讀操作的性能大幅提升。

光說(shuō)概念有些抽象,我們來(lái)看下樂(lè)觀鎖和悲觀鎖的調(diào)用方式示例:

【基本功】不可不說(shuō)的Java“鎖”事

通過(guò)調(diào)用方式示例,我們可以發(fā)現(xiàn)悲觀鎖基本都是在顯式的鎖定之后再操作同步資源,而樂(lè)觀鎖則直接去操作同步資源。那么,為何樂(lè)觀鎖能夠做到不鎖定同步資源也可以正確的實(shí)現(xiàn)線程同步呢?我們通過(guò)介紹樂(lè)觀鎖的主要實(shí)現(xiàn)方式 “CAS” 的技術(shù)原理來(lái)為大家解惑。

CAS全稱 Compare And Swap(比較與交換),是一種無(wú)鎖算法。在不使用鎖(沒(méi)有線程被阻塞)的情況下實(shí)現(xiàn)多線程之間的變量同步。java.util.concurrent包中的原子類就是通過(guò)CAS來(lái)實(shí)現(xiàn)了樂(lè)觀鎖。

CAS算法涉及到三個(gè)操作數(shù):

  • 需要讀寫(xiě)的內(nèi)存值 V。

  • 進(jìn)行比較的值 A。

  • 要寫(xiě)入的新值 B。

當(dāng)且僅當(dāng) V 的值等于 A 時(shí),CAS通過(guò)原子方式用新值B來(lái)更新V的值(“比較+更新”整體是一個(gè)原子操作),否則不會(huì)執(zhí)行任何操作。一般情況下,“更新”是一個(gè)不斷重試的操作。

之前提到j(luò)ava.util.concurrent包中的原子類,就是通過(guò)CAS來(lái)實(shí)現(xiàn)了樂(lè)觀鎖,那么我們進(jìn)入原子類AtomicInteger的源碼,看一下AtomicInteger的定義:

【基本功】不可不說(shuō)的Java“鎖”事

根據(jù)定義我們可以看出各屬性的作用:

  • unsafe: 獲取并操作內(nèi)存的數(shù)據(jù)。

  • valueOffset: 存儲(chǔ)value在AtomicInteger中的偏移量。

  • value: 存儲(chǔ)AtomicInteger的int值,該屬性需要借助volatile關(guān)鍵字保證其在線程間是可見(jiàn)的。

接下來(lái),我們查看AtomicInteger的自增函數(shù)incrementAndGet()的源碼時(shí),發(fā)現(xiàn)自增函數(shù)底層調(diào)用的是unsafe.getAndAddInt()。但是由于JDK本身只有Unsafe.class,只通過(guò)class文件中的參數(shù)名,并不能很好的了解方法的作用,所以我們通過(guò)OpenJDK 8 來(lái)查看Unsafe的源碼:

【基本功】不可不說(shuō)的Java“鎖”事

根據(jù)OpenJDK 8的源碼我們可以看出,getAndAddInt()循環(huán)獲取給定對(duì)象o中的偏移量處的值v,然后判斷內(nèi)存值是否等于v。如果相等則將內(nèi)存值設(shè)置為 v + delta,否則返回false,繼續(xù)循環(huán)進(jìn)行重試,直到設(shè)置成功才能退出循環(huán),并且將舊值返回。整個(gè)“比較+更新”操作封裝在compareAndSwapInt()中,在JNI里是借助于一個(gè)CPU指令完成的,屬于原子操作,可以保證多個(gè)線程都能夠看到同一個(gè)變量的修改值。

后續(xù)JDK通過(guò)CPU的cmpxchg指令,去比較寄存器中的 A 和 內(nèi)存中的值 V。如果相等,就把要寫(xiě)入的新值 B 存入內(nèi)存中。如果不相等,就將內(nèi)存值 V 賦值給寄存器中的值 A。然后通過(guò)Java代碼中的while循環(huán)再次調(diào)用cmpxchg指令進(jìn)行重試,直到設(shè)置成功為止。

CAS雖然很高效,但是它也存在三大問(wèn)題,這里也簡(jiǎn)單說(shuō)一下:

1. ABA問(wèn)題。CAS需要在操作值的時(shí)候檢查內(nèi)存值是否發(fā)生變化,沒(méi)有發(fā)生變化才會(huì)更新內(nèi)存值。但是如果內(nèi)存值原來(lái)是A,后來(lái)變成了B,然后又變成了A,那么CAS進(jìn)行檢查時(shí)會(huì)發(fā)現(xiàn)值沒(méi)有發(fā)生變化,但是實(shí)際上是有變化的。ABA問(wèn)題的解決思路就是在變量前面添加版本號(hào),每次變量更新的時(shí)候都把版本號(hào)加一,這樣變化過(guò)程就從“A-B-A”變成了“1A-2B-3A”。

JDK從1.5開(kāi)始提供了AtomicStampedReference類來(lái)解決ABA問(wèn)題,具體操作封裝在compareAndSet()中。compareAndSet()首先檢查當(dāng)前引用和當(dāng)前標(biāo)志與預(yù)期引用和預(yù)期標(biāo)志是否相等,如果都相等,則以原子方式將引用值和標(biāo)志的值設(shè)置為給定的更新值。

2. 循環(huán)時(shí)間長(zhǎng)開(kāi)銷大。CAS操作如果長(zhǎng)時(shí)間不成功,會(huì)導(dǎo)致其一直自旋,給CPU帶來(lái)非常大的開(kāi)銷。

3. 只能保證一個(gè)共享變量的原子操作。對(duì)一個(gè)共享變量執(zhí)行操作時(shí),CAS能夠保證原子操作,但是對(duì)多個(gè)共享變量操作時(shí),CAS是無(wú)法保證操作的原子性的。

Java從1.5開(kāi)始JDK提供了AtomicReference類來(lái)保證引用對(duì)象之間的原子性,可以把多個(gè)變量放在一個(gè)對(duì)象里來(lái)進(jìn)行CAS操作。

2. 自旋鎖 VS 適應(yīng)性自旋鎖

在介紹自旋鎖前,我們需要介紹一些前提知識(shí)來(lái)幫助大家明白自旋鎖的概念。

阻塞或喚醒一個(gè)Java線程需要操作系統(tǒng)切換CPU狀態(tài)來(lái)完成,這種狀態(tài)轉(zhuǎn)換需要耗費(fèi)處理器時(shí)間。如果同步代碼塊中的內(nèi)容過(guò)于簡(jiǎn)單,狀態(tài)轉(zhuǎn)換消耗的時(shí)間有可能比用戶代碼執(zhí)行的時(shí)間還要長(zhǎng)。

在許多場(chǎng)景中,同步資源的鎖定時(shí)間很短,為了這一小段時(shí)間去切換線程,線程掛起和恢復(fù)現(xiàn)場(chǎng)的花費(fèi)可能會(huì)讓系統(tǒng)得不償失。如果物理機(jī)器有多個(gè)處理器,能夠讓兩個(gè)或以上的線程同時(shí)并行執(zhí)行,我們就可以讓后面那個(gè)請(qǐng)求鎖的線程不放棄CPU的執(zhí)行時(shí)間,看看持有鎖的線程是否很快就會(huì)釋放鎖。

而為了讓當(dāng)前線程“稍等一下”,我們需讓當(dāng)前線程進(jìn)行自旋,如果在自旋完成后前面鎖定同步資源的線程已經(jīng)釋放了鎖,那么當(dāng)前線程就可以不必阻塞而是直接獲取同步資源,從而避免切換線程的開(kāi)銷。這就是自旋鎖。

【基本功】不可不說(shuō)的Java“鎖”事


自旋鎖本身是有缺點(diǎn)的,它不能代替阻塞。自旋等待雖然避免了線程切換的開(kāi)銷,但它要占用處理器時(shí)間。如果鎖被占用的時(shí)間很短,自旋等待的效果就會(huì)非常好。反之,如果鎖被占用的時(shí)間很長(zhǎng),那么自旋的線程只會(huì)白浪費(fèi)處理器資源。所以,自旋等待的時(shí)間必須要有一定的限度,如果自旋超過(guò)了限定次數(shù)(默認(rèn)是10次,可以使用-XX:PreBlockSpin來(lái)更改)沒(méi)有成功獲得鎖,就應(yīng)當(dāng)掛起線程。

自旋鎖的實(shí)現(xiàn)原理同樣也是CAS,AtomicInteger中調(diào)用unsafe進(jìn)行自增操作的源碼中的do-while循環(huán)就是一個(gè)自旋操作,如果修改數(shù)值失敗則通過(guò)循環(huán)來(lái)執(zhí)行自旋,直至修改成功。

【基本功】不可不說(shuō)的Java“鎖”事

自旋鎖在JDK1.4.2中引入,使用-XX:+UseSpinning來(lái)開(kāi)啟。JDK 6中變?yōu)槟J(rèn)開(kāi)啟,并且引入了自適應(yīng)的自旋鎖(適應(yīng)性自旋鎖)。

自適應(yīng)意味著自旋的時(shí)間(次數(shù))不再固定,而是由前一次在同一個(gè)鎖上的自旋時(shí)間及鎖的擁有者的狀態(tài)來(lái)決定。如果在同一個(gè)鎖對(duì)象上,自旋等待剛剛成功獲得過(guò)鎖,并且持有鎖的線程正在運(yùn)行中,那么虛擬機(jī)就會(huì)認(rèn)為這次自旋也是很有可能再次成功,進(jìn)而它將允許自旋等待持續(xù)相對(duì)更長(zhǎng)的時(shí)間。如果對(duì)于某個(gè)鎖,自旋很少成功獲得過(guò),那在以后嘗試獲取這個(gè)鎖時(shí)將可能省略掉自旋過(guò)程,直接阻塞線程,避免浪費(fèi)處理器資源。

在自旋鎖中 另有三種常見(jiàn)的鎖形式:TicketLock、CLHlock和MCSlock,本文中僅做名詞介紹,不做深入講解,感興趣的同學(xué)可以自行查閱相關(guān)資料。

3. 無(wú)鎖 VS 偏向鎖 VS 輕量級(jí)鎖 VS 重量級(jí)鎖

這四種鎖是指鎖的狀態(tài),專門針對(duì)synchronized的。在介紹這四種鎖狀態(tài)之前還需要介紹一些額外的知識(shí)。

首先為什么Synchronized能實(shí)現(xiàn)線程同步?

在回答這個(gè)問(wèn)題之前我們需要了解兩個(gè)重要的概念:“Java對(duì)象頭”、“Monitor”。

Java對(duì)象頭

synchronized是悲觀鎖,在操作同步資源之前需要給同步資源先加鎖,這把鎖就是存在Java對(duì)象頭里的,而Java對(duì)象頭又是什么呢?

我們以Hotspot虛擬機(jī)為例,Hotspot的對(duì)象頭主要包括兩部分?jǐn)?shù)據(jù):Mark Word(標(biāo)記字段)、Klass Pointer(類型指針)。

Mark Word:默認(rèn)存儲(chǔ)對(duì)象的HashCode,分代年齡和鎖標(biāo)志位信息。這些信息都是與對(duì)象自身定義無(wú)關(guān)的數(shù)據(jù),所以Mark Word被設(shè)計(jì)成一個(gè)非固定的數(shù)據(jù)結(jié)構(gòu)以便在極小的空間內(nèi)存存儲(chǔ)盡量多的數(shù)據(jù)。它會(huì)根據(jù)對(duì)象的狀態(tài)復(fù)用自己的存儲(chǔ)空間,也就是說(shuō)在運(yùn)行期間Mark Word里存儲(chǔ)的數(shù)據(jù)會(huì)隨著鎖標(biāo)志位的變化而變化。

Klass Point:對(duì)象指向它的類元數(shù)據(jù)的指針,虛擬機(jī)通過(guò)這個(gè)指針來(lái)確定這個(gè)對(duì)象是哪個(gè)類的實(shí)例。

Monitor

Monitor可以理解為一個(gè)同步工具或一種同步機(jī)制,通常被描述為一個(gè)對(duì)象。每一個(gè)Java對(duì)象就有一把看不見(jiàn)的鎖,稱為內(nèi)部鎖或者M(jìn)onitor鎖。

Monitor是線程私有的數(shù)據(jù)結(jié)構(gòu),每一個(gè)線程都有一個(gè)可用monitor record列表,同時(shí)還有一個(gè)全局的可用列表。每一個(gè)被鎖住的對(duì)象都會(huì)和一個(gè)monitor關(guān)聯(lián),同時(shí)monitor中有一個(gè)Owner字段存放擁有該鎖的線程的唯一標(biāo)識(shí),表示該鎖被這個(gè)線程占用。

現(xiàn)在話題回到synchronized,synchronized通過(guò)Monitor來(lái)實(shí)現(xiàn)線程同步,Monitor是依賴于底層的操作系統(tǒng)的Mutex Lock(互斥鎖)來(lái)實(shí)現(xiàn)的線程同步。

如同我們?cè)谧孕i中提到的“阻塞或喚醒一個(gè)Java線程需要操作系統(tǒng)切換CPU狀態(tài)來(lái)完成,這種狀態(tài)轉(zhuǎn)換需要耗費(fèi)處理器時(shí)間。如果同步代碼塊中的內(nèi)容過(guò)于簡(jiǎn)單,狀態(tài)轉(zhuǎn)換消耗的時(shí)間有可能比用戶代碼執(zhí)行的時(shí)間還要長(zhǎng)”。這種方式就是synchronized最初實(shí)現(xiàn)同步的方式,這就是JDK 6之前synchronized效率低的原因。這種依賴于操作系統(tǒng)Mutex Lock所實(shí)現(xiàn)的鎖我們稱之為“重量級(jí)鎖”,JDK 6中為了減少獲得鎖和釋放鎖帶來(lái)的性能消耗,引入了“偏向鎖”和“輕量級(jí)鎖”。

所以目前鎖一共有4種狀態(tài),級(jí)別從低到高依次是:無(wú)鎖、偏向鎖、輕量級(jí)鎖和重量級(jí)鎖。鎖狀態(tài)只能升級(jí)不能降級(jí)。

通過(guò)上面的介紹,我們對(duì)synchronized的加鎖機(jī)制以及相關(guān)知識(shí)有了一個(gè)了解,那么下面我們給出四種鎖狀態(tài)對(duì)應(yīng)的的Mark Word內(nèi)容,然后再分別講解四種鎖狀態(tài)的思路以及特點(diǎn):

【基本功】不可不說(shuō)的Java“鎖”事

無(wú)鎖

無(wú)鎖沒(méi)有對(duì)資源進(jìn)行鎖定,所有的線程都能訪問(wèn)并修改同一個(gè)資源,但同時(shí)只有一個(gè)線程能修改成功。

無(wú)鎖的特點(diǎn)就是修改操作在循環(huán)內(nèi)進(jìn)行,線程會(huì)不斷的嘗試修改共享資源。如果沒(méi)有沖突就修改成功并退出,否則就會(huì)繼續(xù)循環(huán)嘗試。如果有多個(gè)線程修改同一個(gè)值,必定會(huì)有一個(gè)線程能修改成功,而其他修改失敗的線程會(huì)不斷重試直到修改成功。上面我們介紹的CAS原理及應(yīng)用即是無(wú)鎖的實(shí)現(xiàn)。無(wú)鎖無(wú)法全面代替有鎖,但無(wú)鎖在某些場(chǎng)合下的性能是非常高的。

偏向鎖

偏向鎖是指一段同步代碼一直被一個(gè)線程所訪問(wèn),那么該線程會(huì)自動(dòng)獲取鎖,降低獲取鎖的代價(jià)。

在大多數(shù)情況下,鎖總是由同一線程多次獲得,不存在多線程競(jìng)爭(zhēng),所以出現(xiàn)了偏向鎖。其目標(biāo)就是在只有一個(gè)線程執(zhí)行同步代碼塊時(shí)能夠提高性能。

當(dāng)一個(gè)線程訪問(wèn)同步代碼塊并獲取鎖時(shí),會(huì)在Mark Word里存儲(chǔ)鎖偏向的線程ID。在線程進(jìn)入和退出同步塊時(shí)不再通過(guò)CAS操作來(lái)加鎖和解鎖,而是檢測(cè)Mark Word里是否存儲(chǔ)著指向當(dāng)前線程的偏向鎖。引入偏向鎖是為了在無(wú)多線程競(jìng)爭(zhēng)的情況下盡量減少不必要的輕量級(jí)鎖執(zhí)行路徑,因?yàn)檩p量級(jí)鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換ThreadID的時(shí)候依賴一次CAS原子指令即可。

偏向鎖只有遇到其他線程嘗試競(jìng)爭(zhēng)偏向鎖時(shí),持有偏向鎖的線程才會(huì)釋放鎖,線程不會(huì)主動(dòng)釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(diǎn)(在這個(gè)時(shí)間點(diǎn)上沒(méi)有字節(jié)碼正在執(zhí)行),它會(huì)首先暫停擁有偏向鎖的線程,判斷鎖對(duì)象是否處于被鎖定狀態(tài)。撤銷偏向鎖后恢復(fù)到無(wú)鎖(標(biāo)志位為“01”)或輕量級(jí)鎖(標(biāo)志位為“00”)的狀態(tài)。

偏向鎖在JDK 6及以后的JVM里是默認(rèn)啟用的??梢酝ㄟ^(guò)JVM參數(shù)關(guān)閉偏向鎖:-XX:-UseBiasedLocking=false,關(guān)閉之后程序默認(rèn)會(huì)進(jìn)入輕量級(jí)鎖狀態(tài)。

輕量級(jí)鎖

是指當(dāng)鎖是偏向鎖的時(shí)候,被另外的線程所訪問(wèn),偏向鎖就會(huì)升級(jí)為輕量級(jí)鎖,其他線程會(huì)通過(guò)自旋的形式嘗試獲取鎖,不會(huì)阻塞,從而提高性能。

在代碼進(jìn)入同步塊的時(shí)候,如果同步對(duì)象鎖狀態(tài)為無(wú)鎖狀態(tài)(鎖標(biāo)志位為“01”狀態(tài),是否為偏向鎖為“0”),虛擬機(jī)首先將在當(dāng)前線程的棧幀中建立一個(gè)名為鎖記錄(Lock Record)的空間,用于存儲(chǔ)鎖對(duì)象目前的Mark Word的拷貝,然后拷貝對(duì)象頭中的Mark Word復(fù)制到鎖記錄中。

拷貝成功后,虛擬機(jī)將使用CAS操作嘗試將對(duì)象的Mark Word更新為指向Lock Record的指針,并將Lock Record里的owner指針指向?qū)ο蟮腗ark Word。

如果這個(gè)更新動(dòng)作成功了,那么這個(gè)線程就擁有了該對(duì)象的鎖,并且對(duì)象Mark Word的鎖標(biāo)志位設(shè)置為“00”,表示此對(duì)象處于輕量級(jí)鎖定狀態(tài)。

如果輕量級(jí)鎖的更新操作失敗了,虛擬機(jī)首先會(huì)檢查對(duì)象的Mark Word是否指向當(dāng)前線程的棧幀,如果是就說(shuō)明當(dāng)前線程已經(jīng)擁有了這個(gè)對(duì)象的鎖,那就可以直接進(jìn)入同步塊繼續(xù)執(zhí)行,否則說(shuō)明多個(gè)線程競(jìng)爭(zhēng)鎖。

若當(dāng)前只有一個(gè)等待線程,則該線程通過(guò)自旋進(jìn)行等待。但是當(dāng)自旋超過(guò)一定的次數(shù),或者一個(gè)線程在持有鎖,一個(gè)在自旋,又有第三個(gè)來(lái)訪時(shí),輕量級(jí)鎖升級(jí)為重量級(jí)鎖。

重量級(jí)鎖

升級(jí)為重量級(jí)鎖時(shí),鎖標(biāo)志的狀態(tài)值變?yōu)椤?0”,此時(shí)Mark Word中存儲(chǔ)的是指向重量級(jí)鎖的指針,此時(shí)等待鎖的線程都會(huì)進(jìn)入阻塞狀態(tài)。

整體的鎖狀態(tài)升級(jí)流程如下:

【基本功】不可不說(shuō)的Java“鎖”事

綜上,偏向鎖通過(guò)對(duì)比Mark Word解決加鎖問(wèn)題,避免執(zhí)行CAS操作。而輕量級(jí)鎖是通過(guò)用CAS操作和自旋來(lái)解決加鎖問(wèn)題,避免線程阻塞和喚醒而影響性能。重量級(jí)鎖是將除了擁有鎖的線程以外的線程都阻塞。

4. 公平鎖 VS 非公平鎖

公平鎖是指多個(gè)線程按照申請(qǐng)鎖的順序來(lái)獲取鎖,線程直接進(jìn)入隊(duì)列中排隊(duì),隊(duì)列中的第一個(gè)線程才能獲得鎖。公平鎖的優(yōu)點(diǎn)是等待鎖的線程不會(huì)餓死。缺點(diǎn)是整體吞吐效率相對(duì)非公平鎖要低,等待隊(duì)列中除第一個(gè)線程以外的所有線程都會(huì)阻塞,CPU喚醒阻塞線程的開(kāi)銷比非公平鎖大。

非公平鎖是多個(gè)線程加鎖時(shí)直接嘗試獲取鎖,獲取不到才會(huì)到等待隊(duì)列的隊(duì)尾等待。但如果此時(shí)鎖剛好可用,那么這個(gè)線程可以無(wú)需阻塞直接獲取到鎖,所以非公平鎖有可能出現(xiàn)后申請(qǐng)鎖的線程先獲取鎖的場(chǎng)景。非公平鎖的優(yōu)點(diǎn)是可以減少喚起線程的開(kāi)銷,整體的吞吐效率高,因?yàn)榫€程有幾率不阻塞直接獲得鎖,CPU不必喚醒所有線程。缺點(diǎn)是處于等待隊(duì)列中的線程可能會(huì)餓死,或者等很久才會(huì)獲得鎖。

直接用語(yǔ)言描述可能有點(diǎn)抽象,這里作者用從別處看到的一個(gè)例子來(lái)講述一下公平鎖和非公平鎖。

【基本功】不可不說(shuō)的Java“鎖”事


如上圖所示,假設(shè)有一口水井,有管理員看守,管理員有一把鎖,只有拿到鎖的人才能夠打水,打完水要把鎖還給管理員。每個(gè)過(guò)來(lái)打水的人都要管理員的允許并拿到鎖之后才能去打水,如果前面有人正在打水,那么這個(gè)想要打水的人就必須排隊(duì)。管理員會(huì)查看下一個(gè)要去打水的人是不是隊(duì)伍里排最前面的人,如果是的話,才會(huì)給你鎖讓你去打水;如果你不是排第一的人,就必須去隊(duì)尾排隊(duì),這就是公平鎖。

但是對(duì)于非公平鎖,管理員對(duì)打水的人沒(méi)有要求。即使等待隊(duì)伍里有排隊(duì)等待的人,但如果在上一個(gè)人剛打完水把鎖還給管理員而且管理員還沒(méi)有允許等待隊(duì)伍里下一個(gè)人去打水時(shí),剛好來(lái)了一個(gè)插隊(duì)的人,這個(gè)插隊(duì)的人是可以直接從管理員那里拿到鎖去打水,不需要排隊(duì),原本排隊(duì)等待的人只能繼續(xù)等待。如下圖所示:

【基本功】不可不說(shuō)的Java“鎖”事

接下來(lái)我們通過(guò)ReentrantLock的源碼來(lái)講解公平鎖和非公平鎖。

【基本功】不可不說(shuō)的Java“鎖”事

根據(jù)代碼可知,ReentrantLock里面有一個(gè)內(nèi)部類Sync,Sync繼承AQS(AbstractQueuedSynchronizer),添加鎖和釋放鎖的大部分操作實(shí)際上都是在Sync中實(shí)現(xiàn)的。它有公平鎖FairSync和非公平鎖NonfairSync兩個(gè)子類。ReentrantLock默認(rèn)使用非公平鎖,也可以通過(guò)構(gòu)造器來(lái)顯示的指定使用公平鎖。

下面我們來(lái)看一下公平鎖與非公平鎖的加鎖方法的源碼:

【基本功】不可不說(shuō)的Java“鎖”事


通過(guò)上圖中的源代碼對(duì)比,我們可以明顯的看出公平鎖與非公平鎖的lock()方法唯一的區(qū)別就在于公平鎖在獲取同步狀態(tài)時(shí)多了一個(gè)限制條件:hasQueuedPredecessors()。

【基本功】不可不說(shuō)的Java“鎖”事

再進(jìn)入hasQueuedPredecessors(),可以看到該方法主要做一件事情:主要是判斷當(dāng)前線程是否位于同步隊(duì)列中的第一個(gè)。如果是則返回true,否則返回false。

綜上,公平鎖就是通過(guò)同步隊(duì)列來(lái)實(shí)現(xiàn)多個(gè)線程按照申請(qǐng)鎖的順序來(lái)獲取鎖,從而實(shí)現(xiàn)公平的特性。非公平鎖加鎖時(shí)不考慮排隊(duì)等待問(wèn)題,直接嘗試獲取鎖,所以存在后申請(qǐng)卻先獲得鎖的情況。

5. 可重入鎖 VS 非可重入鎖

可重入鎖又名遞歸鎖,是指在同一個(gè)線程在外層方法獲取鎖的時(shí)候,再進(jìn)入該線程的內(nèi)層方法會(huì)自動(dòng)獲取鎖(前提鎖對(duì)象得是同一個(gè)對(duì)象或者class),不會(huì)因?yàn)橹耙呀?jīng)獲取過(guò)還沒(méi)釋放而阻塞。Java中ReentrantLock和synchronized都是可重入鎖,可重入鎖的一個(gè)優(yōu)點(diǎn)是可一定程度避免死鎖。下面用示例代碼來(lái)進(jìn)行分析:

【基本功】不可不說(shuō)的Java“鎖”事

在上面的代碼中,類中的兩個(gè)方法都是被內(nèi)置鎖synchronized修飾的,doSomething()方法中調(diào)用doOthers()方法。因?yàn)閮?nèi)置鎖是可重入的,所以同一個(gè)線程在調(diào)用doOthers()時(shí)可以直接獲得當(dāng)前對(duì)象的鎖,進(jìn)入doOthers()進(jìn)行操作。

如果是一個(gè)不可重入鎖,那么當(dāng)前線程在調(diào)用doOthers()之前需要將執(zhí)行doSomething()時(shí)獲取當(dāng)前對(duì)象的鎖釋放掉,實(shí)際上該對(duì)象鎖已被當(dāng)前線程所持有,且無(wú)法釋放。所以此時(shí)會(huì)出現(xiàn)死鎖。

而為什么可重入鎖就可以在嵌套調(diào)用時(shí)可以自動(dòng)獲得鎖呢?我們通過(guò)圖示和源碼來(lái)分別解析一下。

還是打水的例子,有多個(gè)人在排隊(duì)打水,此時(shí)管理員允許鎖和同一個(gè)人的多個(gè)水桶綁定。這個(gè)人用多個(gè)水桶打水時(shí),第一個(gè)水桶和鎖綁定并打完水之后,第二個(gè)水桶也可以直接和鎖綁定并開(kāi)始打水,所有的水桶都打完水之后打水人才會(huì)將鎖還給管理員。這個(gè)人的所有打水流程都能夠成功執(zhí)行,后續(xù)等待的人也能夠打到水。這就是可重入鎖。

【基本功】不可不說(shuō)的Java“鎖”事

但如果是非可重入鎖的話,此時(shí)管理員只允許鎖和同一個(gè)人的一個(gè)水桶綁定。第一個(gè)水桶和鎖綁定打完水之后并不會(huì)釋放鎖,導(dǎo)致第二個(gè)水桶不能和鎖綁定也無(wú)法打水。當(dāng)前線程出現(xiàn)死鎖,整個(gè)等待隊(duì)列中的所有線程都無(wú)法被喚醒。

【基本功】不可不說(shuō)的Java“鎖”事

之前我們說(shuō)過(guò)ReentrantLock和synchronized都是重入鎖,那么我們通過(guò)重入鎖ReentrantLock以及非可重入鎖NonReentrantLock的源碼來(lái)對(duì)比分析一下為什么非可重入鎖在重復(fù)調(diào)用同步資源時(shí)會(huì)出現(xiàn)死鎖。

首先ReentrantLock和NonReentrantLock都繼承父類AQS,其父類AQS中維護(hù)了一個(gè)同步狀態(tài)status來(lái)計(jì)數(shù)重入次數(shù),status初始值為0。

當(dāng)線程嘗試獲取鎖時(shí),可重入鎖先嘗試獲取并更新status值,如果status == 0表示沒(méi)有其他線程在執(zhí)行同步代碼,則把status置為1,當(dāng)前線程開(kāi)始執(zhí)行。如果status != 0,則判斷當(dāng)前線程是否是獲取到這個(gè)鎖的線程,如果是的話執(zhí)行status+1,且當(dāng)前線程可以再次獲取鎖。而非可重入鎖是直接去獲取并嘗試更新當(dāng)前status的值,如果status != 0的話會(huì)導(dǎo)致其獲取鎖失敗,當(dāng)前線程阻塞。

釋放鎖時(shí),可重入鎖同樣先獲取當(dāng)前status的值,在當(dāng)前線程是持有鎖的線程的前提下。如果status-1 == 0,則表示當(dāng)前線程所有重復(fù)獲取鎖的操作都已經(jīng)執(zhí)行完畢,然后該線程才會(huì)真正釋放鎖。而非可重入鎖則是在確定當(dāng)前線程是持有鎖的線程之后,直接將status置為0,將鎖釋放。

【基本功】不可不說(shuō)的Java“鎖”事

6. 獨(dú)享鎖 VS 共享鎖

獨(dú)享鎖和共享鎖同樣是一種概念。我們先介紹一下具體的概念,然后通過(guò)ReentrantLock和ReentrantReadWriteLock的源碼來(lái)介紹獨(dú)享鎖和共享鎖。

獨(dú)享鎖也叫排他鎖,是指該鎖一次只能被一個(gè)線程所持有。如果線程T對(duì)數(shù)據(jù)A加上排它鎖后,則其他線程不能再對(duì)A加任何類型的鎖。獲得排它鎖的線程即能讀數(shù)據(jù)又能修改數(shù)據(jù)。JDK中的synchronized和JUC中Lock的實(shí)現(xiàn)類就是互斥鎖。

共享鎖是指該鎖可被多個(gè)線程所持有。如果線程T對(duì)數(shù)據(jù)A加上共享鎖后,則其他線程只能對(duì)A再加共享鎖,不能加排它鎖。獲得共享鎖的線程只能讀數(shù)據(jù),不能修改數(shù)據(jù)。

獨(dú)享鎖與共享鎖也是通過(guò)AQS來(lái)實(shí)現(xiàn)的,通過(guò)實(shí)現(xiàn)不同的方法,來(lái)實(shí)現(xiàn)獨(dú)享或者共享。

下圖為ReentrantReadWriteLock的部分源碼:

【基本功】不可不說(shuō)的Java“鎖”事


我們看到ReentrantReadWriteLock有兩把鎖:ReadLock和WriteLock,由詞知意,一個(gè)讀鎖一個(gè)寫(xiě)鎖,合稱“讀寫(xiě)鎖”。再進(jìn)一步觀察可以發(fā)現(xiàn)ReadLock和WriteLock是靠?jī)?nèi)部類Sync實(shí)現(xiàn)的鎖。Sync是AQS的一個(gè)子類,這種結(jié)構(gòu)在CountDownLatch、ReentrantLock、Semaphore里面也都存在。

在ReentrantReadWriteLock里面,讀鎖和寫(xiě)鎖的鎖主體都是Sync,但讀鎖和寫(xiě)鎖的加鎖方式不一樣。讀鎖是共享鎖,寫(xiě)鎖是獨(dú)享鎖。讀鎖的共享鎖可保證并發(fā)讀非常高效,而讀寫(xiě)、寫(xiě)讀、寫(xiě)寫(xiě)的過(guò)程互斥,因?yàn)樽x鎖和寫(xiě)鎖是分離的。所以ReentrantReadWriteLock的并發(fā)性相比一般的互斥鎖有了很大提升。

那讀鎖和寫(xiě)鎖的具體加鎖方式有什么區(qū)別呢?在了解源碼之前我們需要回顧一下其他知識(shí)。

在最開(kāi)始提及AQS的時(shí)候我們也提到了state字段(int類型,32位),該字段用來(lái)描述有多少線程獲持有鎖。

在獨(dú)享鎖中這個(gè)值通常是0或者1(如果是重入鎖的話state值就是重入的次數(shù)),在共享鎖中state就是持有鎖的數(shù)量。但是在ReentrantReadWriteLock中有讀、寫(xiě)兩把鎖,所以需要在一個(gè)整型變量state上分別描述讀鎖和寫(xiě)鎖的數(shù)量(或者也可以叫狀態(tài))。于是將state變量“按位切割”切分成了兩個(gè)部分,高16位表示讀鎖狀態(tài)(讀鎖個(gè)數(shù)),低16位表示寫(xiě)鎖狀態(tài)(寫(xiě)鎖個(gè)數(shù))。如下圖所示:

【基本功】不可不說(shuō)的Java“鎖”事

了解了概念之后我們?cè)賮?lái)看代碼,先看寫(xiě)鎖的加鎖源碼:

【基本功】不可不說(shuō)的Java“鎖”事

  • 這段代碼首先取到當(dāng)前鎖的個(gè)數(shù)c,然后再通過(guò)c來(lái)獲取寫(xiě)鎖的個(gè)數(shù)w。因?yàn)閷?xiě)鎖是低16位,所以取低16位的最大值與當(dāng)前的c做與運(yùn)算( int w = exclusiveCount(c); ),高16位和0與運(yùn)算后是0,剩下的就是低位運(yùn)算的值,同時(shí)也是持有寫(xiě)鎖的線程數(shù)目。

  • 在取到寫(xiě)鎖線程的數(shù)目后,首先判斷是否已經(jīng)有線程持有了鎖。如果已經(jīng)有線程持有了鎖(c!=0,則查看當(dāng)前寫(xiě)鎖線程的數(shù)目,如果寫(xiě)線程數(shù)為0(即此時(shí)存在讀鎖)或者持有鎖的線程不是當(dāng)前線程就返回失?。?/span>涉及到公平鎖和非公平鎖的實(shí)現(xiàn))。

  • 如果寫(xiě)入鎖的數(shù)量大于最大數(shù)(65535,2的16次方-1)就拋出一個(gè)Error。

  • 如果當(dāng)且寫(xiě)線程數(shù)為0(那么讀線程也應(yīng)該為0,因?yàn)樯厦嬉呀?jīng)處理c!=0的情況),并且當(dāng)前線程需要阻塞那么就返回失敗;如果通過(guò)CAS增加寫(xiě)線程數(shù)失敗也返回失敗。

  • 如果c=0,w=0或者c>0,w>0(重入),則設(shè)置當(dāng)前線程或鎖的擁有者,返回成功!

tryAcquire()除了重入條件(當(dāng)前線程為獲取了寫(xiě)鎖的線程)之外,增加了一個(gè)讀鎖是否存在的判斷。如果存在讀鎖,則寫(xiě)鎖不能被獲取,原因在于:必須確保寫(xiě)鎖的操作對(duì)讀鎖可見(jiàn),如果允許讀鎖在已被獲取的情況下對(duì)寫(xiě)鎖的獲取,那么正在運(yùn)行的其他讀線程就無(wú)法感知到當(dāng)前寫(xiě)線程的操作。

因此,只有等待其他讀線程都釋放了讀鎖,寫(xiě)鎖才能被當(dāng)前線程獲取,而寫(xiě)鎖一旦被獲取,則其他讀寫(xiě)線程的后續(xù)訪問(wèn)均被阻塞。寫(xiě)鎖的釋放與ReentrantLock的釋放過(guò)程基本類似,每次釋放均減少寫(xiě)狀態(tài),當(dāng)寫(xiě)狀態(tài)為0時(shí)表示寫(xiě)鎖已被釋放,然后等待的讀寫(xiě)線程才能夠繼續(xù)訪問(wèn)讀寫(xiě)鎖,同時(shí)前次寫(xiě)線程的修改對(duì)后續(xù)的讀寫(xiě)線程可見(jiàn)。

接著是讀鎖的代碼:

【基本功】不可不說(shuō)的Java“鎖”事

可以看到在tryAcquireShared(int unused)方法中,如果其他線程已經(jīng)獲取了寫(xiě)鎖,則當(dāng)前線程獲取讀鎖失敗,進(jìn)入等待狀態(tài)。如果當(dāng)前線程獲取了寫(xiě)鎖或者寫(xiě)鎖未被獲取,則當(dāng)前線程(線程安全,依靠CAS保證)增加讀狀態(tài),成功獲取讀鎖。讀鎖的每次釋放(線程安全的,可能有多個(gè)讀線程同時(shí)釋放讀鎖)均減少讀狀態(tài),減少的值是“1<<16”。所以讀寫(xiě)鎖才能實(shí)現(xiàn)讀讀的過(guò)程共享,而讀寫(xiě)、寫(xiě)讀、寫(xiě)寫(xiě)的過(guò)程互斥。

此時(shí),我們?cè)倩仡^看一下互斥鎖ReentrantLock中公平鎖和非公平鎖的加鎖源碼:

【基本功】不可不說(shuō)的Java“鎖”事


我們發(fā)現(xiàn)在ReentrantLock雖然有公平鎖和非公平鎖兩種,但是它們添加的都是獨(dú)享鎖。根據(jù)源碼所示,當(dāng)某一個(gè)線程調(diào)用lock方法獲取鎖時(shí),如果同步資源沒(méi)有被其他線程鎖住,那么當(dāng)前線程在使用CAS更新state成功后就會(huì)成功搶占該資源。而如果公共資源被占用且不是被當(dāng)前線程占用,那么就會(huì)加鎖失敗。所以可以確定ReentrantLock無(wú)論讀操作還是寫(xiě)操作,添加的鎖都是都是獨(dú)享鎖。

結(jié)語(yǔ)

本文Java中常用的鎖以及常見(jiàn)的鎖的概念進(jìn)行了基本介紹,并從源碼以及實(shí)際應(yīng)用的角度進(jìn)行了對(duì)比分析。限于篇幅以及個(gè)人水平,沒(méi)有在本篇文章中對(duì)所有內(nèi)容進(jìn)行深層次的講解。

其實(shí)Java本身已經(jīng)對(duì)鎖本身進(jìn)行了良好的封裝,降低了研發(fā)同學(xué)在平時(shí)工作中的使用難度。但是研發(fā)同學(xué)也需要熟悉鎖的底層原理,不同場(chǎng)景下選擇最適合的鎖。而且源碼中的思路都是非常好的思路,也是值得大家去學(xué)習(xí)和借鑒的。

參考資料

1.《Java并發(fā)編程藝術(shù)》
2. 
Java中的鎖

3. Java并發(fā):關(guān)鍵字synchronized解析

4. 深入理解讀寫(xiě)鎖:ReadWriteLock源碼分析

5. Java synchronized原理總結(jié)

6. Java CAS 原理剖析

7. 聊聊并發(fā)(二):Java SE1.6中的Synchronized

8.【JUC】JDK1.8源碼分析之ReentrantReadWriteLock

9. Java多線程(十)之ReentrantReadWriteLock深入分析

10. Java:讀寫(xiě)鎖的實(shí)現(xiàn)原理

作者簡(jiǎn)介

家琪,美團(tuán)點(diǎn)評(píng)后端工程師。2017 年加入美團(tuán)點(diǎn)評(píng),負(fù)責(zé)美團(tuán)點(diǎn)評(píng)境內(nèi)度假的業(yè)務(wù)開(kāi)發(fā)。

原文鏈接:https://mp.weixin.qq.com/s/E2fOUHOabm10k_EVugX08g

向AI問(wèn)一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI