轉載:http://www.iteye.com/topic/1018932程序員
高效併發是JDK 1.6的一個重要主題,HotSpot虛擬機開發團隊在這個版本上花費了大量的精力去實現各類鎖優化技術,如適應性自旋(Adaptive Spinning)、鎖削除(Lock Elimination)、鎖膨脹(Lock Coarsening)、輕量級鎖(Lightweight Locking)、偏向鎖(Biased Locking)等,這些技術都是爲了在線程之間更高效地共享數據,以及解決競爭問題,從而提升程序的執行效率。
13.3.1 自旋鎖與自適應自旋
前面咱們討論互斥同步的時候,提到了互斥同步對性能最大的影響是阻塞的實現,掛起線程和恢復線程的操做都須要轉入內核態中完成,這些操做給系統的併發性能帶來了很大的壓力。同時,虛擬機的開發團隊也注意到在許多應用上,共享數據的鎖定狀態只會持續很短的一段時間,爲了這段時間去掛起和恢復線程並不值得。若是物理機器有一個以上的處理器,能讓兩個或以上的線程同時並行執行,咱們就可讓後面請求鎖的那個線程「稍等一會」,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖。爲了讓線程等待,咱們只須讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。
自旋鎖在JDK 1.4.2中就已經引入,只不過默認是關閉的,可使用-XX:+UseSpinning參數來開啓,在JDK 1.6中就已經改成默認開啓了。自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待自己雖然避免了線程切換的開銷,但它是要佔用處理器時間的,因此若是鎖被佔用的時間很短,自旋等待的效果就會很是好,反之若是鎖被佔用的時間很長,那麼自旋的線程只會白白消耗處理器資源,而不會作任何有用的工做,反而會帶來性能的浪費。所以自旋等待的時間必需要有必定的限度,若是自旋超過了限定的次數仍然沒有成功得到鎖,就應當使用傳統的方式去掛起線程了。自旋次數的默認值是10次,用戶可使用參數-XX:PreBlockSpin來更改。
在JDK 1.6中引入了自適應的自旋鎖。自適應意味着自旋的時間再也不固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。若是在同一個鎖對象上,自旋等待剛剛成功得到過鎖,而且持有鎖的線程正在運行中,那麼虛擬機就會認爲此次自旋也頗有可能再次成功,進而它將容許自旋等待持續相對更長的時間,好比100個循環。另外一方面,若是對於某個鎖,自旋不多成功得到過,那在之後要獲取這個鎖時將可能省略掉自旋過程,以免浪費處理器資源。有了自適應自旋,隨着程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的情況預測就會愈來愈準確,虛擬機就會變得愈來愈「聰明」了。
13.3.2 鎖削除
鎖削除是指虛擬機即時編譯器在運行時,對一些代碼上要求同步,可是被檢測到不可能存在共享數據競爭的鎖進行削除。鎖削除的主要斷定依據來源於逃逸分析的數據支持(第11章已經講解過逃逸分析技術),若是判斷到一段代碼中,在堆上的全部數據都不會逃逸出去被其餘線程訪問到,那就能夠把它們看成棧上數據對待,認爲它們是線程私有的,同步加鎖天然就無須進行。
也許讀者會有疑問,變量是否逃逸,對於虛擬機來講須要使用數據流分析來肯定,可是程序員本身應該是很清楚的,怎麼會在明知道不存在數據爭用的狀況下要求同步呢?答案是有許多同步措施並非程序員本身加入的,同步的代碼在Java程序中的廣泛程度也許超過了大部分讀者的想象。咱們來看看下面代碼清單13-6中的例子,這段很是簡單的代碼僅僅是輸出三個字符串相加的結果,不管是源碼字面上仍是程序語義上都沒有同步。
代碼清單 13-6 一段看起來沒有同步的代碼 數組
咱們也知道,因爲String是一個不可變的類,對字符串的鏈接操做老是經過生成新的String對象來進行的,所以Javac編譯器會對String鏈接作自動優化。在JDK 1.5以前,會轉化爲StringBuffer對象的連續append()操做,在JDK 1.5及之後的版本中,會轉化爲StringBuilder對象的連續append()操做。即代碼清單13-6中的代碼可能會變成代碼清單13-7的樣子 。
代碼清單 13-7 Javac轉化後的字符串鏈接操做 安全
(注1:實事求是地說,既然談到鎖削除與逃逸分析,那虛擬機就不多是JDK 1.5以前的版本,因此實際上會轉化爲非線程安全的StringBuilder來完成字符串拼接,並不會加鎖。可是這也不影響筆者用這個例子證實Java對象中同步的廣泛性。)
如今你們還認爲這段代碼沒有涉及同步嗎?每一個StringBuffer.append()方法中都有一個同步塊,鎖就是sb對象。虛擬機觀察變量sb,很快就會發現它的動態做用域被限制在concatString()方法內部。也就是sb的全部引用永遠不會「逃逸」到concatString()方法以外,其餘線程沒法訪問到它,因此這裏雖然有鎖,可是能夠被安全地削除掉,在即時編譯以後,這段代碼就會忽略掉全部的同步而直接執行了。
13.3.3 鎖膨脹
原則上,咱們在編寫代碼的時候,老是推薦將同步塊的做用範圍限制得儘可能小——只在共享數據的實際做用域中才進行同步,這樣是爲了使得須要同步的操做數量儘量變小,若是存在鎖競爭,那等待鎖的線程也能儘快地拿到鎖。
大部分狀況下,上面的原則都是正確的,可是若是一系列的連續操做都對同一個對象反覆加鎖和解鎖,甚至加鎖操做是出如今循環體中的,那即便沒有線程競爭,頻繁地進行互斥同步操做也會致使沒必要要的性能損耗。
上面代碼清單13-7中連續的append()方法就屬於這類狀況。若是虛擬機探測到有這樣一串零碎的操做都對同一個對象加鎖,將會把加鎖同步的範圍擴展(膨脹)到整個操做序列的外部,以代碼清單13-7爲例,就是擴展到第一個append()操做以前直至最後一個append()操做以後,這樣只須要加鎖一次就能夠了。
13.3.4 輕量級鎖
輕量級鎖是JDK 1.6之中加入的新型鎖機制,它名字中的「輕量級」是相對於使用操做系統互斥量來實現的傳統鎖而言的,所以傳統的鎖機制就被稱爲「重量級」鎖。首先須要強調一點的是,輕量級鎖並非用來代替重量級鎖的,它的本意是在沒有多線程競爭的前提下,減小傳統的重量級鎖使用操做系統互斥量產生的性能消耗。
要理解輕量級鎖,以及後面會講到的偏向鎖的原理和運做過程,必須從HotSpot虛擬機的對象(對象頭部分)的內存佈局開始介紹。HotSpot虛擬機的對象頭(Object Header)分爲兩部分信息,第一部分用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡(Generational GC Age)等,這部分數據的長度在32位和64位的虛擬機中分別爲32個和64個Bits,官方稱它爲「Mark Word」,它是實現輕量級鎖和偏向鎖的關鍵。另一部分用於存儲指向方法區對象類型數據的指針,若是是數組對象的話,還會有一個額外的部分用於存儲數組長度。
對象頭信息是與對象自身定義的數據無關的額外存儲成本,考慮到虛擬機的空間效率,Mark Word被設計成一個非固定的數據結構以便在極小的空間內存儲儘可能多的信息,它會根據對象的狀態複用本身的存儲空間。例如在32位的HotSpot虛擬機中對象未被鎖定的狀態下,Mark Word的32個Bits空間中的25Bits用於存儲對象哈希碼(HashCode),4Bits用於存儲對象分代年齡,2Bits用於存儲鎖標誌位,1Bit固定爲0,在其餘狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下對象的存儲內容如表13-1所示。
表13-1 HotSpot虛擬機對象頭Mark Word數據結構
存儲內容 | 標誌位 | 狀態 |
對象哈希碼、對象分代年齡 | 01 | 未鎖定 |
指向鎖記錄的指針 | 00 | 輕量級鎖定 |
指向重量級鎖的指針 | 10 | 膨脹(重量級鎖定) |
空,不須要記錄信息 | 11 | GC標記 |
偏向線程ID、偏向時間戳、對象分代年齡 | 01 | 可偏向 |
簡單地介紹完了對象的內存佈局,咱們把話題返回到輕量級鎖的執行過程上。在代碼進入同步塊的時候,若是此同步對象沒有被鎖定(鎖標誌位爲「01」狀態),虛擬機首先將在當前線程的棧幀中創建一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced前綴,即Displaced Mark Word),這時候線程堆棧與對象頭的狀態如圖13-3所示。
多線程
而後,虛擬機將使用CAS操做嘗試將對象的Mark Word更新爲指向Lock Record的指針。若是這個更新動做成功了,那麼這個線程就擁有了該對象的鎖,而且對象Mark Word的鎖標誌位(Mark Word的最後兩個Bits)將轉變爲「00」,即表示此對象處於輕量級鎖定狀態,這時候線程堆棧與對象頭的狀態如圖13-4所示。
併發
(注2:圖13-3和圖13-4來源於HotSpot虛擬機的一位Senior Staff Engineer——Paul Hohensee所寫的PPT《The Hotspot Java Virtual Machine》)
若是這個更新操做失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,若是是就說明當前線程已經擁有了這個對象的鎖,那就能夠直接進入同步塊繼續執行,不然說明這個鎖對象已經被其餘線程搶佔了。若是有兩條以上的線程爭用同一個鎖,那輕量級鎖就再也不有效,要膨脹爲重量級鎖,鎖標誌的狀態值變爲「10」,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,後面等待鎖的線程也要進入阻塞狀態。
上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也是經過CAS操做來進行的,若是對象的Mark Word仍然指向着線程的鎖記錄,那就用CAS操做把對象當前的Mark Word和線程中複製的Displaced Mark Word替換回來,若是替換成功,整個同步過程就完成了。若是替換失敗,說明有其餘線程嘗試過獲取該鎖,那就要在釋放鎖的同時,喚醒被掛起的線程。
輕量級鎖能提高程序同步性能的依據是「對於絕大部分的鎖,在整個同步週期內都是不存在競爭的」,這是一個經驗數據。若是沒有競爭,輕量級鎖使用CAS操做避免了使用互斥量的開銷,但若是存在鎖競爭,除了互斥量的開銷外,還額外發生了CAS操做,所以在有競爭的狀況下,輕量級鎖會比傳統的重量級鎖更慢。
13.3.5 偏向鎖
偏向鎖也是JDK 1.6中引入的一項鎖優化,它的目的是消除數據在無競爭狀況下的同步原語,進一步提升程序的運行性能。若是說輕量級鎖是在無競爭的狀況下使用CAS操做去消除同步使用的互斥量,那偏向鎖就是在無競爭的狀況下把整個同步都消除掉,連CAS操做都不作了。
偏向鎖的「偏」,就是偏愛的「偏」、偏袒的「偏」。它的意思是這個鎖會偏向於第一個得到它的線程,若是在接下來的執行過程當中,該鎖沒有被其餘的線程獲取,則持有偏向鎖的線程將永遠不須要再進行同步。
若是讀者讀懂了前面輕量級鎖中關於對象頭Mark Word與線程之間的操做過程,那偏向鎖的原理理解起來就會很簡單。假設當前虛擬機啓用了偏向鎖(啓用參數-XX:+UseBiasedLocking,這是JDK 1.6的默認值),那麼,當鎖對象第一次被線程獲取的時候,虛擬機將會把對象頭中的標誌位設爲「01」,即偏向模式。同時使用CAS操做把獲取到這個鎖的線程的ID記錄在對象的Mark Word之中,若是CAS操做成功,持有偏向鎖的線程之後每次進入這個鎖相關的同步塊時,虛擬機均可以再也不進行任何同步操做(例如Locking、Unlocking及對Mark Word的Update等)。
當有另一個線程去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖對象目前是否處於被鎖定的狀態,撤銷偏向(Revoke Bias)後恢復到未鎖定(標誌位爲「01」)或輕量級鎖定(標誌位爲「00」)的狀態,後續的同步操做就如上面介紹的輕量級鎖那樣執行。偏向鎖、輕量級鎖的狀態轉化及對象Mark Word的關係如圖13-5所示。
app
偏向鎖能夠提升帶有同步但無競爭的程序性能。它一樣是一個帶有效益權衡(Trade Off)性質的優化,也就是說它並不必定老是對程序運行有利,若是程序中大多數的鎖都老是被多個不一樣的線程訪問,那偏向模式就是多餘的。在具體問題具體分析的前提下,有時候使用參數-XX:-UseBiasedLocking來禁止偏向鎖優化反而能夠提高性能。 佈局