字節(jié)面試官問 synchronized,我給出了虎軀一震的回答
synchronized 這個關(guān)鍵字的重要性不言而喻,幾乎可以說是并發(fā)、多線程必須會問到的關(guān)鍵字了。synchronized 會涉及到鎖、升級降級操作、鎖的撤銷、對象頭等。所以理解 synchronized 非常重要,本篇文章就帶你從 synchronized 的基本用法、再到 synchronized 的深入理解,對象頭等,為你揭開 synchronized 的面紗。
淺析 synchronized
synchronized 是 Java 并發(fā)模塊 非常重要的關(guān)鍵字,它是 Java 內(nèi)建的一種同步機制,代表了某種內(nèi)在鎖定的概念,當一個線程對某個共享資源加鎖后,其他想要獲取共享資源的線程必須進行等待,synchronized 也具有互斥和排他的語義。
什么是互斥?我們想必小時候都玩兒過磁鐵,磁鐵會有正負極的概念,同性相斥異性相吸,相斥相當于就是一種互斥的概念,也就是兩者互不相容。
synchronized 也是一種獨占的關(guān)鍵字,但是它這種獨占的語義更多的是為了增加線程安全性,通過獨占某個資源以達到互斥、排他的目的。
在了解了排他和互斥的語義后,我們先來看一下 synchronized 的用法,先來了解用法,再來了解底層實現(xiàn)。
synchronized 的使用
關(guān)于 synchronized 想必你應(yīng)該都大致了解過
synchronized 修飾實例方法,相當于是對類的實例進行加鎖,進入同步代碼前需要獲得當前實例的鎖
synchronized 修飾靜態(tài)方法,相當于是對類對象進行加鎖
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);
}
}
上面輸出的結(jié)果 i = 2000 ,并且每次都會打印當前現(xiàn)成的名字
來解釋一下上面代碼,代碼中的 i 是一個靜態(tài)變量,靜態(tài)變量也是全局變量,靜態(tài)變量存儲在方法區(qū)中。increase 方法由 synchronized 關(guān)鍵字修飾,但是沒有使用 static 關(guān)鍵字修飾,表示 increase 方法是一個實例方法,每次創(chuàng)建一個 TSynchronized 類的同時都會創(chuàng)建一個 increase 方法,increase 方法中只是打印出來了當前訪問的線程名稱。Synchronized 類實現(xiàn)了 Runnable 接口,重寫了 run 方法,run 方法里面就是一個 0 - 1000 的計數(shù)器,這個沒什么好說的。在 main 方法中,new 出了兩個線程,分別是 aThread 和 bThread,Thread.join 表示等待這個線程處理結(jié)束。這段代碼主要的作用就是判斷 synchronized 修飾的方法能夠具有獨占性。
synchronized 修飾靜態(tài)方法
synchronized 修飾靜態(tài)方法就是 synchronized 和 static 關(guān)鍵字一起使用
public static synchronized void increase(){}
當 synchronized 作用于靜態(tài)方法時,表示的就是當前類的鎖,因為靜態(tài)方法是屬于類的,它不屬于任何一個實例成員,因此可以通過 class 對象控制并發(fā)訪問。
這里需要注意一點,因為 synchronized 修飾的實例方法是屬于實例對象,而 synchronized 修飾的靜態(tài)方法是屬于類對象,所以調(diào)用 synchronized 的實例方法并不會阻止訪問 synchronized 的靜態(tài)方法。
synchronized 修飾代碼塊
synchronized 除了修飾實例方法和靜態(tài)方法外,synchronized 還可用于修飾代碼塊,代碼塊可以嵌套在方法體的內(nèi)部使用。
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 指令實現(xiàn)的。而Monitor 對象是實現(xiàn)同步的基本單元。
啥是
Monitor對象呢?
Monitor 對象
任何對象都關(guān)聯(lián)了一個管程,管程就是控制對象并發(fā)訪問的一種機制。管程 是一種同步原語,在 Java 中指的就是 synchronized,可以理解為 synchronized 就是 Java 中對管程的實現(xiàn)。
管程提供了一種排他訪問機制,這種機制也就是 互斥。互斥保證了在每個時間點上,最多只有一個線程會執(zhí)行同步方法。
所以你理解了 Monitor 對象其實就是使用管程控制同步訪問的一種對象。
對象內(nèi)存布局
在 hotspot 虛擬機中,對象在內(nèi)存中的布局分為三塊區(qū)域:
對象頭(Header)實例數(shù)據(jù)(Instance Data)對齊填充(Padding)
這三塊區(qū)域的內(nèi)存分布如下圖所示

