JVM鎖實現探究2:synchronized深探


本文來自網易雲社區html


做者:馬進
程序員

這裏咱們來聊聊synchronized,以及wait(),notify()的實現原理。數組

在深刻介紹synchronized原理以前,先介紹兩種不一樣的鎖實現。性能優化

1、阻塞鎖

咱們平時說的鎖都是經過阻塞線程來實現的:當出現鎖競爭時,只有得到鎖的線程可以繼續執行,競爭失敗的線程會由running狀態進入blocking狀態,並被登記在目標鎖相關的一個等待隊列中,當前一個線程退出臨界區,釋放鎖後,會將等待隊列中的一個阻塞線程喚醒(按FIFO原則喚醒),令其從新參與到鎖競爭中。多線程

這裏要區別一下公平鎖和非公平鎖,顧名思義,公平鎖就是得到鎖的順序按照先到先得的原則,從實現上說,要求當一個線程競爭某個對象鎖時,只要這個鎖的等待隊列非空,就必須把這個線程阻塞並塞入隊尾(插入隊尾通常經過一個CAS保持插入過程當中沒有鎖釋放)。相對的,非公平鎖場景下,每一個線程都先要競爭鎖,在競爭失敗或當前已被加鎖的前提下才會被塞入等待隊列,在這種實現下,後到的線程有可能無需進入等待隊列直接競爭到鎖。併發

非公平鎖雖然可能致使活鎖(所謂的飢餓),可是鎖的吞吐率是公平鎖的5-10倍,synchronized是一個典型的非公平鎖,沒法經過配置或其餘手段將synchronized變爲公平鎖,在JDK1.5後,提供了一個ReentrantLock能夠代替synchronized實現阻塞鎖,而且能夠選擇公平仍是非公平。
app

2、自旋鎖

線程的阻塞和喚醒須要CPU從用戶態轉爲核心態,頻繁的阻塞和喚醒對CPU來講是一件負擔很重的工做。同時咱們能夠發現,不少對象鎖的鎖定狀態只會持續很短的一段時間,例如整數的自加操做,在很短的時間內阻塞並喚醒線程顯然不值得,爲此引入了自旋鎖。ide

所謂「自旋」,就是讓線程去執行一個無心義的循環,循環結束後再去從新競爭鎖,若是競爭不到繼續循環,循環過程當中線程會一直處於running狀態,可是基於JVM的線程調度,會出讓時間片,因此其餘線程依舊有申請鎖和釋放鎖的機會。工具

自旋鎖省去了阻塞鎖的時間空間(隊列的維護等)開銷,可是長時間自旋就變成了「忙式等待」,忙式等待顯然還不如阻塞鎖。因此自旋的次數通常控制在一個範圍內,例如10,100等,在超出這個範圍後,自旋鎖會升級爲阻塞鎖。佈局

所謂自適應自旋鎖,是經過JVM在運行時收集的統計信息,動態調整自旋鎖的自旋上界,使鎖的總體代價達到最優。

介紹了自旋鎖和阻塞鎖這兩種基本的鎖實現以後,咱們來聊一聊synchronized背後的鎖實現。

synchronized鎖在運行過程當中可能通過N次升級變化,首先能夠想到的是:

自適應自旋鎖—>阻塞鎖

自適應自旋鎖是JDK1.6中引入的,自旋鎖的自旋上界由同一個鎖上的自旋時間統計和鎖的持有者狀態共同決定。當自旋超過上界後,自旋鎖就升級爲阻塞鎖。就像C中的Mutex,阻塞鎖的空間和時間開銷都比較大(畢竟有個隊列),爲此在阻塞鎖中,synchronized又進一步進行了優化細分。阻塞鎖升級變化過程以下:

偏向鎖—>輕量鎖—>重量鎖

重量鎖就是帶着隊列的鎖,開銷最大,它的實現和Mutex很像,可是多了一個waiting的隊列,這部分實現最後介紹,咱們先來看看輕量鎖和偏向鎖是什麼玩意。
在進一步介紹鎖實現以前,咱們須要先了解一下JVM中對象的內存佈局,JVM中每一個對象都有一個對象頭(Object header),普通對象頭的長度爲兩個字,數組對象頭的長度爲三個字(JVM內存字長等於虛擬機位數,32位虛擬機即32位一字,64位亦然),其構成以下所示:


圖1. JAVA對象頭結構

