synchronized鎖深度分析


轉載:https://sq.163yun.com/blog/article/198148723617792000

一、   引言

JAVA是一門極易入門的語言,這一點尤其表現在JAVA中對象鎖的使用和多線程編程上。所謂對象鎖,就是可以直接在JAVA的任意Object加鎖(synchronized),也可以在通過任意Object進行線程的阻塞(Object.wait())和喚醒(Object.notify() or Object.notifyAll()),這種面向對象的鎖與C系中的Mutex和Semaphore相比,一來省去了創建鎖對象的麻煩,二來這種更加抽象的封裝使鎖的使用更加人性化和便利。

然而這種便利帶來了另外一個問題:說到C系中的mutext和Semaphore,都知道這是對操作系統中信號量的封裝,其原理只要學過操作系統的人都會非常清楚,因此這種鎖雖然使用起來略麻煩,但是原理透明,這就為后期鎖調優提供了可能。而JAVA中的synchronized雖然提供了更加友好抽象的互斥原語,卻很少有JAVA程序員了解synchronized背后的原理,甚至你會發現,JAVA面試官在考察你對JVM的了解程度時,基本上問的都是GC相關的問題。

拿我個人來說,JAVA開發做了四五年,對synchornized可以說駕輕就熟,但是當被問到這些問題時,我只能無言以對:

synchronized到底有多大開銷?與CAS這樣的樂觀並發控制相比如何?

怎樣使用synchronized更加高效?

與ReentrantLock(JDK1.5之后提供的鎖對象)相比有什么優勢劣勢?

程序員可以對synchronized做哪些優化?

要回答這些問題,就需要對synchrnonized背后的原理一探究竟,在查閱了一些資料后,我驚訝地發現synchornized實現遠比我想象的復雜地多,一個簡單的synchronized過程,可能會涉及到自旋鎖(spinlock)、自適應自旋鎖(adaptivespinlock)、輕量鎖(lightweightlock)、偏向鎖(biasedlock)以及粗量鎖(heavyweightlock),看起來synchronized是把各種復雜的鎖過程封裝在一起,幫助開發者無腦使用,在這一點上與C系可謂兩個極端。

本文將分兩個部分,第一部分初探篇,介紹synchronized的使用方法和原理。第二部分深探篇,將介紹synchronized背后的實現原理,帶大家理解各種不同鎖優化實現之間的轉換,最后根據synchronized的實現原理,回答上文提到的四個問題。
另外,對於對象上的阻塞和喚醒,本文也會進行部分講解。

本文內容很多來自互聯網中的分享,由我進行了總結和發散性思考,對本文貢獻較多的鏈接會貼在下一篇文章末尾。

二、   初探篇

synchronized使用起來非常簡單,有三種使用模式:

1. 作為修飾符加在方法聲明上,synchronized修飾非靜態方法時表示鎖住了調用該方法的堆對象,修飾靜態方法時表示鎖住了這個類在方法區中的類對象(記住JAVA中everything is object),例如下述代碼:

1

2

3

4

5

6

public class IncableInt {

    private int value = 0;

    public synchronized int incAndGet() {

       return ++value;

    }

}

上述代碼實現了一個線程安全的自增函數,當不同線程進入incAndGet()方法體時,會競爭這個IncableInt對象上的鎖,通過鎖的互斥性保證了該方法不會被不同線程同時進入。

2. 可以用synchronized直接構建代碼塊,上述的自增整數可以也可以寫成下面的形式:

1

2

3

4

5

6

7

8

public class IncableInt {

    private int value = 0;

    public int incAndGet() {

        synchronized (this) {

            return ++value;

        }

    }

}

上述代碼可以達到同樣的互斥效果,sychronized代碼塊競爭的是后面括號中的對象鎖,我們常常可以在一些源碼中看到用一個普通的Object作為synchronized對象,相當於C系中mutex的效果。

3. 在使用Object.wait()使當前線程進入該Object的阻塞隊列時,以及用Object.notify()或Object.notifyAll()喚醒該Object的阻塞隊列中一個或所有線程時,必須在外層使用synchronized (Object),這是JAVA中線程同步的最常見做法。之所以在這里要強制使用synchronized代碼塊,是因為在JAVA語義中,wait有出讓Object鎖的語義,要想出讓鎖,前提是要先獲得鎖,所以要先用synchronized獲得鎖之后才能調用wait,notify原因類似,另外,我們知道操作系統信號量的增減都是原子性的,而Object.wait()和notify()不具有原子性語義,所以必須用synchronized保證線程安全。

另外,在使用synchronized時有三個原則:

a) sychronized的對象最好選擇引用不會變化的對象(例如被標記為final,或初始化后永遠不會變),原因顯而易見的,雖然synchronized是在對象上加鎖,但是它首先要通過引用來定位對象,如果引用會變化,可能帶來意想不到的后果,對於需要synchronized不同對象的情況,建議的做法是為每個對象構建一個Object鎖來synchronized(不建議對同一個引用反復賦值)。當然將synchronized作為修飾符修飾方法就不會有引用變化的問題,但是這種做法在方法體較大時容易違反第二個原則。

b) 盡可能把synchronized范圍縮小,線程互斥是以犧牲並發度為代價的,這點大家都懂。

c) 盡量不要在可變引用上wait()和notify(),例如:

1

2

3

4

synchronized (a) {

    (1)

    a.wait()

}

若其他線程在線程1進入(1)時更改了a值,那么線程1會直接拋出一個IllegalMonitorException,表示在a.wait()前沒有獲得a的對象鎖。推薦的做法還是聲明一個專門用於線程同步的Object,這個Object永遠不變。

