1. 程式人生 > >synchronized實現原理及其優化-(自旋鎖,偏向鎖,輕量鎖,重量鎖)

synchronized實現原理及其優化-(自旋鎖,偏向鎖,輕量鎖,重量鎖)

1.synchronized概述:

  synchronized修飾的方法或程式碼塊相當於併發中的臨界區,即在同一時刻jvm只允許一個執行緒進入執行。synchronized是通過鎖機制實現同一時刻只允許一個執行緒來訪問共享資源的。另外synchronized鎖機制還可以保證執行緒併發執行的原子性,有序性,可見性。

2.synchronized的原理:

  我們先通過反編譯下面的程式碼來看看Synchronized是如何實現對程式碼進行同步的:

  步驟:首先找到存放java檔案的目錄,在位址列輸入cmd進入命令列,然後執行javac test.java命令,形成class檔案,接著執行javap -v test.class進行反編譯。

【程式碼示例】:同步方法

 1 class thread  extends Thread{
 2     Object obj=new Object();
 3     @Override
 4     public synchronized void run() {
 5         System.out.println("run...");
 6     }
 7 }
 8 public class test {
 9     public static void main(String[] args) {
10         new thread().start();
11     }
12 }

反編譯結果:

  從反編譯的結果來看,Synchronized同步方法相對於普通方法,其常量池中多了ACC_SYNCHRONIZED標示符。JVM就是根據該標示符來實現方法的同步的:當方法呼叫時,呼叫指令將會檢查方法是否設定訪問標誌 ACC_SYNCHRONIZED ,如果設定了,執行執行緒將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件。 

 【程式碼演示】:同步程式碼塊。

 1 class thread  extends Thread{
 2     Object obj=new Object();
 3     @Override
 4     public  void run() {
 5         synchronized(obj){
 6             System.out.println("run...");
 7         }
 8     }
 9 }
10 public class test {
11     public static void main(String[] args) {
12         new thread().start();
13     }
14 }

反編譯結果: 

 關於這三條指令的作用,我們直接參考JVM規範中描述:

monitorenter :

  每個物件有一個監視器鎖(monitor),當monitor被佔用時該物件就會處於鎖定狀態。執行緒執行monitorenter指令時嘗試獲取monitor的所有權,如果monitor的進入數為0,則該執行緒進入monitor,然後將進入數設定為1,該執行緒即為monitor的所有者。

如果執行緒已經佔有該monitor,只是重新進入,則將monitor的進入數加1.如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權。

monitorexit: 

  執行monitorexit的執行緒必須是monitor對應的所有者。指令執行時,monitor的進入數減1,如果減1後進入數為0,那執行緒退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的執行緒可以嘗試去獲取這個 monitor 的所有權。 

  通過這兩段描述,我們應該能很清楚的看出Synchronized同步塊的實現原理,不過還有兩點需要我們注意下,首先synchronized同步塊對同一條執行緒來說是可重入的,不會出現自己將自己鎖死的問題,但同步塊在已進入程式執行完之前,是會阻塞後面其他執行緒的進入。通過上圖我們也可知道Synchronized同步塊的語義底層其實就是通過一個monitor的物件來完成,而我們前面學習的wait/notify等方法的呼叫也依賴於monitor物件,這也就是為什麼只有在同步的塊或者方法中才能呼叫wait/notify等方法,否則會丟擲java.lang.IllegalMonitorStateException的異常的原因。

  至於為什麼Synchronized同步塊要使用兩個monitorexit指令?因為如果只使用一個,當執行緒在執行的過程中發生異常而無法釋放鎖時,就會造成死鎖現象,因此另一個monitorexit指令的作用就是線上程發生異常時釋放鎖的。

 3.Synchronized的優化

  現在我們應該知道,Synchronized是通過物件內部的一個叫做監視器鎖(monitor)來實現的。但是監視器鎖本質又是依賴於底層的作業系統的Mutex Lock來實現的。而作業系統實現執行緒之間的切換就需要從使用者態轉換到核心態,這個成本非常高,狀態之間的轉換需要相對比較長的時間,這就是為什麼Synchronized效率低的原因。因此,這種依賴於作業系統Mutex Lock所實現的鎖我們稱之為“重量級鎖”。

  在jdk1.5之前,只有synchronized重量級鎖,實現需要藉助作業系統,是比較消耗效能的操作,在1.6之中為了提高效能,便對synchronized鎖進行了優化,實現了各種鎖優化技術,如:適應性自旋,鎖消除,鎖粗化,輕量級鎖,偏向鎖。

  為了更好的掌握這幾種鎖,首先我們先學習一下Java物件的記憶體佈局。

