1. 程式人生 > >synchronized 到底該不該用?

synchronized 到底該不該用?

> 我是風箏,公眾號「古時的風箏」,一個兼具深度與廣度的程式設計師鼓勵師,一個本打算寫詩卻寫起了程式碼的田園碼農! 文章會收錄在 [JavaNewBee](https://github.com/huzhicheng/JavaNewBee) 中,更有 Java 後端知識圖譜,從小白到大牛要走的路都在裡面。 在多執行緒環境中,鎖的使用是避免不了的,使用鎖時候有多種鎖供我們選擇,比如 `ReentrantLock`、`CountDownLatch`等等,但是作為 Java 開發者來說,剛剛接觸多執行緒的時候,最早接觸和使用的恐怕非 `synchronized`莫屬了。那你真的瞭解`synchronized`嗎,今天我們就從以下幾個方面徹底搞懂 `synchronized`。 ![](https://hexo.moonkite.cn/blog/synchronized%E6%A6%82%E8%A7%88.png) 首先有一點要說明一下,各位可能或多或少都聽過這樣的說法:“synchronized 的效能不行,比顯式鎖差很多,開發中還是要慎用。” 大可不必有這樣的顧慮,要說在 JDK 1.6 之前,synchronized 的效能確實有點差,但是 JDK 1.6 之後,JDK 開發團隊已經持續對 synchronized 做了效能優化,其效能已經與其他顯式鎖基本沒有差距了。所以,在考慮是不是使用 `synchronized`的時候,只需要根據場景是否合適來決定,效能問題不用作為衡量標準。 ## 使用方法 synchronized 是一個關鍵字,它的一個明顯特點就是使用簡單,一個關鍵字搞定。它可以在一個方法上使用,也可以在一個方法中的某些程式碼塊上使用,非常方便。 ```java public class SyncLock { private Object lock = new Object(); /** * 直接在方法上加關鍵字 */ public synchronized void methodLock() { System.out.println(Thread.currentThread().getName()); } /** * 在程式碼塊上加關鍵字,鎖住當前例項 */ public void codeBlockLock() { synchronized (this) { System.out.println(Thread.currentThread().getName()); } } /** * 在程式碼塊上加關鍵字,鎖住一個變數 */ public void codeBlockLock() { synchronized (lock) { System.out.println(Thread.currentThread().getName()); } } } ``` 具體的使用可以參考我之前寫的這篇文章:TODO 依靠 JVM 中的 monitorenter 和 monitorexit 指令控制。通過 `javap -v`命令可以看到前面的例項程式碼中對 synchronized 關鍵字在位元組碼層面的處理,對於在程式碼塊上加 synchronized 關鍵字的情況,會通過 `monitorenter`和`monitorexit`指令來表示同步的開始和退出標識。而在方法上加關鍵字的情況,會用 `ACC_SYNCHRONIZED`作為方法標識,這是一種隱式形式,底層原理都是一樣的。 ```idl public synchronized void methodLock(); descriptor: ()V flags: ACC_PUBLIC, ACC_SYNCHRONIZED Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: invokestatic #3 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread; 6: invokevirtual #4 // Method java/lang/Thread.getName:()Ljava/lang/String; 9: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 12: return LineNumberTable: line 12: 0 line 13: 12 public void codeBlockLock(); descriptor: ()V flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: aload_0 1: dup 2: astore_1 3: monitorenter # 4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 7: invokestatic #3 // Method java/lang/Thread.currentThread:()Ljava/lang/Thread; 10: invokevirtual #4 // Method java/lang/Thread.getName:()Ljava/lang/String; 13: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 16: aload_1 17: monitorexit 18: goto 26 21: astore_2 22: aload_1 23: monitorexit 24: aload_2 25: athrow 26: return ``` ## 物件佈局 為什麼介紹 synchronized 要說到物件頭呢,這和它的鎖升級過程有關係,具體的鎖升級過程稍後會講到,作為鎖升級過程的資料支撐,必須要掌握物件頭的結構才能瞭解鎖升級的完整過程。 在 Java 中,任何的物件例項的記憶體佈局都分為物件頭、物件例項資料和對齊填充資料三個部分,其中物件頭又包括 MarkWord 和 型別指標。 ![](https://hexo.moonkite.cn/blog/image-20201110211241645.png) **物件例項資料:** 這部分就是物件的實際資料。 **對齊填充:** 因為 HotSpot 虛擬機器記憶體管理要求物件的大小必須是8位元組的整數倍,而物件頭正好是8個位元組的整數倍,但是例項資料不一定,所以需要對齊填充補全。 **物件頭:** *Klass 指標:* 物件頭中的 Klass 指標是用來指向物件所屬型別的,一個類例項究竟屬於哪個類,需要有地方記錄,就在這裡記。 *MarkWord:* 還有一部分就是和 synchronized 緊密相關的 MarkWord 了,主要用來儲存物件自身的執行時資料,如hashcode、gc 分代年齡等資訊。 MarkWord 的位長度為 JVM 的一個 Word 大小,32位 JVM 的大小為32位,64位JVM的大小為64位。 下圖是 64 位虛擬機器下的 MarkWord 結構說明,根據物件鎖狀態不同,某些位元位代表的含義會動態的變化,之所以要這麼設計,是因為不想讓物件頭佔用過大的空間,如果為每一個標示都分配固定的空間,那物件頭佔用的空間將會比較大。 ![](https://hexo.moonkite.cn/blog/image-20201111201043322.png) *陣列長度:* 要說明一下,如果是陣列物件的話, 由於陣列無法通過本身內容求得自身長度,所以需要在物件頭中記錄陣列的長度。 ### 原始碼中的定義 追根溯源,物件在 JVM 中是怎麼定義的呢?開啟 JVM 原始碼,找到其中物件的定義檔案,可以看到關於前面說的物件頭的定義。 ```c++ class oopDesc { friend class VMStructs; friend class JVMCIVMStructs; private: volatile markOop _mark; union _metadata { Klass* _klass; narrowKlass _compressed_klass; } _metadata; } ``` oop 是物件的基礎類定義,也就是或 Java 中的 Object 類的定義其實就是用的 oop,而任何類都由 Object 繼承而來。oopDesc 只是 oop 的一個別名而已。 可以看到裡面有關於 Klass 的宣告,還有 markOop 的宣告,這個 markOop 就是對應上面說到的 MarkWord。 ```c++ class markOopDesc: public oopDesc { private: // Conversion uintptr_t value() const { return (uintptr_t) this; } public: // Constants enum { age_bits = 4, //分代年齡 lock_bits = 2, //鎖標誌位 biased_lock_bits = 1, //偏向鎖標記 max_hash_bits = BitsPerWord - age_bits - lock_bits - biased_lock_bits, hash_bits = max_hash_bits > 31 ? 31 : max_hash_bits, cms_bits = LP64_ONLY(1) NOT_LP64(0), epoch_bits = 2 }; } ``` 以上程式碼只是截取了其中一部分,可以看到其中有關於分代年齡、鎖標誌位、偏向鎖的定義。 雖然原始碼咱也看不太懂,但是當我看到它們的時候,恍惚之間,內心會感嘆到,原來如此。有種宇宙之間,已盡在我掌控之中的感覺。過兩天才發現,原來只是一種心理安慰。但是,已經不重要了。 ##### 提示 如果你有興趣翻原始碼看看,這部分的定義在 `/src/hotspot/share/oops`目錄下,能告訴你的就這麼多了。 ## 鎖升級 JDK 1.6 之後,對 synchronized 做了優化,主要就是 CAS 自旋、鎖消除、鎖膨脹、輕量級鎖、偏向鎖等,這些技術都是為了線上程之間更高效地共享資料及解決競爭問題,從而提高程式的執行效率,進而產生了一套鎖升級的規則。 ![](https://hexo.moonkite.cn/blog/image-20201112131713120.png) synchronized 的鎖升級過程是通過動態改變物件 MarkWord 各個標誌位來表示當前的鎖狀態的,那修改的是哪個物件的 MarkWord 呢,看上面的程式碼中,synchronized 關鍵字是加在 lock 變數上的,那就會控制 lock 的 MarkWord。如果是 `synchronized(this)`或者在方法上加關鍵字,那控制的就是當前例項物件的 MarkWord。 ![](https://hexo.moonkite.cn/blog/image-20201111214057762.png) synchronized 的核心準則概括起來大概是這個樣子。 > 1. 能不加鎖就不加鎖。 > 2. 能偏向就儘量偏向。 > 3. 能加輕量級鎖就不用重量級鎖。 ### 無鎖轉向偏向鎖 偏向鎖的意思是說,這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖一直沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。 ![](https://hexo.moonkite.cn/blog/image-20201112152912912.png) 當執行緒嘗試獲取鎖物件的時候,先檢查 MarkWord 中的執行緒ID 是否為空。如果為空,則虛擬機器會將 MarkWord 中的偏向標記設定為 1,鎖標記位為 01。同時,使用 CAS 操作嘗試將執行緒ID記錄到 MarkWord 中,如果 CAS 操作成功,那之後這個持有偏向鎖的執行緒再次進入相關同步塊的時候,將不需要再進行任何的同步操作。 如果檢查執行緒ID不為空,並且不為當前執行緒ID,或者進行 CAS 操作設定執行緒ID失敗的情況下,都要撤銷偏向狀態,這時候就要升級為偏向鎖了。 ![](https://hexo.moonkite.cn/blog/image-20201112165335056.png) ### 偏向鎖升級到輕量級鎖 當多個執行緒競爭鎖時,偏向鎖會向輕量級鎖狀態升級。 ![](https://hexo.moonkite.cn/blog/image-20201112172722856.png) 首先,執行緒嘗試獲取鎖的時候,先檢查鎖標誌為是否為 01 狀態,也就是未鎖定狀態。 如果是未鎖定狀態,那就在當前執行緒的棧幀中建立一個鎖記錄(Lock Record)區域,這個區域儲存 MarkWord 的拷貝。 之後,嘗試用 CAS 操作將 MarkWord 更新為指向鎖記錄的指標(就是上一步線上程棧幀中的 MarkWord 拷貝),如果 CAS 更新成功了,那偏向鎖正式升級為輕量級鎖,鎖標誌為變為 00。 ![](https://hexo.moonkite.cn/blog/image-20201112175724314.png) 如果 CAS 更新失敗了,那檢查 MarkWord 是否已經指向了當前執行緒的鎖記錄,如果已經指向自己,那表示已經獲取了鎖,否則,輕量級鎖要膨脹為重量級鎖。 ![](https://hexo.moonkite.cn/blog/image-20201112222919240.png) ### 輕量級鎖升級到重量級鎖 上面的圖中已經有了關於輕量級鎖膨脹為重量級鎖的邏輯。當鎖已經是輕量級鎖的狀態,再有其他執行緒來競爭鎖,此時輕量級鎖就會膨脹為重量級鎖。 ![](https://hexo.moonkite.cn/blog/image-20201113091951306.png) ### 重量級鎖的實現原理 為什麼叫重量級鎖呢?在重量級鎖中沒有競爭到鎖的物件會 park 被掛起,退出同步塊時 unpark 喚醒後續執行緒。喚醒操作涉及到作業系統排程會有額外的開銷,這就是它被稱為重量級鎖的原因。 當鎖升級為重量級鎖的時候,MarkWord 會指向重量級鎖的指標 monitor,monitor 也稱為管程或監視器鎖, 每個物件都存在著一個 monitor 與之關聯 ,物件與其 monitor 之間的關係有存在多種實現方式,如monitor可以與物件一起建立銷燬或當執行緒試圖獲取物件鎖時自動生成,但當一個 monitor 被某個執行緒持有後,它便處於鎖定狀態。 ObjectMonitor中有兩個佇列,_WaitSet 和 _EntryList,用來儲存 ObjectWaiter 物件列表( 每個等待鎖的執行緒都會被封裝成 ObjectWaiter物件),_owner 指向持有 ObjectMonitor 物件的執行緒,當多個執行緒同時訪問一段同步程式碼時,首先會進入 _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中的原因。 ### 適用場景 #### 偏向鎖 **優點:** 加鎖和解鎖不需要額外的消耗,和執行非同步方法比僅存在納秒級的差距。 **缺點:** 如果執行緒間存在鎖競爭,會帶來額外的鎖撤銷的消耗。 **適用場景:** 適用於只有一個執行緒訪問同步塊場景。 有的同學可能會有疑惑,適用於只有一個執行緒的場景是什麼鬼,一個執行緒還加什麼鎖。 要知道,有些鎖不是你想不加就不加的。比方說你在使用一個第三方庫,呼叫它裡面的一個 API,你雖然知道是在單執行緒下使用,並不需要加鎖,但是第三方庫不知道啊,你呼叫的這個 API 正好是用 synchronized 做了同步的。這種情況下,使用偏向鎖可以達到最高的效能。 #### 輕量級鎖 **優點:** 競爭的執行緒不會阻塞,提高了程式的響應速度。 **缺點:** 如果始終得不到鎖競爭的執行緒使用自旋會消耗CPU。 **適用場景:** 追求響應時間。同步塊執行速度非常快。 #### 重量級鎖 **優點:** 執行緒競爭不使用自旋,不會消耗CPU。 **缺點:** 執行緒阻塞,響應時間緩慢。 **適用場景:** 追求吞吐量。同步塊執行速度較長。 ## 總結 1、synchronized 是可重入鎖,是一個非公平的可重入鎖,所以如果場景比較複雜的情況,還是要考慮其他的顯式鎖,比如 `Reentrantlock`、`CountDownLatch`等。 2、synchronized 有鎖升級的過程,當有執行緒競爭的情況下,除了互斥量的本身開銷外,還額外發生了CAS操作的開銷。因此在有競爭的情況下,synchronized 會有一定的效能損耗。 *** **這位英俊瀟灑的少年,如果覺得還不錯的話,給個推薦可好!** 公眾號「古時的風箏」,Java 開發者,全棧工程師,bug 殺手,擅長解決問題。 一個兼具深度與廣度的程式設計師鼓勵師,本打算寫詩卻寫起了程式碼的田園碼農!堅持原創乾貨輸出,你可選擇現在就關注我,或者看看歷史文章再關注也不遲。長按二維碼關注,跟我一起變優秀! ![](https://img2020.cnblogs.com/blog/273364/202008/273364-20200807093211558-1258890