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