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

  • 2020 年 3 月 18 日
  • 筆記

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會消除它身上的偏向鎖,將鎖恢復到標準的輕量級鎖。

  假設當前虛擬機啟用了偏向鎖(啟用參數 -XX:+UseBiasedLocking,這就是jdk1.6的默認值),那麼,當鎖對象第一次被線程獲取時,虛擬機將會把對象頭中的標誌位設位01,即偏向模式。同時使用CAS操作把獲取到這個鎖的線程線程的ID記錄在對象的Mark Word之中,如果CAS操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時,虛擬機都可以不在進行任何同步操作。當有另外的線程去嘗試獲取這個鎖時,鎖對象就會根據目前處於的狀態,撤銷偏向鎖轉為無鎖(01)或輕量級鎖(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.總結 

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

優點

缺點

適用場景

偏向鎖

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

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

適用於只有一個線程訪問同步塊場景。

輕量級鎖

競爭的線程不會阻塞,提高了程序的響應速度。

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

追求響應時間。

同步塊執行速度非常快。

重量級鎖

線程競爭不使用自旋,不會消耗CPU。

線程阻塞,響應時間緩慢。

追求吞吐量。

同步塊執行速度較長。