【Java】Synchronized實現原理與常見面試題

前言

Synchronized 是常被我們用來保證臨界區以及臨界資源安全的解決方案。它可以保證當有多個線程訪問同一段代碼,操作共享數據時,其他線程必須等待正在操作線程完成數據處理後再進行訪問。即 Synchronized 可以達到線程互斥訪問的目的。

所以,我們可以瞭解到,Synchronized鎖代表的鎖機制有如下兩種特性:互斥型和可見性。

  • 互斥性:同一時間只允許一個線程持有某個對象鎖,通過這種特性來實現多線程中併發安全;
  • 可見性:確保鎖在釋放之前所做的操作,對之後的其他線程是可見的(即之後獲取到該鎖的線程獲取到的共享變量是最新的)。

除此之外,JDK 1.6後還對synchronized鎖進行了優化,使其擺脫了重量級鎖的稱號。接下來就來了解以下synchronized的實現以及優化。

一、Synchronized對應的鎖對象

理論上Java中所有的對象都可以作爲鎖,Java中根據synchronized使用的場景不同,其鎖對象也是不一樣的。可以有以下場景:

場景 具體分類 鎖對象 代碼示例
修飾方法 實例方法 當前實例對象 public synchronized void method () {
...
}
... 靜態方法 當前類的Class對象 public static synchronized void method () {
...
}
修飾代碼塊 代碼塊 ( )中配置的對象 synchronized(object) {
...
}

所以,當一個線程要訪問一段同步代碼塊時,它必須獲取到如上表中的鎖對象。那麼這一過程在字節碼中又是怎麼表示的呢?

二、 Monitor機制與Java對象頭

首先我們來看一段小Demo:

public class Demo {

    public static void main(String[] args) {
        synchronized (Demo.class) { }
        method();
    }

    private static void method() { }
}

可以看到執行同步代碼塊首先需要去執行monitorenter指令,退出的時候需要執行monitorexit指令。我們來觀察monitorenter指令底層的邏輯,其源碼如下:

IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
#ifdef ASSERT
    thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
	if (PrintBiasedLockingStatistics) {
    	Atomic::inc(BiasedLocking::slow_path_entry_count_addr());
	}
	Handle h_obj(thread, elem->obj());
	assert(Universe::heap()->is_in_reserved_or_null(h_obj()),
       	"must be NULL or an object");
	// 在JVM啓動時,我們可以通過參數選擇是否啓用偏向鎖
	if (UseBiasedLocking) {
        // 在這裏判斷是否啓動偏向鎖
    	// Retry fast entry if bias is revoked to avoid unnecessary inflation
    	ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
	} else {
        // 啓動輕量級鎖
    	ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
	}
	assert(Universe::heap()->is_in_reserved_or_null(elem->obj()),
       	"must be NULL or an object");
#ifdef ASSERT
	thread->last_frame().interpreter_frame_verify_monitor(elem);
#endif
IRT_END

我們可以看到上面這個方法根據是否啓動偏向鎖來決定偏向鎖(if(UseBiasedLocking))來決定是否使用偏向鎖(調用ObjectSynchronizer::fast_enter()方法)還是輕量級鎖(調用ObjectSynchronizer::slow_enter()方法)。如果不能獲取到鎖,那麼就會按偏向鎖、輕量級鎖、重量級鎖的順序膨脹(關於四種鎖狀態後面會提及)。

在JDK 1.6之前,使用synchronized就意味着使用重量級鎖,即直接調用ObjectSynchronizer::enter()方法。之所以稱爲“重量級”,是因爲線程的阻塞和喚醒都需要OS在內核態和用戶態之間轉換。而JDK 1.6引入了偏向鎖、輕量級鎖、適應性自旋、鎖消除等大量優化,synchronized的效率也變高了。

上面鎖提到的鎖,其中偏向鎖和輕量級鎖都是樂觀鎖,基於CAS操作,不需要條件變量之類的東西,所有不需要Monitor,而重量級鎖是悲觀鎖,則會被monitor機制管理。