4.Java物件的記憶體佈局

 

 

 上圖就是Java物件記憶體佈局中包含三大塊:

物件頭區域:
HotSpot虛擬機器的物件頭包括兩部分資訊:

  1.markword:第一部分markword,用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,這部分資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為32bit和64bit,官方稱它為“MarkWord”。

  2.Class:物件頭的另外一部分是Class型別指標,即物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項.

  3.陣列長度(只有陣列物件有):如果物件是一個數組, 那在物件頭中還必須有一塊資料用於記錄陣列長度。如果不是陣列,不存在陣列長度的物件頭資訊。

例項資料
例項資料部分是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來。

對齊填充
第三部分對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說,就是物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的倍數(1倍或者2倍),因此,當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。

  通過上訴內容我們也可知道Synchronized鎖就在Java的物件頭中。

下面這個是32位的Mark Word的預設結構:

鎖狀態

25 bit

4bit

1bit

2bit

23bit

2bit

是否是偏向鎖

鎖標誌位

輕量級鎖

指向棧中鎖記錄的指標

00

重量級鎖

指向互斥量(重量級鎖)的指標

10

GC標記

11

偏向鎖

執行緒ID

Epoch

物件分代年齡

1

01

無鎖

物件的hashCode

物件分代年齡

0

01

從圖中我們可以知道,鎖的狀態有四種:無鎖狀態,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態。這幾種狀態是隨著執行緒競爭情況逐漸升級的,鎖可以升級但不允許降級,目的是提高獲得鎖和釋放鎖的效率。

5.輕量級鎖

  輕量級鎖是jdk1.6中加入的新型鎖機制,它名字中的“輕量級”是相對於使用作業系統互斥量來實現的重量級鎖而言的。首先需要強調的是,輕量級鎖並不是來代替重量級鎖的,它的本意是在沒有多執行緒競爭的前提下,減少傳統重量級鎖使用作業系統互斥量產生的效能消耗。在解釋輕量級鎖的執行過程之前,先明白一點,輕量級鎖所適應的場景是執行緒交替執行同步塊的情況,如果存在同一時間訪問同一鎖的情況,就會導致輕量級鎖膨脹為重量級鎖。

  在輕量級鎖的執行過程上,在程式碼進入同步塊的時候,如果此同步物件沒有被鎖定,也就是說此時物件的鎖標記位為“01”狀態,那麼虛擬機器首先將線上程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced字首),這時候執行緒堆疊與物件頭的狀態如圖1。

  然後虛擬機器將使用CAS操作嘗試將物件的Mark Word更新為指向Lock Record的指標,並將Lock record裡的owner指標指向object mark word。如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件Mark Word的鎖標誌位將轉變位00,即表示此物件處於輕量級鎖定狀態,這時候執行緒堆疊與物件頭的狀態如圖2。

 

  如果這個更新操作失敗了,虛擬機器首先會檢查物件的Mark Word是否指向當前執行緒的棧幀,如果是,就說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行;否則說明這個鎖已經被其他執行緒搶佔了,由於有多個執行緒競爭鎖,輕量級鎖就要膨脹為重量級鎖,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。 而當前執行緒便嘗試使用自旋來獲取鎖,自旋就是為了不讓執行緒阻塞,而採用迴圈去獲取鎖的過程。

   上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也是通過CAS操作來進行的,如果物件的Mark Word仍然指向著執行緒的鎖記錄,那就用CAS操作把物件當前的Mark Word和執行緒中複製的Displace Mark Word替換回來,如果替換成功,整個同步過程就完成了。如果替換失敗,說明有其他執行緒嘗試獲取過該鎖,那就要在釋放鎖的同時,喚醒被掛起的執行緒。

  如果執行緒之間不存在鎖的競爭,與重量級鎖相比,輕量級鎖避免使用了互斥訊號量,只使用了簡單的CAS操作,但如果存在鎖競爭,輕量級鎖除了使用互斥訊號量,還要額外發生CAS操作,因此在有競爭的情況下,輕量級鎖會比重量級鎖開銷更大。

 