三、    死鎖與活鎖

synchronized相當於C++中的mutex,也就是可重入的01信號量,JAVA通過這個關鍵字保證互斥語義,在synchronized過程中因為加鎖失敗而進入阻塞隊列的線程,只能通過其他線程釋放鎖來喚醒,因此使用synchronized可能引發死鎖,使用時需要留意。另外,synchronized也可能引發活鎖,因為synchronized是不公平競爭,后來的線程可能先得到鎖,進而可能導致先到的線程持續飢餓,非公平競爭在很大程度上提升了synchronized吞吐率(why?答案在下一篇中揭曉)。

雖然wait()和notify()也是阻塞和喚醒,看起來和synchronized有點類似,但實際上無論是wait()還是notify()的調用都是以獲得鎖為前提,因此不會在wait()或notify()上發生死鎖,進一步講,wait()或notify()沒有互斥語義,沒有互斥就沒有競爭,沒有競爭就不會有死鎖。另外,wait()操作是可能被其他線程interrupt掉的(拋出中斷異常)。

這里有個概念容易混淆,所謂死鎖與互相等待還是有很大區別的,使用wait()和signal()是可能出現兩個以上線程互相等待的情況,這種互相等待是可以通過加入新線程signal()來解開,造成這種互相等待大部分原因是業務邏輯使然,屬於正常情況。而使用synchronized一旦出現兩個線程互相等待,必然是死鎖。

可以說wait()和notify()是專門用於線程同步的,對應C中的Semaphore,synchronized是專門用於線程互斥的,JAVA中將互斥和同步分成兩種不同原語,使用起來更加友好。

 

 

三、  synchronized深究

這里我們來聊聊synchronized,以及wait(),notify()的實現原理。

在深入介紹synchronized原理之前,先介紹兩種不同的鎖實現。

一、   阻塞鎖

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

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

非公平鎖雖然可能導致活鎖(所謂的飢餓),但是鎖的吞吐率是公平鎖的5-10倍,synchronized是一個典型的非公平鎖,無法通過配置或其他手段將synchronized變為公平鎖,在JDK1.5后,提供了一個ReentrantLock可以代替synchronized實現阻塞鎖,並且可以選擇公平還是非公平。

二、   自旋鎖

線程的阻塞和喚醒需要CPU從用戶態轉為核心態,頻繁的阻塞和喚醒對CPU來說是一件負擔很重的工作。同時我們可以發現,很多對象鎖的鎖定狀態只會持續很短的一段時間,例如整數的自加操作,在很短的時間內阻塞並喚醒線程顯然不值得,為此引入了自旋鎖。

所謂“自旋”,就是讓線程去執行一個無意義的循環,循環結束后再去重新競爭鎖,如果競爭不到繼續循環,循環過程中線程會一直處於running狀態,但是基於JVM的線程調度,會出讓時間片,所以其他線程依舊有申請鎖和釋放鎖的機會。

自旋鎖省去了阻塞鎖的時間空間(隊列的維護等)開銷,但是長時間自旋就變成了“忙式等待”,忙式等待顯然還不如阻塞鎖。所以自旋的次數一般控制在一個范圍內,例如10,100等,在超出這個范圍后,自旋鎖會升級為阻塞鎖。

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

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

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

自適應自旋鎖—>阻塞鎖

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

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

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

圖1. JAVA對象頭結構

ClassAddress是指向方法區中對象所屬類對象的地址指針,ArrayLength標志了數組長度, MarkWord用於存儲對象的各種標志信息,為了在極小的空間存儲盡量多的信息,MarkWord會根據對象狀態復用空間。MarkWord中有2位用於標志對象狀態,在不同狀態下MarkWord中存儲的信息含義分別為:

圖2. MarkValue結構

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

三、   輕量鎖(Light-weightlock)

首先需要明確的是,無論是輕量鎖還是偏向鎖,都不能代替重量鎖,兩者的本意都是在沒有多線程競爭的前提下,減少重量鎖產生的性能消耗。一旦出現了多線程競爭鎖,輕量鎖和偏向鎖都會立即升級為重量鎖。進一步講,輕量鎖和偏向鎖都是重量鎖的樂觀並發優化。

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

先從對象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操作將上一步生成的lockrecord地址賦給目標對象的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在線程棧中生成多個lockrecord,每當退出一個synchronized代碼塊便解鎖一次,並彈出一個lock record。

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

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

偏向鎖

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

偏向鎖和輕量鎖的加鎖過程很類似,不同的是在第二步CAS中,set的值是申請鎖的線程ID,Tag置為01(就初始狀態來說,是不變),這點可以從圖2中開出。當發生鎖重入時,只需要檢查MarkValue中的ThreadID是否與當前線程ID相同即可,相同即可直接重入,不相同說明有不同線程競爭鎖,這時候要先將偏向鎖撤銷(revoke)為輕量鎖,再升級為重量鎖。 因為偏向鎖的MarkValue為線程ID,可以直接定位到持有鎖的線程,偏向鎖撤銷為輕量鎖的過程,需要將持有鎖的線程中與目標對象相關的最老的lockrecord地址替換到當前的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 arelocked and unlocked by different threads but never concurrently. It invalidatesthe bias of all instances of a class without disabling biased locking. An epochvalue 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. Bulkrebiasing can then efficiently be implemented as an increment of the epoch inthe appropriate class. The next time an instance of this class is going to belocked, the code detects a different value in the header word and rebiases theobject 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之外。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM