在java語言中,有兩個鎖一個是jdk層面的ReentrantLock
,一個是jvm層面的synchronized
,這兩個鎖是我們掌握併發程式設計中不可或缺的兩個類。本文主要從使用、原理兩個方面來介紹synchronized
。
使用
synchronized用法
public class SynchronizedTest { Object o1 = new Object(); static Object o2 = new Object(); @SneakyThrows public synchronized void test1() { Thread.sleep(1000); } @SneakyThrows public static synchronized void test2() { Thread.sleep(1000); } @SneakyThrows public void test3() { synchronized (o1) { Thread.sleep(1000); } } @SneakyThrows public void test4() { synchronized (o2) { Thread.sleep(1000); } } @SneakyThrows public void test5() { synchronized (this) { Thread.sleep(1000); } } @SneakyThrows public void test6() { synchronized (SynchronizedTest.class) { Thread.sleep(1000); } } }
這是synchronized的幾種常見用法,但你瞭解其中鎖的粒度麼?哪些是鎖物件、哪些是鎖整個類的呢?下面寫了一個測試的執行程式碼,透過程式碼的執行結果最有說服力。
程式碼說明:
獲取開始時間;
分別建立兩個執行緒,並且new兩個
SynchronizedTest
,使用物件分別呼叫上述方法;開啟兩個執行緒;
等待兩個執行緒執行完成;
列印執行的總時長;
如果時長為1s,則說明鎖的粒度是當前new出來的物件;
如果時長為2s,則說明鎖的力度是當前類。
@SneakyThrows public static void main(String[] args) { long startTime = System.currentTimeMillis(); Thread t1 = new Thread(() -> new SynchronizedTest().test1()); Thread t2 = new Thread(() -> new SynchronizedTest().test1()); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("執行時間: " + (System.currentTimeMillis() - startTime) / 1000); }
鎖粒度
大家可以根據上述程式碼自己執行一遍,這樣印象會更深;下面我直接貼出採用各使用方法時鎖的粒度。
test1 : 鎖的是當前實體的該方法,執行結果為1s.
test2 : 鎖的是當前類
,執行結果為2s.
test3 : 鎖的是當前實體的o1屬性,執行結果為1s.
test4 : 鎖的是當前類的o2屬性
,執行結果為2s.
test5 : 鎖的是當前物件,執行結果為1s.
test6 : 鎖的是當前類
,執行結果為1s.
原理
在原理方面,會先看一下加了synchronized後位元組碼會有什麼變化,再介紹一下在jdk1.6做了哪些最佳化,然後著重介紹一下鎖的各個階段以及重量級鎖的實現方法。
位元組碼
首先我們來分析一下,使用synchronized時位元組碼層面會如何變化,這裏我寫了一個類再透過javac、javap來檢視一下位元組碼有什麼變化。java程式碼如下:
public class Test { Object o = new Object(); public void test1(){ } public void test2(){ synchronized (o){ } } }
在該java型別目錄下,執行javac Test.java
,再執行 javap -v Test.class
,執行完後會出現稍微能看明白一點的彙編程式碼,其中這兩個方法彙編程式碼如下:
public void test1(); descriptor: ()V flags: (0x0001) ACC_PUBLIC Code: stack=0, locals=1, args_size=1 0: return LineNumberTable: line 11: 0 public void test2(); descriptor: ()V flags: (0x0001) ACC_PUBLIC Code: stack=2, locals=3, args_size=1 /** * 0: 將物件引用壓入運算元棧 */ 0: aload_0 /** * 1: 獲取指定欄位的值並壓入運算元棧 */ 1: getfield #7 // Field o:Ljava/lang/Object; /** * 4: 複製運算元棧頂元素 */ 4: dup /** * 5: 將運算元棧頂元素儲存到區域性變數 1 中 */ 5: astore_1 /** * 6: 進入同步塊,獲取物件的監視器鎖 */ 6: monitorenter /** * 7: 將區域性變數 1 所引用的物件壓入運算元棧 */ 7: aload_1 /** * 8: 退出同步塊,釋放物件的監視器鎖 */ 8: monitorexit /** * 9: 跳轉到偏移量為 17 的指令 */ 9: goto 17 /** * 12: 將異常物件引用儲存到區域性變數 2 中 */ 12: astore_2 /** * 13: 將區域性變數 1 所引用的物件壓入運算元棧 */ 13: aload_1 /** * 14: 退出同步塊,釋放物件的監視器鎖 */ 14: monitorexit /** * 15: 將區域性變數 2 所引用的異常物件壓入運算元棧 */ 15: aload_2 /** * 16: 丟擲運算元棧頂的異常 */ 16: athrow /** * 17: 方法返回 */ 17: return
這裏我給test2
方法增加了註釋,可以觀察到到加了synchronized後會多出來monitorenter
,monitorexit
兩個指令,後面我們分析原始碼時可以著重看一下monitor
是如何實現的鎖。
鎖最佳化
在JDK1.5的時候,Doug Lee推出了JUC包,在這裏實現了ReentrantLock,並且它的效能遠高於synchronized,所以JDK團隊就在JDK1.6中,對synchronized做了大量的最佳化。例如:
鎖消除:在synchronized修飾的程式碼中,如果不存在操作共享變數的情況,會觸發鎖消除,即便寫了synchronized,也不會觸發。如:
public int test(){ synchronized (o){ int i = 0; return ++i; } }
鎖膨脹:如果在一個方法中,頻繁的獲取和釋放鎖資源,就會最佳化為將鎖的範圍擴大,避免頻繁的競爭和獲取鎖資源帶來不必要的消耗。如:
public void test1() { for (int i = 0; i < 100000; i++) { synchronized (o) { } } }
鎖升級:在synchronized
在1.6版本之前,獲取不到鎖時,會立即掛起當前執行緒,後續釋放鎖時再喚醒競爭執行緒進行鎖的競爭。而到了1.6版本時,對synchronized
做了鎖升級的最佳化;
無鎖、匿名偏向:當前物件沒有作為鎖存在。
偏向鎖:如果當前鎖資源,只有一個執行緒在使用,那麼這個執行緒過來,只需要判斷,該物件的threadId指向的是否為當前執行緒。
如果是,直接獲取鎖;
如果不是,基於CAS的方式,嘗試透過cas將threadId指向當前執行緒。如果獲取不到,觸發鎖升級,升級為輕量級鎖。(偏向鎖狀態出現了鎖競爭的情況)
輕量級鎖:會採用自旋鎖的方式去頻繁的以CAS的形式獲取鎖資源(採用的是自適應自旋鎖)
如果cas成功則獲取鎖;
如果自旋了一定次數,沒獲取到鎖,鎖升級。
重量級鎖:就是最傳統的synchronized方式,獲取不到鎖資源,就掛起當前執行緒,等待其他執行緒釋放鎖並喚起該執行緒進行鎖資源競爭。
觀察鎖升級
爲了更直觀的觀察到鎖的升級過程,首先我在下面我貼了一張鎖資源物件頭中markdown在各個狀態下各個位元組含義的示意圖,大家對比著看一下。
其次在程式碼中引用了jol-core
,它可以在程式碼層面列印出物件頭資訊,接下來我會舉一些例子讓大家能更直觀的看到鎖的升級。
implementation 'org.openjdk.jol:jol-core:0.9'
無鎖
public static void main(String[] args) { Object o = new Object(); System.out.println(ClassLayout.parseInstance(o).toPrintable()); }
上面程式碼就是列印物件o的例項資訊,列印結果如下:
可以看到我用紅色框框圈出來的就是鎖的標識位001為無鎖狀態
偏向鎖
public static void main(String[] args) { Object o = new Object(); new Thread(() -> { synchronized (o) { //t1 - 偏向鎖 System.out.println(ClassLayout.parseInstance(o).toPrintable()); } }).start(); }
上面程式碼就是用一個執行緒獲取鎖資源,此時該鎖資源就會偏向到該執行緒,再看一下列印結果:
這兩張圖的結果都是正確的,不同的jdk版本會出現不同的結果,如果你本地執行發現是000
輕量級鎖的標識不用驚訝,這是因為你使用的jdk版本預設開啟了鎖的偏向延遲。我本地測試使用open jdk8是第一種結果,在程式碼執行前加上Thread.sleep(5000L);
會變成第二種結果,使用zulu jdk11、jdk17、jdk21都是都是000
,他們預設都不開啟偏向鎖。
偏向鎖在升級為輕量級鎖時,會涉及到偏向鎖撤銷,需要等到一個安全點(STW),纔會讓偏向鎖撤銷,在知道有併發時,就可以選擇不開啟偏向鎖,或者是設定偏向鎖延遲開啟。
因為JVM在啟動時,需要載入大量的.class檔案到記憶體中,這個操作會涉及到synchronized的使用,爲了避免出現偏向鎖撤銷操作,JVM啟動初期,有一個延遲4s開啟偏向鎖的操作
命令引數如下:
-XX:+/-UseBiasedLocking 啟動/禁用偏向鎖,預設虛擬機器啟動4秒後啟動偏向鎖 -XX:BiasedLockingStartupDelay 虛擬機器啟動後,立刻啟動偏向鎖
輕量級鎖
這裏不就舉例了,上個例子中不開啟偏向鎖會直接變更為輕量級鎖的。
重量級鎖
大家還記得上面在看位元組碼時有一個Monitor
物件,我們就來看一下這個物件中有什麼內容。
ObjectMonitor() { // 頭節點,儲存著 MarkWord _header = NULL; // 競爭鎖的執行緒個數 _count = 0; // 等待(wait)的執行緒個數 _waiters = 0; // 標識當前 synchronized 鎖重入的次數 _recursions = 0; // 關聯的物件 _object = NULL; // 持有鎖的執行緒 _owner = NULL; // 儲存等待(wait)執行緒的資訊,雙向連結串列 _WaitSet = NULL; // WaitSet 的鎖 _WaitSetLock = 0 ; // 負責的執行緒(可能用於特定的職責標識) _Responsible = NULL ; // 後繼節點(可能用於連結串列結構) _succ = NULL ; // 獲取鎖資源失敗後,執行緒要放到當前的單向連結串列中 _cxq = NULL ; // 下一個空閒節點(可能用於連結串列管理) FreeNext = NULL ; // 等待獲取鎖的執行緒佇列(_cxq 以及被喚醒的 WaitSet 中的執行緒,在一定機制下,會放到這裏) _EntryList = NULL ; // 自旋頻率 _SpinFreq = 0 ; // 自旋時鐘 _SpinClock = 0 ; // 是否為執行緒所有者的標識 OwnerIsThread = 0 ; // 前一個所有者執行緒的執行緒 ID _previous_owner_tid = 0; }
上述標識我自己翻譯了一遍,這個類還有很多實現方法,但由於都是c語言了,就不再詳細解釋了,可以觀察到這裏面有幾個屬性和ReentrantLock很像,大概也借鑑了ReentrantLock的實現。如重入次數
、連結串列
、等待執行緒
、持有鎖的執行緒
等,有一個區別是這裏採用了單向連結串列
,而ReentrantLock採用了雙向連結串列
因為ReentrantLock有找前驅節點的需求所以採用了雙向連結串列,例如在釋放鎖資源喚醒後置節點時,此時後置已經被取消,那麼就需要從tail節點往前查詢,又或者加入到連結串列中,從後插入只需要O(1)的時間複雜度。而ObjectMonitor
採用單向連結串列,由於我沒詳細看過程式碼,沒法給大傢俱體的觀點,如果有會c語言的同學可以幫忙解答一下。
總結
在jdk1.6以後在低併發時使用synchronized
的效能是高於lock
的,因為它是在JVM層面實現的,而且不需要顯式地獲取和釋放鎖。但是在高併發下synchronized
是不如lock
,例如lock支援讀寫鎖分離、執行緒掛起時插入到連結串列中時間複雜度為O(1)等。
除此之外lock
還支援更多使用場景,例如:公平鎖和非公平鎖、嘗試獲取鎖等。具體選擇哪個還是要根據具體的應用場景和需求來選擇,大家平時都使用哪個鎖呢?