我們來詳細介紹一下上面對象中的內(nèi)容。
對象頭 Header
對象頭 Header 主要包含 MarkWord 和對象指針 Klass Pointer,如果是數(shù)組的話,還要包含數(shù)組的長度。

在 32 位的虛擬機中 MarkWord ,Klass Pointer 和數(shù)組長度分別占用 32 位,也就是 4 字節(jié)。
如果是 64 位虛擬機的話,MarkWord ,Klass Pointer 和數(shù)組長度分別占用 64 位,也就是 8 字節(jié)。
在 32 位虛擬機和 64 位虛擬機的 Mark Word 所占用的字節(jié)大小不一樣,32 位虛擬機的 Mark Word 和 Klass Pointer 分別占用 32 bits 的字節(jié),而 64 位虛擬機的 Mark Word 和 Klass Pointer 占用了64 bits 的字節(jié),下面我們以 32 位虛擬機為例,來看一下其 Mark Word 的字節(jié)具體是如何分配的。


用中文翻譯過來就是

無狀態(tài)也就是
無鎖的時候,對象頭開辟 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 存放鎖的標識位,為 11GC標記開辟 30 bit 的內(nèi)存空間卻沒有占用,2 bit 空間存放鎖標志位為 11。
其中無鎖和偏向鎖的鎖標志位都是 01,只是在前面的 1 bit 區(qū)分了這是無鎖狀態(tài)還是偏向鎖狀態(tài)。
關(guān)于為什么這么分配的內(nèi)存,我們可以從 OpenJDK 中的markOop.hpp類中的枚舉窺出端倪

來解釋一下
age_bits 就是我們說的分代回收的標識,占用4字節(jié)
lock_bits 是鎖的標志位,占用2個字節(jié)
biased_lock_bits 是是否偏向鎖的標識,占用1個字節(jié)。
max_hash_bits 是針對無鎖計算的 hashcode 占用字節(jié)數(shù)量,如果是 32 位虛擬機,就是 32 - 4 - 2 -1 = 25 byte,如果是 64 位虛擬機,64 - 4 - 2 - 1 = 57 byte,但是會有 25 字節(jié)未使用,所以 64 位的 hashcode 占用 31 byte。
hash_bits 是針對 64 位虛擬機來說,如果最大字節(jié)數(shù)大于 31,則取 31,否則取真實的字節(jié)數(shù)
cms_bits 我覺得應(yīng)該是不是 64 位虛擬機就占用 0 byte,是 64 位就占用 1byte
epoch_bits 就是 epoch 所占用的字節(jié)大小,2 字節(jié)。
在上面的虛擬機對象頭分配表中,我們可以看到有幾種鎖的狀態(tài):無鎖(無狀態(tài)),偏向鎖,輕量級鎖,重量級鎖,其中輕量級鎖和偏向鎖是 JDK1.6 中對 synchronized 鎖進行優(yōu)化后新增加的,其目的就是為了大大優(yōu)化鎖的性能,所以在 JDK 1.6 中,使用 synchronized 的開銷也沒那么大了。其實從鎖有無鎖定來講,還是只有無鎖和重量級鎖,偏向鎖和輕量級鎖的出現(xiàn)就是增加了鎖的獲取性能而已,并沒有出現(xiàn)新的鎖。
所以我們的重點放在對 synchronized 重量級鎖的研究上,當 monitor 被某個線程持有后,它就會處于鎖定狀態(tài)。在 HotSpot 虛擬機中,monitor 的底層代碼是由 ObjectMonitor 實現(xiàn)的,其主要數(shù)據(jù)結(jié)構(gòu)如下(位于 HotSpot 虛擬機源碼 ObjectMonitor.hpp 文件,C++ 實現(xiàn)的)

這段 C++ 中需要注意幾個屬性:_WaitSet 、 _EntryList 和 _Owner,每個等待獲取鎖的線程都會被封裝稱為 ObjectWaiter 對象。

_Owner 是指向了 ObjectMonitor 對象的線程,而 _WaitSet 和 _EntryList 就是用來保存每個線程的列表。
那么這兩個列表有什么區(qū)別呢?這個問題我和你聊一下鎖的獲取流程你就清楚了。
鎖的兩個列表
當多個線程同時訪問某段同步代碼時,首先會進入 _EntryList 集合,當線程獲取到對象的 monitor 之后,就會進入 _Owner 區(qū)域,并把 ObjectMonitor 對象的 _Owner 指向為當前線程,并使 _count + 1,如果調(diào)用了釋放鎖(比如 wait)的操作,就會釋放當前持有的 monitor ,owner = null, _count - 1,同時這個線程會進入到 _WaitSet 列表中等待被喚醒。如果當前線程執(zhí)行完畢后也會釋放 monitor 鎖,只不過此時不會進入 _WaitSet 列表了,而是直接復(fù)位 _count 的值。

