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

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

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

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

1、鎖的內存結構

鎖在內存上體現爲何樣的形式?前面說了鎖是一個邏輯抽象,實際上是一種機制。在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及其拷貝相當重要,後面在介紹各個級別的鎖的時候會詳細敘述。數據結構

下面首先先介紹各類級別的鎖及應用場景,而後介紹除了鎖級別以外的其他優化策略。多線程

 

2、鎖的級別 

1. 偏向鎖

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

偏向鎖是等到有競爭資源時才釋放的(這也是基於HotSpot做者發現同步代碼段每每是被同一個線程使用的緣由),線程發起了替換對象頭中的線程ID爲自身的CAS請求,則持有鎖的線程在安全的位置(無字節碼正在執行)看擁有此偏向鎖的線程是否還活着,若是不是活着,則置爲無鎖狀態,以容許其他線程競爭。若是是活的,則掛起此線程,並將指向當前線程的鎖記錄地址的指針放入對象頭,升級爲輕量級鎖,而後恢復持有鎖的線程,進入輕量級鎖的競爭模式。注意,這裏將當前線程掛起再恢復的過程當中並無發生鎖的轉移,仍然在當前線程手中,只是穿插了個「將對象頭中的線程ID變動爲指向鎖記錄地址的指針」這麼個事。app

偏向鎖是在單線程執行代碼塊時使用的機制,若是在多線程併發的環境下(即線程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做者發現的「大多數鎖只會由同一線程併發申請」的經驗規律。

 

3、鎖的其餘優化機制

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就會把整個循環都放在一個同步塊下執行。

相關文章
相關標籤/搜索