Java并發(fā)編程深入理解之Synchronized的使用及底層原理詳解 下
接著上文《Java并發(fā)編程深入理解之Synchronized的使用及底層原理詳解 上》繼續(xù)介紹synchronized
一、synchronized鎖優(yōu)化
高效并發(fā)是從JDK 5升級到JDK 6后一項重要的改進(jìn)項,HotSpot虛擬機(jī)開發(fā)團(tuán)隊在這個版本上花費(fèi)了大量的資源去實現(xiàn)各種鎖優(yōu)化技術(shù),如適應(yīng)性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖膨脹(Lock Coarsening)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)等,這些技術(shù)都是為了在線程之間更高效地共享數(shù)據(jù)及解決競爭問題,從而提高程序的執(zhí)行效率。
1、自旋鎖與自適應(yīng)自旋
前面介紹線程時提到了掛起線程和恢復(fù)線程的操作都需要轉(zhuǎn)入內(nèi)核態(tài)中完成,頻繁的用戶態(tài)、內(nèi)核態(tài)切換是非常消耗資源的。有時候一個線程獲取鎖之后很短時間就能執(zhí)行完畢,為了這段時間去掛起和恢復(fù)線程并不值得,可以讓后面還未獲取鎖的線程自己等會一會兒而不讓出CPU執(zhí)行時間,看看持有鎖的線程是否很快就會釋放鎖。為了讓線程等待,我們只須讓線程執(zhí)行一個忙循環(huán)(自旋),這項技術(shù)就是所謂的自旋鎖。
自旋鎖在JDK 1.4.2中就已經(jīng)引入,只不過默認(rèn)是關(guān)閉的,可以使用-XX:+UseSpinning參數(shù)來開啟,在JDK 6中就已經(jīng)改為默認(rèn)開啟了。自旋等待不能代替阻塞,且先不說對處理器數(shù)量的要求,自旋等待本身雖然避免了線程切換的開銷,但它是要占用處理器時間的,所以如果鎖被占用的時間很短,自旋等待的效果就會非常好,反之如果鎖被占用的時間很長,那么自旋的線程只會白白消耗處理器資源,這就會帶來性能的浪費(fèi)。因此自旋等待的時間必須有一定的限度,如果自旋超過了限定的次數(shù)仍然沒有成功獲得鎖,就應(yīng)當(dāng)使用傳統(tǒng)的方式去掛起線程。自旋次數(shù)的默認(rèn)值是十次,用戶也可以使用參數(shù)-XX:PreBlockSpin來自行更改。
在JDK 6中對自旋鎖的優(yōu)化,引入了自適應(yīng)的自旋。自適應(yīng)意味著自旋的時間不再是固定的了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態(tài)來決定的。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運(yùn)行中,那么虛擬機(jī)就會認(rèn)為這次自旋也很有可能再次成功,進(jìn)而允許自旋等待持續(xù)相對更長的時間,比如持續(xù)100次忙循環(huán)。另一方面,如果對于某個鎖,自旋很少成功獲得過鎖,那在以后要獲取這個鎖時將有可能直接省略掉自旋過程,以避免浪費(fèi)處理器資源。
2、鎖消除
消除鎖是虛擬機(jī)另外一種鎖的優(yōu)化,這種優(yōu)化更徹底,Java虛擬機(jī)在JIT編譯時(可以簡單理解為當(dāng)某段代碼即將第一次被執(zhí)行時進(jìn)行編譯,又稱即時編譯),通過對運(yùn)行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節(jié)省毫無意義的請求鎖時間,如下StringBuffer的append是一個同步方法,但是在add方法中的StringBuffer屬于一個局部變量,并且不會被其他線程所使用,因此StringBuffer不可能存在共享資源競爭的情景,JVM會自動將其鎖消除。
public String concatString(String s1, String s2, String s3) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); sb.append(s3); return sb.toString(); }
逃逸分析:
使用逃逸分析,編譯器可以對代碼做如下優(yōu)化:
- 一、同步省略。如果一個對象被發(fā)現(xiàn)只能從一個線程被訪問到,那么對于這個對象的操作可以不考慮同步。
- 二、將堆分配轉(zhuǎn)化為棧分配。如果一個對象在子程序中被分配,要使指向該對象的指針永遠(yuǎn)不會逃逸,對象可能是棧分配的候選,而不是堆分配。
- 三、分離對象或標(biāo)量替換。有的對象可能不需要作為一個連續(xù)的內(nèi)存結(jié)構(gòu)存在也可以被訪問到,那么對象的部分(或全部)可以不存儲在內(nèi)存,而是存儲在CPU寄存器中。
是不是所有的對象和數(shù)組都會在堆內(nèi)存分配空間?不一定。在Java代碼運(yùn)行時,通過JVM參數(shù)可指定是否開啟逃逸分析, XX:+DoEscapeAnalysis : 表示開啟逃逸分析 XX:-DoEscapeAnalysis: 表示關(guān)閉逃逸分析。從jdk 1.7開始已經(jīng)默認(rèn)開啟逃逸分析,開啟之后可以通過參數(shù)-XX:+PrintEscapeAnalysis來查看分析結(jié)果。有了逃逸分析支持之后,用戶可以使用參數(shù)-XX:+EliminateAllocations來開啟標(biāo)量替換,使用+XX:+EliminateLocks來開啟同步消除,使用參數(shù)-XX:+PrintEliminateAllocations查看標(biāo)量的替換情況。
通過下面的代碼示例演示一下開啟逃逸分析后,對象進(jìn)行棧上分配的情況:運(yùn)行時通過jps查看程序的進(jìn)程ID,然后通過jmap -histo 進(jìn)程ID查看Student對象的數(shù)量,可以觀察到在關(guān)閉逃逸分析的時候?qū)ο蟮臄?shù)量是50萬,而開啟逃逸分析后是小于50萬的,說明有Student對象是在棧上分配的而不是堆上分配的。
public class StackAllocTest { /** * 進(jìn)行兩種測試 * 關(guān)閉逃逸分析,同時調(diào)大堆空間,避免堆內(nèi)GC的發(fā)生,如果有GC信息將會被打印出來 * VM運(yùn)行參數(shù):-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError * * 開啟逃逸分析 * VM運(yùn)行參數(shù):-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError * * 執(zhí)行main方法后 * jps 查看進(jìn)程 * jmap -histo 進(jìn)程ID * */ public static void main(String[] args) { long start = System.currentTimeMillis(); for (int i = 0; i < 500000; i++) { alloc(); } long end = System.currentTimeMillis(); //查看執(zhí)行時間 System.out.println("cost-time " + (end - start) + " ms"); try { Thread.sleep(100000); } catch (InterruptedException e1) { e1.printStackTrace(); } } private static Student alloc() { //Jit對編譯時會對代碼進(jìn)行 逃逸分析 //并不是所有對象存放在堆區(qū),有的一部分存在線程??臻g Student student = new Student(); return student; } static class Student { private String name; private int age; } }
3、鎖粗化
原則上,我們在編寫代碼的時候,總是推薦將同步塊的作用范圍限制得盡量小。大多數(shù)情況下,上面的原則都是正確的,但是如果一系列的連續(xù)操作都對同一個對象反復(fù)加鎖和解鎖,甚至加鎖操作是出現(xiàn)在循環(huán)體之中的,那即使沒有線程競爭,頻繁地進(jìn)行互斥同步操作也會導(dǎo)致不必要的性能損耗。
上面的代碼示例中所示StringBuffer連續(xù)的append()方法就屬于這類情況。如果虛擬機(jī)探測到有這樣一串零碎的操作都對同一個對象加鎖,將會把加鎖同步的范圍擴(kuò)展(粗化)到整個操作序列的外部,以上面代碼為例,就是擴(kuò)展到第一個append()操作之前直至最后一個append()操作之后,這樣只需要加鎖一次就可以了。
二、對象頭內(nèi)存布局
我們知道synchronized加鎖加在對象上,對象是如何記錄鎖狀態(tài)的呢?答案是鎖狀態(tài)是被記錄在每個對象的對象頭(Mark Word)中,下面我們一起認(rèn)識一下對象的內(nèi)存布局。關(guān)于對象的內(nèi)存布局,可以先看下面這張圖,圖中已經(jīng)畫的很清楚了,可以看到內(nèi)存中存儲的區(qū)域可以分為三部分:對象頭(Header),實例數(shù)據(jù)(Instance Data)和對齊填充(Padding)。
對象頭包括兩部分信息,第一部分用于存儲對象自身的運(yùn)行時數(shù)據(jù)(也稱為"MarkWord"),如哈希碼(HashCode)、GC分代年齡、鎖狀態(tài)標(biāo)志、線程持有的鎖、偏向線程ID、偏向時間戳等,這部分?jǐn)?shù)據(jù)的長度在32位和64位的虛擬機(jī)(未開啟壓縮指針)中分別為32bit和64bit。MarkWord被設(shè)計成一個非固定的數(shù)據(jù)結(jié)構(gòu)以便在極小的空間內(nèi)存儲盡量多的信息,它會根據(jù)對象的狀態(tài)復(fù)用自己的存儲空間。例如,在32位的Hotspot虛擬機(jī)中,如果對象處于未被鎖定的狀態(tài)下,那么MarkWord的32bit空間中的25bit用于存儲對象哈希碼,4bit用于存儲對象分代年齡,2bit用于存儲鎖標(biāo)志位,1bit固定為0,而在其他狀態(tài)(輕量級鎖定、重量級鎖定、GC標(biāo)記、可偏向)下對象的存儲內(nèi)容如下所示:
在OpenJDK源碼中openjdk\hotspot\src\share\vm\oops目錄下有個markOop.cpp,里面定義了對象頭MarkWork中的存儲內(nèi)容,有興趣的可以看一下。
三、synchronized鎖的膨脹升級過程
鎖的狀態(tài)總共有四種,無鎖狀態(tài)、偏向鎖、輕量級鎖和重量級鎖。隨著鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現(xiàn)鎖的降級。
1、偏向鎖
偏向鎖也是JDK 6中引入的一項鎖優(yōu)化措施,它的目的是消除數(shù)據(jù)在無競爭情況下的同步原語,進(jìn)一步提高程序的運(yùn)行性能。偏向鎖的意思是這個鎖會偏向于第一個獲得它的線程,如果在接下來的執(zhí)行過程中,該鎖一直沒有被其他的線程獲取,則持有偏向鎖的線程將永遠(yuǎn)不需要再進(jìn)行同步。
假設(shè)當(dāng)前虛擬機(jī)啟用了偏向鎖(啟用參數(shù)-XX:+UseBiased Locking,這是自JDK 6起HotSpot虛擬機(jī)的默認(rèn)值),那么當(dāng)鎖對象第一次被線程獲取的時候,虛擬機(jī)將會把對象頭中的標(biāo)志位設(shè)置為“01”、把偏向模式設(shè)置為“1”,表示進(jìn)入偏向模式。同時使用CAS操作把獲取到這個鎖的線程的ID記錄在對象的Mark Word之中。如果CAS操作成功,持有偏向鎖的線程以后每次進(jìn)入這個鎖相關(guān)的同步塊時,虛擬機(jī)都可以不再進(jìn)行任何同步操作(例如加鎖、解鎖及對Mark Word的更新操作等)。
一旦出現(xiàn)另外一個線程去嘗試獲取這個鎖的情況,偏向模式就馬上宣告結(jié)束。根據(jù)鎖對象目前是否處于被鎖定的狀態(tài)決定是否撤銷偏向(偏向模式設(shè)置為“0”),撤銷后標(biāo)志位恢復(fù)到未鎖定(標(biāo)志位為“01”)或輕量級鎖定(標(biāo)志位為“00”)的狀態(tài),后續(xù)的同步操作就按照輕量級鎖那樣去執(zhí)行。
偏向鎖使用了一種等到競爭出現(xiàn)才釋放鎖的機(jī)制,所以當(dāng)其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間上沒有正在執(zhí)行的字節(jié)碼)。它會首先暫停擁有偏向鎖的線程,然后檢查持有偏向鎖的線程是否活著,如果線程同步塊已經(jīng)執(zhí)行完,則將對象頭設(shè)置成無鎖狀態(tài);如果線程同步塊還沒執(zhí)行完,需要將偏向鎖升級為輕量級鎖。
當(dāng)對象進(jìn)入偏向狀態(tài)的時候,Mark Word大部分的空間(23個比特)都用于存儲持有鎖的線程ID了,這部分空間占用了原有存儲對象哈希碼的位置,那原來對象的哈希碼怎么辦呢?
在Java語言里面一個對象如果計算過哈希碼,就應(yīng)該一直保持該值不變(強(qiáng)烈推薦但不強(qiáng)制,因為用戶可以重載hashCode()方法按自己的意愿返回哈希碼),否則很多依賴對象哈希碼的API都可能存在出錯風(fēng)險。而作為絕大多數(shù)對象哈希碼來源的Object::hashCode()方法,返回的是對象的一致性哈希碼(Identity Hash Code),這個值是能強(qiáng)制保證不變的,它通過在對象頭中存儲計算結(jié)果來保證第一次計算之后,再次調(diào)用該方法取到的哈希碼值永遠(yuǎn)不會再發(fā)生改變。因此,當(dāng)一個對象已經(jīng)計算過一致性哈希碼后,它就再也無法進(jìn)入偏向鎖狀態(tài)了;而當(dāng)一個對象當(dāng)前正處于偏向鎖狀態(tài),又收到需要計算其一致性哈希碼請求(這里說的計算請求應(yīng)來自于對Object::hashCode()或者System::identityHashCode(Object)方法的調(diào)用,如果重寫了對象的hashCode()方法,計算哈希碼時并不會產(chǎn)生這里所說的請求)時,它的偏向狀態(tài)會被立即撤銷,并且鎖會膨脹為重量級鎖。在重量級鎖的實現(xiàn)中,對象頭指向了重量級鎖的位置,代表重量級鎖的ObjectMonitor類里有字段可以記錄非加鎖狀態(tài)(標(biāo)志位為“01”)下的Mark Word,其中自然可以存儲原來的哈希碼。
2、輕量級鎖
倘若偏向鎖失敗,虛擬機(jī)并不會立即升級為重量級鎖,它還會嘗試使用一種稱為輕量級鎖的優(yōu)化手段(1.6之后加入的),此時Mark Word 的結(jié)構(gòu)也變?yōu)檩p量級鎖的結(jié)構(gòu)。輕量級鎖能夠提升程序性能的依據(jù)是“對絕大部分的鎖,在整個同步周期內(nèi)都不存在競爭”,注意這是經(jīng)驗數(shù)據(jù)。需要了解的是,輕量級鎖所適應(yīng)的場景是線程交替執(zhí)行同步塊的場合,如果存在同一時間訪問同一鎖的場合,就會導(dǎo)致輕量級鎖膨脹為重量級鎖。
在代碼即將進(jìn)入同步塊的時候,如果此同步對象沒有被鎖定(鎖標(biāo)志位為“01”狀態(tài)),虛擬機(jī)首先將在當(dāng)前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的Mark Word的拷貝(官方為這份拷貝加了一個Displaced前綴,即Displaced Mark Word),這時候線程堆棧與對象頭的狀態(tài)如下圖所示。
然后,虛擬機(jī)將使用CAS操作嘗試把對象的Mark Word更新為指向Lock Record的指針。如果這個更新動作成功了,即代表該線程擁有了這個對象的鎖,并且對象Mark Word的鎖標(biāo)志位(Mark Word的最后兩個比特)將轉(zhuǎn)變?yōu)椤?0”,表示此對象處于輕量級鎖定狀態(tài)。這時候線程堆棧與對象頭的狀態(tài)如下圖所示。
如果這個更新操作失敗了,那就意味著至少存在一條線程與當(dāng)前線程競爭獲取該對象的鎖。虛擬機(jī)首先會檢查對象的Mark Word是否指向當(dāng)前線程的棧幀,如果是,說明當(dāng)前線程已經(jīng)擁有了這個對象的鎖,那直接進(jìn)入同步塊繼續(xù)執(zhí)行就可以了,否則就說明這個鎖對象已經(jīng)被其他線程搶占了。如果出現(xiàn)兩條以上的線程爭用同一個鎖的情況,那輕量級鎖就不再有效,必須要膨脹為重量級鎖,鎖標(biāo)志的狀態(tài)值變?yōu)椤?0”,此時Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,后面等待鎖的線程也必須進(jìn)入阻塞狀態(tài)。
上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也同樣是通過CAS操作來進(jìn)行的,如果對象的Mark Word仍然指向線程的鎖記錄,那就用CAS操作把對象當(dāng)前的Mark Word和線程中復(fù)制的Displaced Mark Word替換回來。假如能夠成功替換,那整個同步過程就順利完成了;如果替換失敗,則說明有其他線程嘗試過獲取該鎖,就要在釋放鎖的同時,喚醒被掛起的線程。
輕量級鎖能提升程序同步性能的依據(jù)是“對于絕大部分的鎖,在整個同步周期內(nèi)都是不存在競爭的”這一經(jīng)驗法則。如果沒有競爭,輕量級鎖便通過CAS操作成功避免了使用互斥量的開銷;但如果確實存在鎖競爭,除了互斥量的本身開銷外,還額外發(fā)生了CAS操作的開銷。因此在有競爭的情況下,輕量級鎖反而會比傳統(tǒng)的重量級鎖更慢。
3、重量級鎖
當(dāng)鎖升級到重量級鎖時,就用到了上文提到的ObjectMonitor(監(jiān)視器鎖)。在hotspot源碼的markOop.hpp文件中,可以看到下面這段代碼。多個線程訪問同步代碼塊時,相當(dāng)于去爭搶對象監(jiān)視器修改對象中的鎖標(biāo)識。對象頭MarkWord中可以通過monitor()方法獲取ObjectMonitor的指針,也就是前面以32位虛擬機(jī)舉例時MarkWord前30位變成了指向ObjectMonitor的指針。
bool has_monitor() const { return ((value() & monitor_value) != 0); } ObjectMonitor* monitor() const { assert(has_monitor(), "check"); // Use xor instead of &~ to provide one extra tag-bit check. return (ObjectMonitor*) (value() ^ monitor_value); }
4、各種鎖的優(yōu)缺點
鎖 |
優(yōu)點 |
缺點 |
適用場景 |
---|---|---|---|
偏向鎖 |
加鎖和解鎖不需要額外的消耗,和執(zhí)行非同步方法相比僅存在納秒級的差距 |
如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 |
適用于只有一個線程訪問同步塊的場景 |
輕量級鎖 |
競爭的線程不會阻塞,提高了程序的響應(yīng)速度 |
如果始終得不到鎖競爭的線程,使用自旋會消耗CPU |
追求響應(yīng)時間,同步塊執(zhí)行速度非??欤鄠€線程交替執(zhí)行的場景 |
重量級鎖 |
線程競爭不適用自旋,不會消耗CPU |
線程阻塞,響應(yīng)時間緩慢 |
追求吞吐量,同步塊執(zhí)行時間較長,多個線程鎖競爭激烈的場景 |
到此這篇關(guān)于Java并發(fā)編程深入理解之Synchronized的使用及底層原理詳解 下的文章就介紹到這了,更多相關(guān)Java 并發(fā)編程 Synchronized內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java日常練習(xí)題,每天進(jìn)步一點點(43)
下面小編就為大家?guī)硪黄狫ava基礎(chǔ)的幾道練習(xí)題(分享)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧,希望可以幫到你2021-07-07Springboot RestTemplate設(shè)置超時時間的方法(Spring boot
這篇文章主要介紹了Springboot RestTemplate設(shè)置超時時間的方法,包括Spring boot 版本<=1.3和Spring boot 版本>=1.4,本文通過實例代碼給大家介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧2024-08-08解決Intellij IDEA 使用Spring-boot-devTools無效的問題
下面小編就為大家?guī)硪黄鉀QIntellij IDEA 使用Spring-boot-devTools無效的問題。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-07-07