【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{}中。

參考資料


免責聲明!

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



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