細說sychronized關鍵字

sychronized用法

修飾代碼段

public class Test {
	Object lock = new Object();
	int i = 0;
	public void f() {
		sychronized (lock) {
			i++;
		}
	}
}

適用於方法體比較大或者耗時,但需要同步的代碼塊比較短的場景。

修飾非靜態類方法

public class Test {
	
	int i = 0;
	public sychronized void f() {
		i++;
	}
}

當sychronized關鍵字修飾一個非靜態方法的時候,其鎖對象是所修飾方法所屬的實例。也就是說若a實例有m1和m2兩個方法都被sychronized關鍵字修飾,那麼他們是沒法在多個線程中同時執行的,存在對a實例的鎖競爭。

修飾靜態方法

public class Test {
	public sychronized void f() {
		System.out.println("test sychronized");
	}
}

當sychronized修飾靜態方法是,其鎖對象是所修飾方法所屬的class對象。

sychronized原理

對sychronized支持的實現是在JVM層面的,每個Java對象都存在一個叫做對象監視器的結構,而同步過程就是依賴於對這個同步監視器的持有權的競爭來實現的。下面介紹一個概念————對象頭。

Java對象頭

每個Java對象在JVM中都分爲三塊區域:對象頭,實例數據和填充對齊。

  • 實例數據:存放類及其父類的屬性信息
  • 填充對齊:由於虛擬機要求對象起始地址必須是8字節的整數倍。所以不滿整數倍的會有一些額外的空間來補齊,類似於C語言中的結構體。

而對象頭則是存儲了一些Java對象的額外信息,主要包括一些運行時數據(Mark Word)、類型指針、若對象爲數組,則還包括數組長度。運行時數據有:hashcode、GC分代年齡、鎖狀態標識、以及根據不同鎖的類型,該結構的內容也會有一些變化。sychronized用的鎖就是存在Java對象頭裏的。在64位虛擬機下,Mark Word是64bit。不同鎖的狀態下,其結構如下:

  • 無鎖:(25bit)Unused + (31bit)HashCode + (1bit)cms_free + (4bit)分代年齡 + (1bit)0 + (2bit) 鎖標誌位01
  • 偏向鎖:(54bit)ThreadID + (2bit)Epoch + (1bit)cms_free + (4bit)分代年齡 + (1bit)1 + (2bit) 鎖標誌位01
  • 輕量級鎖:(62bit) ptr_to_lock_record + (2bit) 鎖標誌位00
  • 重量級鎖:(62bit) ptr_to_heavyweight_monitor + (2bit) 鎖標誌位10

還有一種GC情況下,其結構爲:(62bit) Unused + (2bit) GC標記11

ObjectMonitor

這裏我們先討論重量級鎖的情況,也就是sychronized常說的對象鎖,此時Mark Word中的前62bit是一個指向重量級鎖對象的指針,sychronized在JVM中是通過monitorenter和monitorexit指令來實現的,在底層則是通過爭奪重量級鎖對象的方式來實現方法同步和代碼塊同步。鎖對象被定義爲ObjectMonitor,其結構如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 獲取鎖的次數
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; // 當前持有鎖的線程
    _WaitSet      = NULL; // 處於wait狀態的線程,會被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 處於等待鎖block狀態的線程,會被加入到該列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有兩個隊列,_WaitSet_EntryList,用來保存ObjectWaiter對象列表( 每個等待鎖的線程都會被封裝成ObjectWaiter對象 )

當多個線程同時訪問一段同步代碼時,首先會進入 _EntryList 集合,當線程獲取到對象的monitor後進入 _Owner 區域並把monitor中的owner變量設置爲當前線程同時monitor中的計數器count加1。

若線程調用 wait() 方法,將釋放當前持有的monitor,owner變量恢復爲null,count自減1,同時該線程進入 WaitSet集合中等待被喚醒。

若當前線程執行完畢也將釋放monitor(鎖)並復位變量的值,以便其他線程進入獲取monitor(鎖)。

monitor對象存在於每個Java對象的對象頭中(存儲的指針的指向),synchronized鎖便是通過這種方式獲取鎖的,也是爲什麼Java中任意對象可以作爲鎖的原因,同時也是notify/notifyAll/wait等方法存在於頂級對象Object中的原因。

synchronized代碼塊底層原理

同步語句塊的實現使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代碼塊的開始位置,monitorexit指令則指明同步代碼塊的結束位置。

當執行monitorenter指令時,當前線程將試圖獲取對象鎖的持有權,當_count爲0時,那線程可以成功取得對象鎖,並將計數器值設置爲1,取鎖成功。如果當前線程已經擁有某一對象鎖,那它可以重入這個鎖,重入時計數器的值也會加1。倘若其他線程已經擁有對象鎖的所有權,則當前線程阻塞,直到正在執行的線程執行monitorexit指令完畢,執行線程將釋放鎖並設置計數器值爲0,其他線程將有機會持有鎖。