ClassAddress是指向方法區中對象所屬類對象的地址指針,ArrayLength標誌了數組長度, MarkWord用於存儲對象的各類標誌信息,爲了在極小的空間存儲儘可能多的信息,MarkWord會根據對象狀態複用空間。MarkWord中有2位用於標誌對象狀態,在不一樣狀態下MarkWord中存儲的信息含義分別爲:

圖2. MarkValue結構

看到這個表格多少會讓人有些眼花繚亂,不急,咱們在講解下面幾種鎖的過程當中會分別介紹這幾種狀態。

3、輕量鎖(Light-weight lock)

首先須要明確的是,不管是輕量鎖仍是偏向鎖,都不能代替重量鎖,二者的本意都是在沒有多線程競爭的前提下,減小重量鎖產生的性能消耗。一旦出現了多線程競爭鎖,輕量鎖和偏向鎖都會當即升級爲重量鎖。進一步講,輕量鎖和偏向鎖都是重量鎖的樂觀併發優化。

對對象加輕量鎖的條件是該對象當前沒有被任何其餘線程鎖住。

先從對象O沒有鎖競爭的狀態提及,這時候MarkWord中Tag狀態爲01,其餘位分別記錄了對象的hashcode,4位的對象年齡信息(新建對象年齡爲0,以後每次在新生代拷貝一次就年齡+1,當年齡超過一個閾值以後,就會被丟入老年代,GC原理不是本文的主題,但至少咱們如今知道了,這個閾值<=15),以及1位的偏向信息用於記錄這個對象是否可用偏向鎖。 當一個線程A在對象O上申請鎖時,它首先檢查對象O的Tag,若發現是01且偏向信息爲0,代表當前對象還未加鎖,或加過偏向鎖(加過,注意是加過偏向鎖的對象只能被一樣的線程加鎖,若是不一樣的線程想要獲取鎖,須要先將偏向鎖升級爲輕量鎖,稍後會講到),在判斷對當前對象確實沒有被任何其餘線程鎖住後(Tag爲01或偏向線程不具備該對象鎖),便可以在該對象上加輕量鎖。

在判斷能夠加輕量鎖以後,加輕量鎖的過程爲兩步:

1. 在當前線程的棧(stack frame)中生成一個鎖記錄(lock record),這個鎖記錄並非咱們一般意義上說的鎖對象(包含隊列的那個),而僅僅是對象頭MarkValue的一個拷貝,官方稱之爲displayed mark value。如圖3所示:



圖3. 加輕量鎖以前


2. 經過CAS操做將上一步生成的lock record地址賦給目標對象的MarkValue中(Tag同時改成00),保證在給MarkValue賦值時Tag不會動態修改,若是CAS成功,代表輕量鎖申請成果,若是CAS不成功,且Tag變爲00,則查看MarkValue中lock record address是否指向當前線程棧中的鎖記錄,如果,則代表是一樣的線程鎖重入,也算鎖申請成果。如圖4所示: 在第二步中,若不知足加鎖成功的兩種狀況,說明目標鎖已經被其餘線程持有,這時再也不知足加輕量鎖條件,須要將當前對象上的鎖狀態升級爲重量鎖:將Tag狀態改成10,並生成一個Monitor對象(重量鎖對象),再將MarkValue值改成該Monitor對象的地址。最後將當前線程塞入該Monitor的等待隊列中。



圖4.加輕量鎖以後

輕量鎖的解鎖過程也依賴CAS操做: 經過CAS將lock record中的Object原MarkValue賦還給Object的MarkValue,若替換成功,則解鎖完成,若替換不成功,表示在當前線程持有鎖的這段時間內,其餘線程也競爭過鎖,而且發生了鎖升級爲重量鎖,這時須要去Monitor的等待隊列中喚醒一個線程去從新競爭鎖。

當發生鎖重入時,會對一個Object在線程棧中生成多個lock record,每當退出一個synchronized代碼塊便解鎖一次,並彈出一個lock record。


一言以蔽之,輕量鎖經過CAS檢測鎖衝突,在沒有鎖衝突的前提下,避免採用重量鎖的一種優化手段。

加輕量鎖的代價是數個指令外加一個CAS操做,雖然輕量鎖的代價已經足夠小,它依然有優化空間。 細心的人應該發現,輕量鎖的每次鎖重入都要進行一次CAS操做,而這個操做是能夠避免的,這即是偏向鎖的優化手段了。

偏向鎖

所謂偏向,就是偏袒的意思,偏向鎖的初衷是在某個線程得到鎖以後,消除這個線程鎖重入(CAS)的開銷,看起來讓這個線程獲得了偏護。