1. Monitor

那麼什麼是Monitor呢?

Monitor可以理解爲一個同步工具或一種同步機制,通常被描述爲一個對象。每一個對象都有一把看不見的鎖,稱爲內部鎖或者Monitor鎖。

Monitor是線程私有的數據結構,每一個線程都有一個可用monitor record列表,同時還有一個全局的可用列表。每一個被重量級鎖鎖住的對象都會和一個monitor關聯,同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一標識,表示該鎖被這個線程佔用。

2. Java對象頭

synchronized說到底是一種鎖機制,在操作同步資源時需要給同步資源加鎖,那麼這個鎖到底存在哪裏呢?答案就是對象頭中。

在HotSpot虛擬機中,對象在堆內存中的存儲佈局可以劃分爲對象頭、實例數據和對齊填充。

其中對象頭主要又包括了兩部分數據:Mark Word(標記字段)、Klass Point(類型指針):

  • Mark Work:默認存儲對象的HashCode,分代年齡和鎖標誌位信息。這些信息都是與對象自身定義無關的數據,所以Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲儘量多的數據。它會根據對象的狀態複用自己的存儲空間,也就是說在運行期間Mark Word裏存儲的數據會隨着鎖標誌位的變化而變化。

  • Klass Point:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。

三、 Synchronized的鎖升級

上面提到了關於鎖升級的過程,那麼現在就來詳細說明下四種鎖狀態以及鎖的膨脹過程。

1. 無鎖狀態

無鎖沒有對資源進行鎖定,所有的線程都能訪問並修改同一個資源,但同時只有一個線程能修改成功。

無鎖的特點就是修改操作在循環內進行,線程會不斷的嘗試修改共享資源。如果沒有衝突就修改成功並退出,否則就會繼續循環嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。上面我們介紹的CAS原理及應用即是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。

2. 偏向鎖

偏向鎖是指一段同步代碼一直被一個線程所訪問,那麼該線程會自動獲取鎖,降低獲取鎖的代價。

在大多數情況下,鎖總是由同一線程多次獲得,不存在多線程競爭,所以出現了偏向鎖。其目標就是在只有一個線程執行同步代碼塊時能夠提高性能。

當一個線程訪問同步代碼塊並獲取鎖時,會在 Mark Word 裏存儲鎖偏向的線程ID。在線程進入和退出同步塊時不再通過 CAS 操作來加鎖和解鎖,而是檢測 Mark Word 裏是否存儲着指向當前線程的偏向鎖。引入偏向鎖是爲了在無多線程競爭的情況下儘量減少不必要的輕量級鎖執行路徑,因爲輕量級鎖的獲取及釋放依賴多次 CAS 原子指令,而偏向鎖只需要在置換 ThreadID 的時候依賴一次CAS原子指令即可。

偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程纔會釋放鎖,線程不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態。撤銷偏向鎖後恢復到無鎖(標誌位爲“01”)或輕量級鎖(標誌位爲“00”)的狀態。

偏向鎖在JDK 6及以後的JVM裏是默認啓用的。可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之後程序默認會進入輕量級鎖狀態。

3. 輕量級鎖

是指當鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。

在代碼進入同步塊的時候,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,然後拷貝對象頭中的Mark Word複製到鎖記錄中。

拷貝成功後,虛擬機將使用CAS操作嘗試將對象的Mark Word更新爲指向Lock Record的指針,並將Lock Record裏的owner指針指向對象的Mark Word。

如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,表示此對象處於輕量級鎖定狀態。

如果輕量級鎖的更新操作失敗了,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明多個線程競爭鎖。

若當前只有一個等待線程,則該線程通過自旋進行等待。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖升級爲重量級鎖。

4. 重量級鎖

升級爲重量級鎖時,鎖標誌的狀態值變爲“10”,此時Mark Word中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞狀態。

