Synchronized 的超多干貨!一起來品品!
synchronized 這個關鍵字的重要性不言而喻,幾乎可以說是并發、多線程必須會問到的關鍵字了。synchronized 會涉及到鎖、升級降級操作、鎖的撤銷、對象頭等。所以理解 synchronized 非常重要,本篇文章就帶你從 synchronized 的基本用法、再到 synchronized 的深入理解,對象頭等,為你揭開 synchronized 的面紗。
淺析 synchronized
synchronized 是 Java 并發模塊 非常重要的關鍵字,它是 Java 內建的一種同步機制,代表了某種內在鎖定的概念,當一個線程對某個共享資源加鎖后,其他想要獲取共享資源的線程必須進行等待,synchronized 也具有互斥和排他的語義。
什么是互斥?我們想必小時候都玩兒過磁鐵,磁鐵會有正負極的概念,同性相斥異性相吸,相斥相當于就是一種互斥的概念,也就是兩者互不相容。
synchronized 也是一種獨占的關鍵字,但是它這種獨占的語義更多的是為了增加線程安全性,通過獨占某個資源以達到互斥、排他的目的。
在了解了排他和互斥的語義后,我們先來看一下 synchronized 的用法,先來了解用法,再來了解底層實現。
synchronized 的使用
關于 synchronized 想必你應該都大致了解過
- synchronized 修飾實例方法,相當于是對類的實例進行加鎖,進入同步代碼前需要獲得當前實例的鎖
- synchronized 修飾靜態方法,相當于是對類對象進行加鎖
- synchronized 修飾代碼塊,相當于是給對象進行加鎖,在進入代碼塊前需要先獲得對象的鎖
下面我們針對每個用法進行解釋
synchronized 修飾實例方法
synchronized 修飾實例方法,實例方法是屬于類的實例。synchronized 修飾的實例方法相當于是對象鎖。下面是一個 synchronized 修飾實例方法的例子。
- public synchronized void method()
- {
- // ...
- }
像如上述 synchronized 修飾的方法就是實例方法,下面我們通過一個完整的例子來認識一下 synchronized 修飾實例方法
- public class TSynchronized implements Runnable{
- static int i = 0;
- public synchronized void increase(){
- i++;
- System.out.println(Thread.currentThread().getName());
- }
- @Override
- public void run() {
- for(int i = 0;i < 1000;i++) {
- increase();
- }
- }
- public static void main(String[] args) throws InterruptedException {
- TSynchronized tSynchronized = new TSynchronized();
- Thread aThread = new Thread(tSynchronized);
- Thread bThread = new Thread(tSynchronized);
- aThread.start();
- bThread.start();
- aThread.join();
- bThread.join();
- System.out.println("i = " + i);
- }
- }
上面輸出的結果 i = 2000 ,并且每次都會打印當前現成的名字
來解釋一下上面代碼,代碼中的 i 是一個靜態變量,靜態變量也是全局變量,靜態變量存儲在方法區中。increase 方法由 synchronized 關鍵字修飾,但是沒有使用 static 關鍵字修飾,表示 increase 方法是一個實例方法,每次創建一個 TSynchronized 類的同時都會創建一個 increase 方法,increase 方法中只是打印出來了當前訪問的線程名稱。Synchronized 類實現了 Runnable 接口,重寫了 run 方法,run 方法里面就是一個 0 - 1000 的計數器,這個沒什么好說的。在 main 方法中,new 出了兩個線程,分別是 aThread 和 bThread,Thread.join 表示等待這個線程處理結束。這段代碼主要的作用就是判斷 synchronized 修飾的方法能夠具有獨占性。
synchronized 修飾靜態方法
synchronized 修飾靜態方法就是 synchronized 和 static 關鍵字一起使用
- public static synchronized void increase(){}
當 synchronized 作用于靜態方法時,表示的就是當前類的鎖,因為靜態方法是屬于類的,它不屬于任何一個實例成員,因此可以通過 class 對象控制并發訪問。
這里需要注意一點,因為 synchronized 修飾的實例方法是屬于實例對象,而 synchronized 修飾的靜態方法是屬于類對象,所以調用 synchronized 的實例方法并不會阻止訪問 synchronized 的靜態方法。
synchronized 修飾代碼塊
synchronized 除了修飾實例方法和靜態方法外,synchronized 還可用于修飾代碼塊,代碼塊可以嵌套在方法體的內部使用。
- public void run() {
- synchronized(obj){
- for(int j = 0;j < 1000;j++){
- i++;
- }
- }
- }
上面代碼中將 obj 作為鎖對象對其加鎖,每次當線程進入 synchronized 修飾的代碼塊時就會要求當前線程持有obj 實例對象鎖,如果當前有其他線程正持有該對象鎖,那么新到的線程就必須等待。
synchronized 修飾的代碼塊,除了可以鎖定對象之外,也可以對當前實例對象鎖、class 對象鎖進行鎖定
- // 實例對象鎖
- synchronized(this){
- for(int j = 0;j < 1000;j++){
- i++;
- }
- }
- //class對象鎖
- synchronized(TSynchronized.class){
- for(int j = 0;j < 1000;j++){
- i++;
- }
- }
synchronized 底層原理
在簡單介紹完 synchronized 之后,我們就來聊一下 synchronized 的底層原理了。
我們或許都有所了解(下文會細致分析),synchronized 的代碼塊是由一組 monitorenter/monitorexit 指令實現的。而Monitor 對象是實現同步的基本單元。
啥是 Monitor 對象呢?
Monitor 對象
任何對象都關聯了一個管程,管程就是控制對象并發訪問的一種機制。管程 是一種同步原語,在 Java 中指的就是 synchronized,可以理解為 synchronized 就是 Java 中對管程的實現。
管程提供了一種排他訪問機制,這種機制也就是 互斥。互斥保證了在每個時間點上,最多只有一個線程會執行同步方法。
所以你理解了 Monitor 對象其實就是使用管程控制同步訪問的一種對象。
對象內存布局
在 hotspot 虛擬機中,對象在內存中的布局分為三塊區域:
- 對象頭(Header)
- 實例數據(Instance Data)
- 對齊填充(Padding)
這三塊區域的內存分布如下圖所示
我們來詳細介紹一下上面對象中的內容。
對象頭 Header
對象頭 Header 主要包含 MarkWord 和對象指針 Klass Pointer,如果是數組的話,還要包含數組的長度。
在 32 位的虛擬機中 MarkWord ,Klass Pointer 和數組長度分別占用 32 位,也就是 4 字節。
如果是 64 位虛擬機的話,MarkWord ,Klass Pointer 和數組長度分別占用 64 位,也就是 8 字節。
在 32 位虛擬機和 64 位虛擬機的 Mark Word 所占用的字節大小不一樣,32 位虛擬機的 Mark Word 和 Klass Pointer 分別占用 32 bits 的字節,而 64 位虛擬機的 Mark Word 和 Klass Pointer 占用了64 bits 的字節,下面我們以 32 位虛擬機為例,來看一下其 Mark Word 的字節具體是如何分配的。
用中文翻譯過來就是
- 無狀態也就是無鎖的時候,對象頭開辟 25 bit 的空間用來存儲對象的 hashcode ,4 bit 用于存放分代年齡,1 bit 用來存放是否偏向鎖的標識位,2 bit 用來存放鎖標識位為 01。
- 偏向鎖 中劃分更細,還是開辟 25 bit 的空間,其中 23 bit 用來存放線程ID,2bit 用來存放 epoch,4bit 存放分代年齡,1 bit 存放是否偏向鎖標識, 0 表示無鎖,1 表示偏向鎖,鎖的標識位還是 01。
- 輕量級鎖中直接開辟 30 bit 的空間存放指向棧中鎖記錄的指針,2bit 存放鎖的標志位,其標志位為 00。
- 重量級鎖中和輕量級鎖一樣,30 bit 的空間用來存放指向重量級鎖的指針,2 bit 存放鎖的標識位,為 11
- GC標記開辟 30 bit 的內存空間卻沒有占用,2 bit 空間存放鎖標志位為 11。
其中無鎖和偏向鎖的鎖標志位都是 01,只是在前面的 1 bit 區分了這是無鎖狀態還是偏向鎖狀態。
關于為什么這么分配的內存,我們可以從 OpenJDK 中的markOop.hpp類中的枚舉窺出端倪
來解釋一下
- age_bits 就是我們說的分代回收的標識,占用4字節
- lock_bits 是鎖的標志位,占用2個字節
- biased_lock_bits 是是否偏向鎖的標識,占用1個字節。
- max_hash_bits 是針對無鎖計算的 hashcode 占用字節數量,如果是 32 位虛擬機,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虛擬機,64 - 4 - 2 - 1 = 57 byte,但是會有 25 字節未使用,所以 64 位的 hashcode 占用 31 byte。
- hash_bits 是針對 64 位虛擬機來說,如果最大字節數大于 31,則取 31,否則取真實的字節數
- cms_bits 我覺得應該是不是 64 位虛擬機就占用 0 byte,是 64 位就占用 1byte
- epoch_bits 就是 epoch 所占用的字節大小,2 字節。
在上面的虛擬機對象頭分配表中,我們可以看到有幾種鎖的狀態:無鎖(無狀態),偏向鎖,輕量級鎖,重量級鎖,其中輕量級鎖和偏向鎖是 JDK1.6 中對 synchronized 鎖進行優化后新增加的,其目的就是為了大大優化鎖的性能,所以在 JDK 1.6 中,使用 synchronized 的開銷也沒那么大了。其實從鎖有無鎖定來講,還是只有無鎖和重量級鎖,偏向鎖和輕量級鎖的出現就是增加了鎖的獲取性能而已,并沒有出現新的鎖。
所以我們的重點放在對 synchronized 重量級鎖的研究上,當 monitor 被某個線程持有后,它就會處于鎖定狀態。在 HotSpot 虛擬機中,monitor 的底層代碼是由 ObjectMonitor 實現的,其主要數據結構如下(位于 HotSpot 虛擬機源碼 ObjectMonitor.hpp 文件,C++ 實現的)
這段 C++ 中需要注意幾個屬性:_WaitSet 、 _EntryList 和 _Owner,每個等待獲取鎖的線程都會被封裝稱為 ObjectWaiter 對象。
_Owner 是指向了 ObjectMonitor 對象的線程,而 _WaitSet 和 _EntryList 就是用來保存每個線程的列表。
那么這兩個列表有什么區別呢?這個問題我和你聊一下鎖的獲取流程你就清楚了。
鎖的兩個列表
當多個線程同時訪問某段同步代碼時,首先會進入 _EntryList 集合,當線程獲取到對象的 monitor 之后,就會進入 _Owner 區域,并把 ObjectMonitor 對象的 _Owner 指向為當前線程,并使 _count + 1,如果調用了釋放鎖(比如 wait)的操作,就會釋放當前持有的 monitor ,owner = null, _count - 1,同時這個線程會進入到 _WaitSet 列表中等待被喚醒。如果當前線程執行完畢后也會釋放 monitor 鎖,只不過此時不會進入 _WaitSet 列表了,而是直接復位 _count 的值。
Klass Pointer 表示的是類型指針,也就是對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
你可能不是很理解指針是個什么概念,你可以簡單理解為指針就是指向某個數據的地址。
實例數據 Instance Data
實例數據部分是對象真正存儲的有效信息,也是代碼中定義的各個字段的字節大小,比如一個 byte 占 1 個字節,一個 int 占用 4 個字節。
對齊 Padding
對齊不是必須存在的,它只起到了占位符(%d, %c 等)的作用。這就是 JVM 的要求了,因為 HotSpot JVM 要求對象的起始地址必須是 8 字節的整數倍,也就是說對象的字節大小是 8 的整數倍,不夠的需要使用 Padding 補全。
鎖的升級流程
先來個大體的流程圖來感受一下這個過程,然后下面我們再分開來說
無鎖
無鎖狀態,無鎖即沒有對資源進行鎖定,所有的線程都可以對同一個資源進行訪問,但是只有一個線程能夠成功修改資源。
無鎖的特點就是在循環內進行修改操作,線程會不斷的嘗試修改共享資源,直到能夠成功修改資源并退出,在此過程中沒有出現沖突的發生,這很像我們在之前文章中介紹的 CAS 實現,CAS 的原理和應用就是無鎖的實現。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。
偏向鎖
HotSpot 的作者經過研究發現,大多數情況下,鎖不僅不存在多線程競爭,還存在鎖由同一線程多次獲得的情況,偏向鎖就是在這種情況下出現的,它的出現是為了解決只有在一個線程執行同步時提高性能。
可以從對象頭的分配中看到,偏向鎖要比無鎖多了線程ID 和 epoch,下面我們就來描述一下偏向鎖的獲取過程
偏向鎖獲取過程
首先線程訪問同步代碼塊,會通過檢查對象頭 Mark Word 的鎖標志位判斷目前鎖的狀態,如果是 01,說明就是無鎖或者偏向鎖,然后再根據是否偏向鎖 的標示判斷是無鎖還是偏向鎖,如果是無鎖情況下,執行下一步
線程使用 CAS 操作來嘗試對對象加鎖,如果使用 CAS 替換 ThreadID 成功,就說明是第一次上鎖,那么當前線程就會獲得對象的偏向鎖,此時會在對象頭的 Mark Word 中記錄當前線程 ID 和獲取鎖的時間 epoch 等信息,然后執行同步代碼塊。
全局安全點(Safe Point):全局安全點的理解會涉及到 C 語言底層的一些知識,這里簡單理解 SafePoint 是 Java 代碼中的一個線程可能暫停執行的位置。
等到下一次線程在進入和退出同步代碼塊時就不需要進行 CAS 操作進行加鎖和解鎖,只需要簡單判斷一下對象頭的 Mark Word 中是否存儲著指向當前線程的線程ID,判斷的標志當然是根據鎖的標志位來判斷的。如果用流程圖來表示的話就是下面這樣
關閉偏向鎖
偏向鎖在Java 6 和Java 7 里是默認啟用的。由于偏向鎖是為了在只有一個線程執行同步塊時提高性能,如果你確定應用程序里所有的鎖通常情況下處于競爭狀態,可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認會進入輕量級鎖狀態。
關于 epoch
偏向鎖的對象頭中有一個被稱為 epoch 的值,它作為偏差有效性的時間戳。
輕量級鎖
輕量級鎖是指當前鎖是偏向鎖的時候,資源被另外的線程所訪問,那么偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能,下面是詳細的獲取過程。
輕量級鎖加鎖過程
- 緊接著上一步,如果 CAS 操作替換 ThreadID 沒有獲取成功,執行下一步
- 如果使用 CAS 操作替換 ThreadID 失敗(這時候就切換到另外一個線程的角度)說明該資源已被同步訪問過,這時候就會執行鎖的撤銷操作,撤銷偏向鎖,然后等原持有偏向鎖的線程到達全局安全點(SafePoint)時,會暫停原持有偏向鎖的線程,然后會檢查原持有偏向鎖的狀態,如果已經退出同步,就會喚醒持有偏向鎖的線程,執行下一步
- 檢查對象頭中的 Mark Word 記錄的是否是當前線程 ID,如果是,執行同步代碼,如果不是,執行偏向鎖獲取流程 的第2步。
如果用流程表示的話就是下面這樣(已經包含偏向鎖的獲取)
重量級鎖
重量級鎖其實就是 synchronized 最終加鎖的過程,在 JDK 1.6 之前,就是由無鎖 -> 加鎖的這個過程。
重量級鎖的獲取流程
- 接著上面偏向鎖的獲取過程,由偏向鎖升級為輕量級鎖,執行下一步
- 會在原持有偏向鎖的線程的棧中分配鎖記錄,將對象頭中的 Mark Word 拷貝到原持有偏向鎖線程的記錄中,原持有偏向鎖的線程獲得輕量級鎖,然后喚醒原持有偏向鎖的線程,從安全點處繼續執行,執行完畢后,執行下一步,當前線程執行第 4 步
- 執行完畢后,開始輕量級解鎖操作,解鎖需要判斷兩個條件
- 判斷對象頭中的 Mark Word 中鎖記錄指針是否指向當前棧中記錄的指針
- 拷貝在當前線程鎖記錄的 Mark Word 信息是否與對象頭中的 Mark Word 一致。
如果上面兩個判斷條件都符合的話,就進行鎖釋放,如果其中一個條件不符合,就會釋放鎖,并喚起等待的線程,進行新一輪的鎖競爭。
- 在當前線程的棧中分配鎖記錄,拷貝對象頭中的 MarkWord 到當前線程的鎖記錄中,執行 CAS 加鎖操作,會把對象頭 Mark Word 中鎖記錄指針指向當前線程鎖記錄,如果成功,獲取輕量級鎖,執行同步代碼,然后執行第3步,如果不成功,執行下一步
- 當前線程沒有使用 CAS 成功獲取鎖,就會自旋一會兒,再次嘗試獲取,如果在多次自旋到達上限后還沒有獲取到鎖,那么輕量級鎖就會升級為 重量級鎖
如果用流程圖表示是這樣的
根據上面對于鎖升級細致的描述,我們可以總結一下不同鎖的適用范圍和場景。
synchronized 代碼塊的底層實現
為了便于方便研究,我們把 synchronized 修飾代碼塊的示例簡單化,如下代碼所示
- public class SynchronizedTest {
- private int i;
- public void syncTask(){
- synchronized (this){
- i++;
- }
- }
- }
我們主要關注一下 synchronized 的字節碼,如下所示
從這段字節碼中我們可以知道,同步語句塊使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令指向同步代碼塊的結束位置。
那么為什么會有兩個 monitorexit 呢?
不知道你注意到下面的異常表了嗎?如果你不知道什么是異常表,那么我建議你讀一下這篇文章
看完這篇Exception 和 Error,和面試官扯皮就沒問題了
synchronized 修飾方法的底層原理
方法的同步是隱式的,也就是說 synchronized 修飾方法的底層無需使用字節碼來控制,真的是這樣嗎?我們來反編譯一波看看結果
- public class SynchronizedTest {
- private int i;
- public synchronized void syncTask(){
- i++;
- }
- }
這次我們使用 javap -verbose 來輸出詳細的結果
從字節碼上可以看出,synchronized 修飾的方法并沒有使用 monitorenter 和 monitorexit 指令,取得代之是ACC_SYNCHRONIZED 標識,該標識指明了此方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標志來辨別一個方法是否聲明為同步方法,從而執行相應的同步調用。這就是 synchronized 鎖在同步代碼塊上和同步方法上的實現差別。
本文轉載自微信公眾號「程序員cxuan」,可以通過以下二維碼關注。轉載本文請聯系程序員cxuan公眾號。