Klass Pointer 表示的是類型指針,也就是對象指向它的類元數(shù)據(jù)的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
你可能不是很理解指針是個什么概念,你可以簡單理解為指針就是指向某個數(shù)據(jù)的地址。

實例數(shù)據(jù) Instance Data
實例數(shù)據(jù)部分是對象真正存儲的有效信息,也是代碼中定義的各個字段的字節(jié)大小,比如一個 byte 占 1 個字節(jié),一個 int 占用 4 個字節(jié)。
對齊 Padding
對齊不是必須存在的,它只起到了占位符(%d, %c 等)的作用。這就是 JVM 的要求了,因為 HotSpot JVM 要求對象的起始地址必須是 8 字節(jié)的整數(shù)倍,也就是說對象的字節(jié)大小是 8 的整數(shù)倍,不夠的需要使用 Padding 補全。
鎖的升級流程
先來個大體的流程圖來感受一下這個過程,然后下面我們再分開來說

無鎖
無鎖狀態(tài),無鎖即沒有對資源進行鎖定,所有的線程都可以對同一個資源進行訪問,但是只有一個線程能夠成功修改資源。

無鎖的特點就是在循環(huán)內(nèi)進行修改操作,線程會不斷的嘗試修改共享資源,直到能夠成功修改資源并退出,在此過程中沒有出現(xiàn)沖突的發(fā)生,這很像我們在之前文章中介紹的 CAS 實現(xiàn),CAS 的原理和應(yīng)用就是無鎖的實現(xiàn)。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。
偏向鎖
HotSpot 的作者經(jīng)過研究發(fā)現(xiàn),大多數(shù)情況下,鎖不僅不存在多線程競爭,還存在鎖由同一線程多次獲得的情況,偏向鎖就是在這種情況下出現(xiàn)的,它的出現(xiàn)是為了解決只有在一個線程執(zhí)行同步時提高性能。

可以從對象頭的分配中看到,偏向鎖要比無鎖多了線程ID 和 epoch,下面我們就來描述一下偏向鎖的獲取過程
偏向鎖獲取過程
首先線程訪問同步代碼塊,會通過檢查對象頭 Mark Word 的
鎖標志位判斷目前鎖的狀態(tài),如果是 01,說明就是無鎖或者偏向鎖,然后再根據(jù)是否偏向鎖的標示判斷是無鎖還是偏向鎖,如果是無鎖情況下,執(zhí)行下一步線程使用 CAS 操作來嘗試對對象加鎖,如果使用 CAS 替換 ThreadID 成功,就說明是第一次上鎖,那么當前線程就會獲得對象的偏向鎖,此時會在對象頭的 Mark Word 中記錄當前線程 ID 和獲取鎖的時間 epoch 等信息,然后執(zhí)行同步代碼塊。
全局安全點(Safe Point):全局安全點的理解會涉及到 C 語言底層的一些知識,這里簡單理解 SafePoint 是 Java 代碼中的一個線程可能暫停執(zhí)行的位置。
等到下一次線程在進入和退出同步代碼塊時就不需要進行 CAS 操作進行加鎖和解鎖,只需要簡單判斷一下對象頭的 Mark Word 中是否存儲著指向當前線程的線程ID,判斷的標志當然是根據(jù)鎖的標志位來判斷的。如果用流程圖來表示的話就是下面這樣

關(guān)閉偏向鎖
偏向鎖在Java 6 和Java 7 里是默認啟用的。由于偏向鎖是為了在只有一個線程執(zhí)行同步塊時提高性能,如果你確定應(yīng)用程序里所有的鎖通常情況下處于競爭狀態(tài),可以通過JVM參數(shù)關(guān)閉偏向鎖:-XX:-UseBiasedLocking=false,那么程序默認會進入輕量級鎖狀態(tài)。
關(guān)于 epoch
偏向鎖的對象頭中有一個被稱為 epoch 的值,它作為偏差有效性的時間戳。
輕量級鎖
輕量級鎖是指當前鎖是偏向鎖的時候,資源被另外的線程所訪問,那么偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能,下面是詳細的獲取過程。
輕量級鎖加鎖過程
緊接著上一步,如果 CAS 操作替換 ThreadID 沒有獲取成功,執(zhí)行下一步
如果使用 CAS 操作替換 ThreadID 失敗(這時候就切換到另外一個線程的角度)說明該資源已被同步訪問過,這時候就會執(zhí)行鎖的撤銷操作,撤銷偏向鎖,然后等原持有偏向鎖的線程到達
全局安全點(SafePoint)時,會暫停原持有偏向鎖的線程,然后會檢查原持有偏向鎖的狀態(tài),如果已經(jīng)退出同步,就會喚醒持有偏向鎖的線程,執(zhí)行下一步檢查對象頭中的 Mark Word 記錄的是否是當前線程 ID,如果是,執(zhí)行同步代碼,如果不是,執(zhí)行偏向鎖獲取流程 的第2步。
如果用流程表示的話就是下面這樣(已經(jīng)包含偏向鎖的獲取)

