synchronized 到底該不該用?
阿新 • • 發佈:2020-11-19
> 我是風箏,公眾號「古時的風箏」,一個兼具深度與廣度的程式設計師鼓勵師,一個本打算寫詩卻寫起了程式碼的田園碼農!
文章會收錄在 [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