Java的多線程機制系列:(三)synchronized的同步原理


synchronized關鍵字是JDK5之實現鎖(包括互斥性和可見性)的唯一途徑(volatile關鍵字能保證可見性,但不能保證互斥性,詳細參見后文關於vloatile的詳述章節),其在字節碼上編譯為monitorenter和monitorexit這樣的JVM層次的原語(原語的意思是這個命令是原子執行的,中間不可中斷,詳細可查閱原語的概念,這里monitorenter和monitorexit是原語對,表明它們之間的代碼段是原子執行的,所以保證了鎖機制中的互斥性。如果反編譯會發現同步函數的前面加上了monitorenter命令,而在其結束處加上monitorexit命令),JVM通過調用操作系統的互斥原語mutex來實現,被阻塞的線程會被掛起、等待重新調度,也就是如前面“用戶態和內核態”章節所說的,在兩個態之間來回切換,對性能有較大影響。 

JDK5引入了現代操作系統新增加的CAS原子操作(JDK5中並沒有對synchronized關鍵字做優化,而是體現在J.U.C中,所以在該版本concurrent包有更好的性能),從JDK6開始,就對synchronized的實現機制進行了較大調整,包括使用JDK5引進的CAS自旋之外,還增加了自適應的CAS自旋、鎖消除、鎖粗化、偏向鎖、輕量級鎖這些優化策略(后面詳述)。由於此關鍵字的優化使得性能極大提高,同時語義清晰、操作簡單、無需手動關閉,所以java專家組推薦在允許的情況下盡量使用此關鍵字,同時在性能上此關鍵字還有優化的空間。

在《Java的多線程機制系列:(一)總述及基礎概念》中曾經提到,鎖機制有兩種特性:互斥性和可見性。synchronized的互斥性通過在同一時間只允許一個線程持有某個對象鎖來實現(這種串行也保證了指令有序性,即“一個unlock操作先行發生(happen-before)於后面對同一個鎖的lock操作”);可見性被關注較少,其是通過Java內存模型中的“對一個變量unlock操作之前,必須要同步到主內存中;如果對一個變量進行lock操作,則將會清空工作內存中此變量的值,在執行引擎使用此變量前,需要重新從主內存中load操作或assign操作初始化變量值”來保證的。關於內存模型的簡單介紹及指令重排序參見“《Java的多線程機制系列:(四)不得不提的volatile及指令重排序(happen-before)》”。

一、鎖的內存結構

鎖在內存上體現為什么樣的形式?前面說了鎖是一個邏輯抽象,其實是一種機制。在Java內存模型里在不同機制下對應不同的數據結構。每個對象都有個長度2個字寬的對象頭(在32位虛擬機里,1字寬是4個字節,64位虛擬機里,1字寬是8個字節。如果是數組對象,則對象頭是3個字寬,其中第三個字存儲數組的長度),這里面存儲了對象的hashcode或鎖信息,官方稱它為“Mark Word”,如下圖:

2_thumb[2]

 

對象頭的最后兩位存儲了鎖的標志位,01是初始狀態,未加鎖,其對象頭里存儲的是對象本身的哈希碼,隨着鎖級別的不同,對象頭里存儲不同的內容。偏向鎖存儲的是當前占用此對象的線程ID;而輕量級則存儲指向線程棧中鎖記錄的指針。從這里我們可以看到,“鎖”這個東西,可能是個鎖記錄+對象頭里的引用指針(判斷線程是否擁有鎖時將線程的鎖記錄地址和對象頭里的指針地址比較),也可能是對象頭里的線程ID(判斷線程是否擁有鎖時將線程的ID和對象頭里存儲的線程ID比較)。

在代碼進入同步塊的時候,如果此同步對象沒有被鎖定,即它的鎖標志位是01,則虛擬機首先在當前線程的棧中創建我們稱之為“鎖記錄”的空間,用於存儲鎖對象的Mark Word的拷貝,官方把這個拷貝稱為Displaced Mark Word。整個Mark Word及其拷貝至關重要,后面在介紹各個級別的鎖的時候會詳細敘述。