重量級鎖
重量級鎖其實就是 synchronized 最終加鎖的過程,在 JDK 1.6 之前,就是由無鎖 -> 加鎖的這個過程。
重量級鎖的獲取流程
接著上面偏向鎖的獲取過程,由偏向鎖升級為輕量級鎖,執(zhí)行下一步
會在原持有偏向鎖的線程的棧中分配鎖記錄,將對象頭中的 Mark Word 拷貝到原持有偏向鎖線程的記錄中,原持有偏向鎖的線程獲得輕量級鎖,然后喚醒原持有偏向鎖的線程,從安全點處繼續(xù)執(zhí)行,執(zhí)行完畢后,執(zhí)行下一步,當前線程執(zhí)行第 4 步
執(zhí)行完畢后,開始輕量級解鎖操作,解鎖需要判斷兩個條件
判斷對象頭中的 Mark Word 中鎖記錄指針是否指向當前棧中記錄的指針

拷貝在當前線程鎖記錄的 Mark Word 信息是否與對象頭中的 Mark Word 一致。
如果上面兩個判斷條件都符合的話,就進行鎖釋放,如果其中一個條件不符合,就會釋放鎖,并喚起等待的線程,進行新一輪的鎖競爭。
在當前線程的棧中分配鎖記錄,拷貝對象頭中的 MarkWord 到當前線程的鎖記錄中,執(zhí)行 CAS 加鎖操作,會把對象頭 Mark Word 中鎖記錄指針指向當前線程鎖記錄,如果成功,獲取輕量級鎖,執(zhí)行同步代碼,然后執(zhí)行第3步,如果不成功,執(zhí)行下一步
當前線程沒有使用 CAS 成功獲取鎖,就會自旋一會兒,再次嘗試獲取,如果在多次自旋到達上限后還沒有獲取到鎖,那么輕量級鎖就會升級為
重量級鎖

如果用流程圖表示是這樣的

根據(jù)上面對于鎖升級細致的描述,我們可以總結(jié)一下不同鎖的適用范圍和場景。

synchronized 代碼塊的底層實現(xiàn)
為了便于方便研究,我們把 synchronized 修飾代碼塊的示例簡單化,如下代碼所示
public class SynchronizedTest {
private int i;
public void syncTask(){
synchronized (this){
i++;
}
}
}
我們主要關(guān)注一下 synchronized 的字節(jié)碼,如下所示

從這段字節(jié)碼中我們可以知道,同步語句塊使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令指向同步代碼塊的結(jié)束位置。
那么為什么會有兩個 monitorexit 呢?
不知道你注意到下面的異常表了嗎?如果你不知道什么是異常表,那么我建議你讀一下這篇文章
看完這篇Exception 和 Error,和面試官扯皮就沒問題了
synchronized 修飾方法的底層原理
方法的同步是隱式的,也就是說 synchronized 修飾方法的底層無需使用字節(jié)碼來控制,真的是這樣嗎?我們來反編譯一波看看結(jié)果
public class SynchronizedTest {
private int i;
public synchronized void syncTask(){
i++;
}
}
這次我們使用 javap -verbose 來輸出詳細的結(jié)果

從字節(jié)碼上可以看出,synchronized 修飾的方法并沒有使用 monitorenter 和 monitorexit 指令,取得代之是ACC_SYNCHRONIZED 標識,該標識指明了此方法是一個同步方法,JVM 通過該 ACC_SYNCHRONIZED 訪問標志來辨別一個方法是否聲明為同步方法,從而執(zhí)行相應(yīng)的同步調(diào)用。這就是 synchronized 鎖在同步代碼塊上和同步方法上的實現(xiàn)差別。
另外,cxuan 肝了六本 PDF,公號回復(fù) cxuan ,領(lǐng)取作者全部 PDF 。