重量級鎖通過對象內部的監視器(monitor)實現,而其中 monitor 的本質是依賴於底層操作系統的 Mutex Lock 實現,操作系統實現線程之間的切換需要從用戶態切換到內核態,切換成本非常高。

簡言之,就是所有的控制權都交給了操作系統,由操作系統來負責線程間的調度和線程的狀態變更。而這樣會出現頻繁地對線程運行狀態的切換,線程的掛起和喚醒,從而消耗大量的系統資源,導致性能低下。

5. 鎖膨脹(升級)

synchronized關鍵字鎖修飾的代碼塊在第一次被執行時,鎖對象就會從無鎖狀態變成偏向鎖(此時會通過CAS修改對象頭裏的鎖標誌位)。執行完同步代碼快後,線程並不會主動釋放偏向鎖。當第二次到達同步代碼塊時,線程會判斷喫有鎖的線程是否就是自己(持有鎖的線程 ID 也在對象頭裏),如果是則正常往下執行。由於之前沒有釋放鎖,這裏也不需要重新加鎖。如果自始自終使用鎖的線程只有一個,很明顯偏向鎖幾乎沒有額外開銷,性能極高。

一旦又第二個線程加入鎖競爭,偏向鎖就升級爲輕量級鎖(自旋鎖)。在輕量級鎖狀態下繼續鎖競爭,沒有搶到鎖的線程將自旋,即不停地循環判斷鎖是否能夠被成功獲取。獲取鎖的操作,其實就是通過CAS修改對象頭裏的鎖標誌位。先比較當前鎖標誌位是否爲“釋放”,如果是則將其設置爲“鎖定”,比較並設置是原子性操作。這就算搶到鎖了,然後線程將當前鎖的持有者信息修改爲自己。

長時間的自旋操作是非常消耗資源的,一個線程持有鎖,其他線程就只能在原地空耗CPU,執行不了任何有效的任務,這種現象叫做忙等(busy-waiting)。如果多個線程用一個鎖,但是沒有發生鎖競爭,或者發生了很輕微的鎖競爭,那麼synchronized就用輕量級鎖,允許短時間的忙等現象。這是一種折衷的想法,短時間的忙等,換取線程在用戶態和內核態之間切換的開銷。

顯然,此忙等是有限度的(JVM有個計數器會記錄自旋次數,默認允許循環10次,可以通過虛擬機參數更改)。如果鎖競爭情況嚴重,某個達到最大自旋次數的線程,會將輕量級鎖升級爲重量級鎖(依然是CAS修改鎖標誌位,但不修改持有鎖的線程ID)。當後續線程嘗試獲取鎖時,發現被佔用的鎖是重量級鎖,則直接將自己掛起(而不是忙等),等待將來被喚醒。在JDK 1.6之前,synchronized直接加重量鎖,很明顯現在得到了很好的優化。

有一點需要特別注意:鎖只能按照偏向鎖、輕量級鎖、重量級鎖的順序升級,而不能降級。

所以綜上所述,偏向鎖通過對比Mark Word解決加鎖問題,避免執行 CAS 操作。而輕量級鎖是通過用 CAS 操作和自旋來解決加鎖問題,避免線程阻塞和喚醒而影響性能。重量級鎖是將除了擁有鎖的線程以外的線程都阻塞。