synchronized方法底層原理

方法級的同步是隱式,即無需通過字節碼指令來控制的,它實現在方法調用和返回操作之中。JVM可以從方法常量池中的方法表結構中的ACC_SYNCHRONIZED 訪問標誌區分一個方法是否同步方法。

當方法調用時,調用指令將會檢查方法的ACC_SYNCHRONIZED訪問標誌是否被設置,如果設置了,執行線程將先持有monitor(虛擬機規範中用的是管程一詞), 然後再執行方法,最後再方法完成(無論是正常完成還是非正常完成)時釋放monitor。在方法執行期間,執行線程持有了monitor,其他任何線程都無法再獲得同一個monitor。

鎖升級過程

早起的Java版本中,synchronized屬於重量級鎖,效率低下,因爲監視器鎖(monitor)是依賴於底層的操作系統的Mutex Lock來實現的,而操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是爲什麼早期的synchronized效率低的原因。

Java 6之後,爲了減少獲得鎖和釋放鎖所帶來的性能消耗,引入了輕量級鎖和偏向鎖。鎖的狀態總共有四種,無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖可以從偏向鎖升級到輕量級鎖,再升級的重量級鎖,但是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級。

偏向鎖

偏向鎖是JDK6中的重要引進,因爲HotSpot作者經過研究實踐發現,在大多數情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低,引進了偏向鎖。

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

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

引入偏向鎖主要目的是:爲了在沒有多線程競爭的情況下儘量減少不必要的輕量級鎖執行路徑。因爲輕量級鎖的加鎖解鎖操作是需要依賴多次CAS原子指令的,而偏向鎖只需要在置換ThreadID的時候依賴一次CAS原子指令(由於一旦出現多線程競爭的情況就必須撤銷偏向鎖,所以偏向鎖的撤銷操作的性能損耗也必須小於節省下來的CAS原子指令的性能消耗)。

輕量級鎖是爲了在線程交替執行同步塊時提高性能,而偏向鎖則是在只有一個線程執行同步塊時進一步提高性能。

當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,以後該線程進入和退出同步塊時不需要花費CAS操作來爭奪鎖資源,只需要檢查是否爲偏向鎖、鎖標識爲以及ThreadID即可。

偏向鎖的釋放採用了 一種只有競爭纔會釋放鎖的機制,線程是不會主動去釋放偏向鎖,需要等待其他線程來競爭。

輕量級鎖

引入輕量級鎖的主要目的是在沒有多線程競爭的前提下,減少傳統的重量級鎖使用操作系統互斥量產生的性能消耗。其適用場景爲線程交替執行同步塊的情況。當關閉偏向鎖功能或者多個線程競爭偏向鎖導致偏向鎖升級爲輕量級鎖,則會嘗試獲取輕量級鎖。其步驟如下:

  1. 在線程進入同步塊時,如果同步對象鎖狀態爲無鎖狀態(鎖標誌位爲“01”狀態,是否爲偏向鎖爲“0”),虛擬機首先將在當前線程的棧幀中建立一個名爲鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,官方稱之爲 Displaced Mark Word;
  2. 拷貝對象頭中的Mark Word複製到鎖記錄(Lock Record)中;
  3. 拷貝成功後,虛擬機將使用CAS操作嘗試將對象Mark Word中的Lock Word更新爲指向當前線程Lock Record的指針,並將Lock record裏的owner指針指向object mark word。如果更新成功,則執行步驟(4),否則執行步驟(5);
  4. 如果這個更新動作成功了,那麼當前線程就擁有了該對象的鎖,並且對象Mark Word的鎖標誌位設置爲“00”,即表示此對象處於輕量級鎖定狀態;
  5. 如果這個更新操作失敗了,虛擬機首先會檢查對象Mark Word中的Lock Word是否指向當前線程的棧幀,如果是,就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行。否則說明多個線程競爭鎖,進入自旋執行(3),若自旋結束時仍未獲得鎖,輕量級鎖就要膨脹爲重量級鎖,鎖標誌的狀態值變爲“10”,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,當前線程以及後面等待鎖的線程也要進入阻塞狀態。

對於輕量級鎖,其性能提升的依據是 “對於絕大部分的鎖,在整個生命週期內都是不會存在競爭的”,如果打破這個依據則除了互斥的開銷外,還有額外的CAS操作,因此在有多線程競爭的情況下,輕量級鎖比重量級鎖更慢。

自旋鎖