下面首先先介紹各種級別的鎖及應用場景,然后介紹除了鎖級別之外的其余優化策略。

 

二、鎖的級別 

1. 偏向鎖

這是JDK6中的重要引進,因為hotspot作者經過研究實踐發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,為了讓線程獲得鎖的代價更低,引進了偏向鎖。當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄里存儲鎖偏向的線程ID,以后該線程進入和退出同步塊時不需要花費CAS操作來爭奪鎖資源。當一個線程希望獲得對象鎖時,首先搜索下對象頭里是否存儲着當前線程的ID,如果是則直接使用(由於僅僅是查詢比較、不需要寫,所以不需要同步機制,只需要在將對象頭設置為線程ID這個事是需要同步的,這使用CAS來實現:假設兩個線程A和B來查看對象頭的時候,都是無鎖狀態,那么線程A給對象頭賦A的ID,CAS成功,此時若線程B再來更新對象頭時,發現對象頭的值已經不等於其之前讀取的值了,就會更新失敗,所以這能保證“將對象頭設置為線程ID”是同步的),如果設置了則表明此對象已被別的線程鎖定,則嘗試發起替換對象頭中的線程ID為自己的CAS請求,此時就進入偏量鎖撤銷、升級為輕量級鎖的環節。

偏向鎖是等到有競爭資源時才釋放的(這也是基於HotSpot作者發現同步代碼段往往是被同一個線程使用的原因),線程發起了替換對象頭中的線程ID為自身的CAS請求,則持有鎖的線程在安全的位置(無字節碼正在執行)看擁有此偏向鎖的線程是否還活着,如果不是活着,則置為無鎖狀態,以允許其余線程競爭。如果是活的,則掛起此線程,並將指向當前線程的鎖記錄地址的指針放入對象頭,升級為輕量級鎖,然后恢復持有鎖的線程,進入輕量級鎖的競爭模式。注意,這里將當前線程掛起再恢復的過程中並沒有發生鎖的轉移,仍然在當前線程手中,只是穿插了個“將對象頭中的線程ID變更為指向鎖記錄地址的指針”這么個事。

偏向鎖是在單線程執行代碼塊時使用的機制,如果在多線程並發的環境下(即線程A尚未執行完同步代碼塊,線程B發起了申請鎖的申請),則一定會轉化為輕量級鎖或者重量級鎖。

在JDK5中偏向鎖默認是關閉的,而到了JDK6中偏向鎖已經默認開啟。如果並發數較大同時同步代碼塊執行時間較長,則被多個線程同時訪問的概率就很大,就可以使用參數-XX:-UseBiasedLocking來禁止偏向鎖(但這是個JVM參數,不能針對某個對象鎖來單獨設置)。

 

3. 輕量級鎖

如果進入了輕量級鎖的模式(不論是由偏向鎖升級來的,還是關閉了偏向鎖直接進入輕量級鎖),則每次線程想進入同步代碼塊的時候,都得通過CAS嘗試將對象頭中的鎖指針替換為自身棧中的記錄,如果沒有成功,則進入了自適應的自旋。這個自適應自旋結束時還沒有獲得鎖,則升級為重量鎖。如下圖(本圖引自網絡,由於是別的作者所畫,這里注明出處,來源於淘寶工程師方騰飛的聊聊並發(二)——Java SE1.6中的Synchronized)。

為什么升級為輕量鎖時要把對象頭里的Mark Word復制到線程棧的鎖記錄中呢?因為在申請對象鎖時需要以該值作為CAS的比較條件,同時在升級到重量級鎖的時候,能通過這個比較判定是否子持有鎖的過程中此鎖被其他線程申請了(如果被其他線程申請了,則在釋放鎖的時候要喚醒被掛起的線程)。