常見面試題

  • synchronized鎖住的是什麼?

    synchronized本身並不是鎖,鎖本身是一個對象,synchronized最多相當於“加鎖”操作,所以synchronized並不是鎖住代碼塊。Java中的每一個對象都可以作爲鎖。具體表示有三種形式,當修飾普通同步方法,鎖是當前實例對象;當修飾靜態同步方法,鎖是synchronized括號裏配置的對象。

  • synchronized鎖升級的過程?

    當沒有競爭出現時,默認使用偏向鎖。JVM會利用CAS操作,在對象頭上的Mark Word部分設置線程ID,以表示這個對象偏向於當前線程,所以並不涉及真正的互斥鎖。這樣做的假設是基於在很多應用場景中,大部分對象生命週期中最多會被一個線程鎖定,使用偏向鎖可以降低無競爭開銷。

    如果有另外的線程試圖鎖定某個已經被偏向過的對象,JVM就需要撤銷(revoke)偏向鎖,並切換到輕量級鎖實現。輕量級鎖依賴 CAS 操作Mark Word來試圖獲取鎖,如果重試成功,就使用輕量級鎖;否則在自旋一定次數後進一步升級爲重量級鎖。

  • 爲什麼說Synchronized是非公平鎖,這樣的優缺點是什麼?

    非公平主要表現在獲取鎖的行爲上,並非是按照申請鎖的時間前後給等待線程分配鎖的,每當鎖被釋放後,任何一個線程都有機會競爭到鎖,這樣做的目的是爲了提高執行性能,缺點是可能產生線程飢餓現象。

  • 爲什麼說synchronized是一個悲觀鎖?樂觀鎖的實現原理又是什麼?什麼是CAS,它有什麼特性?

    Synchronized顯然是一個悲觀鎖,因爲它的併發策略是悲觀的:不管是否會產生競爭,任何的數據都必須加鎖、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要被喚醒等操作

    隨着硬件指令集的發展,我們可以使用基於衝突檢測的樂觀併發策略。先進行操作,如果沒有任何其他線程徵用數據,那操作就成功了;

    如果共享數據有徵用,產生了衝突,那就再進行其他的補償措施。這種樂觀的併發策略的許多實現不需要線程掛起,所以被稱爲非阻塞同步。

    樂觀鎖的核心算法是CAS(Compared And Swap,比較並交換),它涉及到三個操作數:內存值、預期值、新值。當且僅當預期值和內存值相等時纔將內存指修改爲新值。

    這樣處理的邏輯是,首先檢查某塊內存的值是否跟之前讀取時的一樣,如不一樣則表示期間此期望值已經被別的線程更改過,捨棄本次操作,反之則說明期間沒有其他線程對此內存進行操作,可以把新值設置給此塊內存。

    CAS具有原子性,它的原子性由CPU硬件指令實現保證,即使用JNI調用Native方法調用由C++編寫的硬件級別指令,JDK中提供了Unsafe類執行這些操作。

  • 跟Synchronized相比,可重入鎖ReenterLock其實現原理有什麼不同?

    其實,鎖的實現原理基本都是爲了達到一個目的:讓所有線程都能看到某種標記。

    Synchronized通過在對象頭中設置標誌實現這一個目的,是一種JVM原生的鎖實現方式;而ReenterLock以及所有基於Lock接口的實現類,都是通過一個volatile修飾的int型變量,並保證每個線程都能擁有對該int值的可見性和原子修改,其本質基於AQS框架實現的。

  • 儘可能詳細地對比下Synchronized和ReenterLock的異同。

    ReennterLock是Lock的實現類,是一個互斥的同步鎖。從功能角度,ReenterLock比Synchronized的同步操作更精細(因爲可以像普通對象一樣使用),甚至實現Synchronized沒有的高級功能,如:

    • 等待可中斷:當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,對處理執行時間非常長的同步塊很有用。
    • 帶超時的獲取鎖嘗試:在指定的時間範圍內獲取鎖,如果時間到了仍然無法獲取則返回。
    • 可以判斷是否有線程在排隊等待獲取鎖。
    • 可以響應中斷請求:與Synchronized不同,當獲取到鎖的線程被中斷時,能夠響應中斷,中斷異常將會被拋出,同時鎖會被釋放。
    • 可以實現公平鎖。

    從鎖釋放的角度,Synchronized在JVM層面上實現的,不但可以通過一些監控工具監控Synchronized的鎖定,而且在代碼執行出現異常時,JVM會自動釋放鎖定;但是使用Lock則不行,Lock是通過代碼實現的,要保證鎖一定會被釋放,就必須將unLock()放到finall{}中。

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章