輕量級鎖失敗後,虛擬機爲了避免線程真實地在操作系統層面掛起,還會進行一項稱爲自旋鎖的優化手段。這是基於在大多數情況下,線程持有鎖的時間都不會太長,如果直接掛起操作系統層面的線程可能會得不償失,畢竟操作系統實現線程之間的切換時需要從用戶態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,因此自旋鎖會假設在不久將來,當前的線程可以獲得鎖,因此虛擬機會讓當前想要獲取鎖的線程做幾個空循環(這也是稱爲自旋的原因),一般不會太久,可能是50個循環或100循環,在經過若干次循環後,如果得到鎖,就順利進入臨界區。如果還不能獲得鎖,那就會將線程在操作系統層面掛起,這就是自旋鎖的優化方式,這種方式確實也是可以提升效率的。最後沒辦法也就只能升級爲重量級鎖了。

鎖消除

消除鎖是虛擬機另外一種鎖的優化,這種優化更徹底,Java虛擬機在JIT編譯時(可以簡單理解爲當某段代碼即將第一次被執行時進行編譯,又稱即時編譯),通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的請求鎖時間。

鎖的比較

ReetrantLock區別

  • sychronized是在JVM層面提供的支持,而Lock接口的系列實現則是在jdk層面和操作系統層面。
  • sychronized的實現涉及到鎖的升級,而ReetrantLock的實現則是通過AQS結構,CAS保證原子性,volatile保證可見性
  • synchronized 不需要用戶去手動釋放鎖,synchronized 代碼執行完後系統會自動讓線程釋放對鎖的佔用; ReentrantLock則需要用戶去手動釋放鎖,如果沒有手動釋放鎖,就可能導致死鎖現象。一般通過lock()和unlock()方法配合try/finally語句塊來完成,使釋放更加靈活。
  • synchronized是不可中斷類型的鎖,除非加鎖的代碼中出現異常或正常執行完成; ReentrantLock則可以中斷,可通過trylock(long timeout,TimeUnit unit)設置超時方法或者將lockInterruptibly()放到代碼塊中,調用interrupt方法進行中斷。
  • synchronized爲非公平鎖 ReentrantLock則即可以選公平鎖也可以選非公平鎖,通過構造方法new ReentrantLock時傳入boolean值進行選擇,爲空默認false非公平鎖,true爲公平鎖。
  • synchronized不能綁定; ReentrantLock通過綁定Condition結合await()/singal()方法實現線程的精確喚醒,而不是像synchronized通過Object類的wait()/notify()/notifyAll()方法要麼隨機喚醒一個線程要麼喚醒全部線程。
  • synchronzied鎖的是對象,鎖是保存在對象頭裏面的,根據對象頭數據來標識是否有線程獲得鎖/爭搶鎖;ReentrantLock鎖的是線程,根據進入的線程和int類型的state標識鎖的獲得/爭搶。

其他

可重入性

從互斥鎖的設計上來說,當一個線程試圖操作一個由其他線程持有的對象鎖的臨界資源時,將會處於阻塞狀態,但當一個線程再次請求自己持有對象鎖的臨界資源時,這種情況屬於重入鎖,請求將會成功,在java中synchronized是基於原子性的內部鎖機制,是可重入的,因此在一個線程調用synchronized方法的同時在其方法體內部調用該對象另一個synchronized方法,也就是說一個線程得到一個對象鎖後再次請求該對象鎖,是允許的,這就是synchronized的可重入性。

線程中斷

當一個線程處於被阻塞狀態或者試圖執行一個阻塞操作時,使用Thread.interrupt()方式中斷該線程,注意此時將會拋出一個InterruptedException的異常,同時中斷狀態將會被複位(由中斷狀態改爲非中斷狀態)。

但是,當線程處於運行期且是非阻塞狀態,直接調用interrupt()方法中斷線程,是不會得到任何響應的。

線程的中斷操作對於正在等待獲取的鎖對象的synchronized方法或者代碼塊並不起作用,也就是對於synchronized來說,如果一個線程在等待鎖,那麼結果只有兩種,要麼它獲得這把鎖繼續執行,要麼它就保存等待,即使調用中斷線程的方法,也不會生效。

等待喚醒機制

每個對象都有notify/notifyAll和wait這三個頂級方法,在使用這3個方法時,必須處於synchronized代碼塊或者synchronized方法中,否則就會拋出IllegalMonitorStateException異常,這是因爲調用這幾個方法前必須拿到當前對象的監視器monitor對象,也就是說notify/notifyAll和wait方法依賴於monitor對象,在前面的分析中,我們知道monitor 存在於對象頭的Mark Word 中(存儲monitor引用指針),而synchronized關鍵字可以獲取 monitor ,這也就是爲什麼notify/notifyAll和wait方法必須在synchronized代碼塊或者synchronized方法調用的原因。

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