關於什么是自適應后面再講,但這里的嘗試CAS沒有成功有一定的混淆性,很多文章包括書籍都沒有把這里說清楚,我覺得有必要專門指出來。

為什么會嘗試CAS不成功以及什么情況下會不成功?

CAS本身是不帶鎖機制的,其是通過比較而來。假設如下場景:線程A和線程B都在對象頭里的鎖標識為無鎖狀態進入,那么如線程A先更新對象頭為其鎖記錄指針成功之后,線程B再用CAS去更新,就會發現此時的對象頭已經不是其操作前的對象HashCode了,所以CAS會失敗。也就是說,只有兩個線程並發申請鎖的時候會發生CAS失敗。

然后線程B進行CAS自旋,(后面這部分的邏輯我由於沒有深入研究JVM,也沒有看到有資料介紹,而是根據CAS的概念推理出來,可能會不正確,如果誰有准確答案,望告知),等待對象頭的鎖標識重新變回無鎖狀態或對象頭內容等於對象HashCode(因為這是線程B做CAS操作前的值),這也就意味着線程A執行結束(參見后面輕量級鎖的撤銷,只有線程A執行完畢撤銷鎖了才會重置對象頭),此時線程B的CAS操作終於成功了,於是線程B獲得了鎖以及執行同步代碼的權限。如果線程A的執行時間較長,線程B經過若干次CAS時鍾沒有成功,則鎖膨脹為重量級鎖,即線程B被掛起阻塞、等待重新調度。

輕量級鎖的解鎖過程也是通過CAS來操作。由於持有鎖線程的鎖記錄里頭存儲着Displaced Mark Word,當線程執行完同步代碼塊后,將對象頭里的鎖記錄指針所指向的地址和自己的鎖記錄地址相比較,如果相等則將對象頭的內容替換為Displanced Mark Word,並將對象的標識重置為無鎖狀態。

下圖來自這個地址Java輕量級鎖原理詳解(Lightweight Locking),其中不僅描述了獲得輕量級鎖的過程,也描述了輕量級鎖撤銷的過程。

 

有一點令我不明白的是:大多文章和書籍都說,這里的CAS替換存在失敗可能,即“如果對象頭里的鎖記錄指針所指向的地址不等於自己的鎖記錄地址(為了后面描述方便,我們暫將這個比較操作稱為步驟Compare),則表明曾經有線程嘗試過申請該鎖,則需要在釋放鎖的同時,喚醒被掛起的線程”,我們來考慮兩個時間段:在對象鎖為無鎖狀態時,線程B和線程A同時申請鎖,在線程A成功獲取的情況下,線程B要么是對象鎖釋放后CAS成功、要么是被掛起但此時對象頭的內容始終保持是線程A的鎖記錄指針,步驟Compare不會失敗;另外一個時間段是:在線程A成功獲取鎖之后,即此時對象頭已經是輕量級鎖狀態時,線程B再發起鎖申請,則由於狀態不對,線程B馬上就進入掛起阻塞狀態,不存在修改對象頭的可能,步驟Compare也不會失敗。那么究竟是什么情況下會存在步驟Compare失敗?還望知道的人告知。

 

4. 重量級鎖

前面已經提到過,重量級鎖就已經到了在操作系統級別了,調用的是互斥mutex命令,這也意味着如果線程沒有獲取到鎖,則被掛起阻塞,等待重新調度,需要較頻繁的內核態與用戶態的切換,開銷較大。

 

5.各鎖級別的適用場景