偏向鎖和輕量鎖的加鎖過程很相似,不一樣的是在第二步CAS中,set的值是申請鎖的線程ID,Tag置爲01(就初始狀態來講,是不變),這點能夠從圖2中開出。當發生鎖重入時,只須要檢查MarkValue中的ThreadID是否與當前線程ID相同便可,相同便可直接重入,不相同說明有不一樣線程競爭鎖,這時候要先將偏向鎖撤銷(revoke)爲輕量鎖,再升級爲重量鎖。 由於偏向鎖的MarkValue爲線程ID,能夠直接定位到持有鎖的線程,偏向鎖撤銷爲輕量鎖的過程,須要將持有鎖的線程中與目標對象相關的最老的lock record地址替換到當前的MarkValue中,並將Tag置爲00。

偏向鎖的釋放不須要作任何事情,這也就意味着加過偏向鎖的MarkValue會一直保留偏向鎖的狀態,所以即使同一個線程持續不斷地加鎖解鎖,也是沒有開銷的。 另外一方面,偏向鎖比輕量鎖更容易被終結,輕量鎖是在有鎖競爭出現時升級爲重量鎖,而通常偏向鎖是在有不一樣線程申請鎖時升級爲輕量鎖,這也就意味着假如一個對象先被線程1加鎖解鎖,再被線程2加鎖解鎖,這過程當中沒有鎖衝突,也同樣會發生偏向鎖失效,不一樣的是這回要先退化爲無鎖的狀態,再加輕量鎖,如圖5:


圖5. 偏向鎖,以及鎖升級

回到圖2,咱們發現出了Tag外還有一個01標誌位,上文中提到,這位表示偏向信息,0表示偏向不可用,1表示偏向可用,這位信息一樣記錄在對象的類對象中,當JVM發現一類對象頻繁發生鎖升級,而鎖升級自己須要必定的開銷,這種狀況下偏向鎖反而成爲一種負擔,尤爲在生產者消費者這類常態競爭鎖的場景中,偏向鎖是徹底無心義的,當JVM蒐集到足夠的「證據」證實偏向鎖不該當存在後,它就會將類對象中的相關標誌置0,以後每次生成新對象其偏向信息都是0,都不會再加偏向鎖。官網上稱之爲Bulk revokation。

另外,JVM對那種會有多線程加鎖,但不存在鎖競爭的狀況也作了優化,聽起來比較拗口,但在現實應用中確實是可能出現這種狀況,由於線程以前除了互斥以外也可能發生同步關係,被同步的兩個線程(一前一後)對共享對象鎖的競爭極可能是沒有衝突的。對這種狀況,JVM用一個epoch表示一個偏向鎖的時間戳(真實地生成一個時間戳代價仍是蠻大的,所以這裏應當理解爲一種相似時間戳的identifier),對epoch,官方是這麼解釋的:

A similar mechanism, called bulk rebiasing, optimizes situations in which objects of a class are locked and unlocked by different threads but never concurrently. It invalidates the bias of all instances of a class without disabling biased locking. An epoch value in the class acts as a timestamp that indicates the validity of the bias. This value is copied into the header word upon object allocation. Bulk rebiasing can then efficiently be implemented as an increment of the epoch in the appropriate class. The next time an instance of this class is going to be locked, the code detects a different value in the header word and rebiases the object towards the current thread.

再次一言以蔽之,偏向鎖是在輕量鎖的基礎上減小了減小了鎖重入的開銷。

重量鎖

重量鎖在JVM中又叫對象監視器(Monitor),它很像C中的Mutex,除了具有Mutex互斥的功能,它還負責實現了Semaphore的功能,也就是說它至少包含一個競爭鎖的隊列,和一個信號阻塞隊列(wait隊列),前者負責作互斥,後一個用於作線程同步。

這兩天在網上找資料,發現一篇對重量鎖不錯的介紹,雖然我的以爲裏面對輕量鎖,偏向鎖介紹的有點少,另外在鎖的變化升級上有點含糊。不妨礙它在Monitor描述上的優質。爲了尊重原做者,這裏貼出它的博客連接:
http://blog.csdn.net/chen77716/article/details/6618779
從這篇博文中咱們能夠看到,在重量鎖的調度過程當中,可能有不一樣線程訪問Monitor的隊列,因此Monitor的隊列必然都是併發隊列,而併發隊列的操做須要併發控制,是否是發現這又要依賴synchronized?哈哈,固然這種循環依賴是不可能出現的,由於Monitor中的隊列都是經過CAS來保證其併發的正確性的。