6.偏向鎖  

   Java偏向鎖是在jdk1.6中引入的,它的目的是消除資料無競爭情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用CAS操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除連CAS都不做。偏向鎖,顧名思義,它會偏向於第一個訪問它的執行緒,如果在執行過程中,同步鎖只有一個執行緒訪問,不存在多執行緒爭用鎖的情況,則持有偏向鎖的執行緒將永遠是不需要在進行同步。如果執行過程中,遇到其它執行緒搶佔資源,則持有偏向鎖的執行緒會被掛起,jvm會消除它身上的偏向鎖,將鎖恢復到標準的輕量級鎖。

6.1由於偏向鎖會轉換成輕量級鎖,那麼許多人可能就會疑惑為什麼不直接使用輕量級鎖呢?

  引入偏向級鎖是為了減少在無多執行緒競爭的情況下,儘量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的獲取及釋放依賴多次CAS原子指令,而偏向鎖只需要在置換執行緒時,使用CAS操作把獲取到這個鎖的執行緒執行緒的ID記錄在物件的Mark Word之中,從而減少效能消耗,不過遇到多執行緒競爭的情況時就必須撤銷偏向鎖。另外一個原因就是,在HotSpot虛擬機器中,大多時候是不存在鎖競爭的,常常是一個執行緒多次獲取同一個鎖,因此直接使用輕量級鎖會增加很多不必要的消耗,所以可以才引入了偏向鎖。

6.2偏向鎖的升級過程

  假設當前虛擬機器啟用了偏向鎖(啟用引數 -XX:+UseBiasedLocking,這就是jdk1.6的預設值),那麼,當鎖物件第一次被執行緒獲取時,虛擬機器將會把物件頭中的標誌位設位01,即偏向模式,同時使用CAS操作把獲取到這個鎖的執行緒執行緒的ID記錄在物件的Mark Word之中,由於偏向鎖不會主動釋放鎖,所以持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不在進行任何同步操作。但當有另外的執行緒去嘗試獲取這個鎖時,就需要檢視鎖物件頭中記錄的那個執行緒是否還存活,如果沒有存活,那麼鎖物件就會被置為無鎖狀態,且這時候其他執行緒是可以競爭該鎖,如果獲取成功該鎖,該鎖就又被設為偏向鎖;如果物件頭中記錄的那個執行緒仍存活,那就立即查詢該執行緒的棧幀資訊,判斷是否還需要此鎖,如果不需要,那麼該鎖物件就會被置為無鎖狀態,且偏向其他新的執行緒,如果還需要此鎖,那麼就先暫停當前執行緒,撤銷掉偏向鎖,升級為輕量級鎖(00)的狀態。

  偏向鎖可以提高帶有同步但無競爭的程式效能。但是如果程式中大多數鎖總是被多個不同的執行緒訪問,那麼偏向鎖模式就是多餘的。

  

重量級鎖、輕量級鎖和偏向鎖之間轉換

 

7.自旋鎖

  自旋鎖原理非常簡單,如果持有鎖的執行緒能在很短時間內釋放鎖資源,那麼那些等待競爭鎖的執行緒就不需要做核心態和使用者態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),等持有鎖的執行緒釋放鎖後即可立即獲取鎖,這樣就避免使用者執行緒和核心的切換的消耗。

  但是執行緒自旋是需要消耗cup的,說白了就是讓cup在做無用功,如果一直獲取不到鎖,那執行緒也不能一直佔用cup自旋做無用功,所以需要設定一個自旋等待的最大時間。

  如果持有鎖的執行緒執行的時間超過自旋等待的最大時間仍沒有釋放鎖,這時其他爭用執行緒會停止自旋進入阻塞狀態。

優缺點:

  自旋鎖儘可能的減少了執行緒的阻塞,這對於鎖競爭不激烈,且佔用鎖時間非常短的程式碼塊來說效能大幅度的提升了,因為自旋的消耗會小於執行緒阻塞掛起再喚醒的操作的消耗,這些操作會導致執行緒發生兩次上下文切換!但是如果鎖的競爭激烈,或者持有鎖的執行緒需要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,因為自旋鎖在獲取鎖前一直都是佔用cpu做無用功,會白白浪費CPU資源。同時如果有大量執行緒在競爭一個鎖,會導致獲取鎖的時間很長,這時候執行緒自旋的消耗就大於執行緒阻塞掛起操作的消耗,同時其它需要cup的執行緒也因為不能獲取到cpu,而造成cpu的浪費,這種情況下也不適合使用自旋鎖;

  自旋鎖的目的是為了佔著CPU的資源不釋放,等到獲取到鎖立即進行處理。但是如何去選擇自旋的執行時間呢?如果自旋執行時間太長,會有大量的執行緒處於自旋狀態佔用CPU資源,進而會影響整體系統的效能。因此自旋的週期選的額外重要!

JVM對於自旋週期的選擇,在jdk1.5時規定,當自旋操作超過了預設的限定次數10次,仍然沒有獲取到鎖,那就應該使用傳統的方式去掛起執行緒(當然使用者可也以可使用引數-XX:PreBlockSpin來更改)。在1.6時便引入了適應性自旋鎖,適應性自旋鎖意味著自旋的時間不在是固定的了,而是由前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態來決定,基本認為一個執行緒上下文切換的時間是最佳的一個時間,同時JVM還針對當前CPU的負荷情況做了較多的優化

  1. 如果平均負載小於CPUs則一直自旋

  2. 如果有超過(CPUs/2)個執行緒正在自旋,則後來執行緒直接阻塞

  3. 如果正在自旋的執行緒發現Owner發生了變化則延遲自旋時間(自旋計數)或進入阻塞

  4. 如果CPU處於節電模式則停止自旋

  5. 自旋時間的最壞情況是CPU的儲存延遲(CPU A儲存了一個數據,到CPU B得知這個資料直接的時間差)

  6. 自旋時會適當放棄執行緒優先順序之間的差異

8.各種鎖的使用場景

 

  偏向鎖:通常只有一個執行緒訪問臨界區。

 

  輕量級鎖:可以有多個執行緒交替進入臨界區,在競爭不激烈的時候,稍微自旋就能獲得鎖。

 

  重量級鎖:執行緒間出現了激烈的競爭就需要使用重量級鎖,此時未獲取到鎖的執行緒會進入阻塞佇列,需要作業系統介入。

  jvm設定偏向鎖和輕量級鎖,就是為了避免阻塞,避免作業系統的介入。

 

9.總結 

  本文重點介紹了JDk中採用輕量級鎖和偏向鎖等對Synchronized的優化,但是這兩種鎖也不是完全沒缺點的,比如競爭比較激烈的時候,不但無法提升效率,反而會降低效率,因為多了一個鎖升級的過程,這個時候就需要通過-XX:-UseBiasedLocking來禁用偏向鎖。下面是這幾種鎖的對比:

優點

缺點

適用場景

偏向鎖

加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距。

如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗。

適用於只有一個執行緒訪問同步塊場景。

輕量級鎖

競爭的執行緒不會阻塞,提高了程式的響應速度。

如果始終得不到鎖競爭的執行緒使用自旋會消耗CPU。

追求響應時間。

同步塊執行速度非常快。

重量級鎖

執行緒競爭不使用自旋,不會消耗CPU。

執行緒阻塞,響應時間緩慢。

追求吞吐量。

同步塊執行速度較長。