各種鎖並不是相互代替的,而是在不同場景下的不同選擇,絕對不是說重量級鎖就是不合適的。每種鎖是只能升級,不能降級,即由偏向鎖->輕量級鎖->重量級鎖,而這個過程就是開銷逐漸加大的過程。如果是單線程使用,那偏向鎖毫無疑問代價最小,並且它就能解決問題,連CAS都不用做,僅僅在內存中比較下對象頭就可以了;如果出現了其他線程競爭則偏向鎖就會升級為輕量級鎖,如果其他線程通過一定次數的CAS嘗試沒有成功則進入重量級鎖,在這種情況下進入同步代碼塊就要做偏向鎖建立、偏向鎖撤銷、輕量級鎖建立、升級到重量級鎖,最終還是得靠重量級鎖來解決問題,那這樣的代價就比直接用重量級鎖要大不少了。所以使用哪種技術,一定要看其所處的環境及場景,在絕大多數的情況下,偏向鎖是有效的,這是基於HotSpot作者發現的“大多數鎖只會由同一線程並發申請”的經驗規律。

 

三、鎖的其他優化機制

1.自適應的CAS自旋

自旋的概念就是在一個無限循環中不斷地去做CAS,直到成功為止,比如申請鎖的過程。自旋不會使當前線程掛起、調度,省去了這部分時間,但它還是會不斷占據CPU時間的,如果持有鎖的線程執行時間較長,這個自旋的持續時間就很長,對性能就會造成較明顯的影響(我們平時寫個死循環就知道,機器馬上CPU使用率就很高),所以需要一定的保護機制,使CAS自旋一定次數之后,就不再嘗試了,如輕量級鎖的CAS嘗試屢次不成之后就會升級為重量級鎖。

那么自旋嘗試多久合適?在JDK5中是嘗試10次,JDK6引入了自適應的概念,即根據前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定,如果上次經過一定嘗試就成功了,則推斷這次相應次數甚至更長一些的次數也很可能會成功,如果上次等了很久也沒成功,則推斷這次也很可能不成功,很少的CAS自旋就會放棄。這是很有道理的,上次很快成功,說明同步代碼塊執行地很快、耗時很少,值得等一等;如果上次等很久也沒成功,則其同步代碼塊執行比較耗時,如較長時間的IO操作,則這次也沒必要等了。隨着時間的推移,經驗逐漸累計,這樣自適應的CAS自旋就越來越准確,應該說每段同步代碼塊的第一次並發執行會嘗試多一些,后面的就會比較和實際匹配了。

 

2. 鎖消除

虛擬機在運行時,有一些代碼雖然要求同步,加了synchronized,但被檢測到不可能存在共享數據的競爭,所以就把鎖去除。舉個簡單例子,下面這個類是個累加器,i++方法不是原子的,所以需要用synchronized修飾,這沒有問題

    private class Accumulator{
        private int val=0;
        public synchronized void increase(){
            val++;
        }
        public int getVal(){
            return val;
        }
    }

但使用累加器的方式是這樣的,如下面代碼

public class ClearLockDemo {
    
    public void execute(){
        Accumulator aor=new Accumulator();
        for(int i=0;i<100;i++){
            aor.increase();
        }
        int result=aor.getVal();
    }
}

雖然說Accumulator的increase方法是線程不安全的,但在上面的execute方法中,創建了方法內的局部對象,也就是說是在單線程下循環運行,不存在多線程並發的問題,此時JVM就會據此判斷從而優化,消除掉在increase執行前的鎖判斷,以提高效率。與此類似的還有StringBuffer的append方法,JDK提供的這個方法用synchronized修飾來保證線程安全,但如果是在方法內創建StringBuffer對象並append,則會鎖消除。

 

3. 鎖粗化

原則上我們用synchronized修飾的代碼塊應該盡量小,以減少同步代碼執行時間,但如果在一個線程中針對同一個對象鎖有較多連續的同步代碼塊,那么再每次進同步代碼塊都爭取鎖就會帶來不必要的效率損失,所以JVM在這種情況下會進行鎖粗化。最常見的場景是循環里面調用方法,仍然是上面的ClearLockDemo的execute方法為例,假如說需要啟用同步,那么在每個循環體中都爭奪鎖、釋放鎖沒有任何意義,JVM就會把整個循環都放在一個同步塊下執行。


免責聲明!

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



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