寫到這裏,我本身都不禁驚歎CAS的神奇,任何閱讀到這裏的讀者都會發現,synchronized的實現中處處都有CAS的身影。那麼CAS的代價到底有多大呢? 關於CAS的介紹推薦兩篇介紹,和一個答疑:這裏還須要說明一下自旋鎖與阻塞鎖三個過程之間的關係:自旋鎖是在發生鎖競爭時自旋等待,那麼自旋鎖的前提是發生鎖競爭,而輕量鎖,偏向鎖的前提都是沒有鎖競爭,因此加自旋鎖應當發生在加劇量鎖以前,準確地說,是在線程進入Monitor等待隊列以前,先自旋一會,從新競爭,若是還競爭不到,纔會進入Monitor等待隊列。加鎖順序爲:

偏向鎖—>輕量鎖—>自適應自旋鎖—>重量鎖

CAS具體的代價在不一樣硬件上有所區別,但從指令複雜度考慮,必然比普通賦值指令多不少時鐘週期,可是在CAS和synchronized之間作選擇時,依舊傾向CAS,由於synchronized背後佈滿了CAS,若是你對本身的coding有足夠自信,那嘗試本身CAS或許能有不錯的收穫。

最後回答咱們最初提出的幾個問題:


Q1: synchronized到底有多大開銷?與CAS這樣的樂觀併發控制相好比何?

從上述四個鎖的原理以及加速順序咱們不難發現,synchronzied在沒有鎖衝突的前提下最小開銷爲一個CAS+棧變量維護(lock record)+一個賦值指令,有鎖衝突時須要維護一個Montor對象,經過Moinitor對象維護鎖隊列,這種狀況下涉及到線程阻塞和喚醒,開銷很大。

Synchronized大多數狀況下沒有CAS高效,由於synchronized的最小開銷也至少包含一個CAS操做。CAS和synchronized實現的多線程自加操做性能對比見上一篇博客。


Q2:怎樣使用synchronized更加高效?

使用synchronized要聽從上篇博客中提到的三個原則,另外若是業務場景容許使用CAS,傾向使用CAS,或者JDK提供的一些樂觀併發容器(如ConcurrentLinkedQueue等),也能夠先用synchronized將業務邏輯實現,以後作針對性的性能優化。


Q3:與ReentrantLock(JDK1.5以後提供的鎖對象)一類的鎖相比有什麼優劣?

ReentrantLock表明了JDK1.5以後由JAVA語言實現的一系列鎖的工具類,而synchronized做爲JAVA中的關鍵字,是由native(根據平臺有所不一樣,通常是C)語言實現的。ReentrantLock雖然也實現了 synchronized中的幾種鎖優化技術,但與synchronized相比,性能未必好,畢竟JAVA語言效率和native語言效率比大多數狀況總有不如。ReentrantLock的優點在於爲程序員提供了更多的選擇和更好地擴展性,好比公平性鎖和非公平性鎖,讀寫鎖,CountLatch等。

細心地人會發現,JDK1.6中的併發容器大多數都是用ReentrantLock一類的鎖對象實現。例如LinkedBlockingQueue這樣的生產者消費者隊列,雖然也能夠用synchronized實現,可是這種隊列中存在若干個互斥和同步邏輯,用synchronized容易使邏輯變得混亂,難以閱讀和維護。

總結一點,在業務併發簡單清晰的狀況下推薦synchronized,在業務邏輯併發複雜,或對使用鎖的擴展性要求較高時,推薦使用ReentrantLock這類鎖。


Q5:能夠對synchronized作哪些優化?

經過介紹synchronized的背後實現,不難看出synchronized自己已經通過了高度優化,並且除了JVM運行時的鎖優化外,JAVA編譯器還會對synchronized代碼塊作一些額外優化,例如對確定不會發生鎖競爭的synchronized進行鎖消除,或頻繁對一個對象進行synchronized時能夠鎖粗化(如synchronzied寫在for循環內時,能夠優化到外面),所以程序員在使用synchronized時須要注意的就是上篇博客中提到的三點原則,尤爲是控制synchronzied的代碼量,將無需互斥執行的代碼儘可能移到synchronzed以外。


本文來自網易雲社區,經做者馬進受權發佈    

         

相關文章:
【推薦】 搜索湊單頁大促顯示延遲方案設計
【推薦】 知物由學 | AI時代,那些黑客正在如何打磨他們的「利器」?

相關文章
相關標籤/搜索