死磕synchronized底層實現
- 2020 年 5 月 18 日
- 筆記
點贊再看,養成習慣,微信搜索【三太子敖丙】第一時間閱讀。
本文 GitHub //github.com/JavaFamily 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。
前言

多線程的東西很多,也很有意思,所以我最近的重心可能都是多線程的方向去靠了,不知道大家喜歡否?
閱讀本文之前閱讀以下兩篇文章會幫助你更好的理解:
正文
場景
我們正常去使用Synchronized一般都是用在下面這幾種場景:
-
修飾實例方法,對當前實例對象this加鎖
public class Synchronized {
public synchronized void husband(){
}
} -
修飾靜態方法,對當前類的Class對象加鎖
public class Synchronized {
public void husband(){
synchronized(Synchronized.class){
}
}
} -
修飾代碼塊,指定一個加鎖的對象,給對象加鎖
public class Synchronized {
public void husband(){
synchronized(new test()){
}
}
}
其實就是鎖方法、鎖代碼塊和鎖對象,那他們是怎麼實現加鎖的呢?
在這之前,我就先跟大家聊一下我們Java對象的構成
在 JVM 中,對象在內存中分為三塊區域:
-
對象頭
-
Mark Word(標記字段):默認存儲對象的HashCode,分代年齡和鎖標誌位信息。它會根據對象的狀態復用自己的存儲空間,也就是說在運行期間Mark Word里存儲的數據會隨着鎖標誌位的變化而變化。 -
Klass Point(類型指針):對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
-
-
實例數據
-
這部分主要是存放類的數據信息,父類的信息。
-
-
對其填充
-
由於虛擬機要求對象起始地址必須是8位元組的整數倍,填充數據不是必須存在的,僅僅是為了位元組對齊。
Tip:不知道大家有沒有被問過一個空對象佔多少個位元組?就是8個位元組,是因為對齊填充的關係哈,不到8個位元組對其填充會幫我們自動補齊。
-

我們經常說到的,有序性、可見性、原子性,synchronized又是怎麼做到的呢?
有序性
我在Volatile章節已經說過了CPU會為了優化我們的代碼,會對我們程序進行重排序。
as-if-serial
不管編譯器和CPU如何重排序,必須保證在單線程情況下程序的結果是正確的,還有就是有數據依賴的也是不能重排序的。
就比如:
int a = 1;
int b = a;
這兩段是怎麼都不能重排序的,b的值依賴a的值,a如果不先賦值,那就為空了。
可見性
同樣在Volatile章節我介紹到了現代計算機的內存結構,以及JMM(Java內存模型),這裡我需要說明一下就是JMM並不是實際存在的,而是一套規範,這個規範描述了很多java程序中各種變量(線程共享變量)的訪問規則,以及在JVM中將變量存儲到內存和從內存中讀取變量這樣的底層細節,Java內存模型是對共享數據的可見性、有序性、和原子性的規則和保障。
大家感興趣,也記得去了解計算機的組成部分,cpu、內存、多級緩存等,會幫助更好的理解java這麼做的原因。

原子性
其實他保證原子性很簡單,確保同一時間只有一個線程能拿到鎖,能夠進入代碼塊這就夠了。
這幾個是我們使用鎖經常用到的特性,那synchronized他自己本身又具有哪些特性呢?
可重入性
synchronized鎖對象的時候有個計數器,他會記錄下線程獲取鎖的次數,在執行完對應的代碼塊之後,計數器就會-1,直到計數器清零,就釋放鎖了。
那可重入有什麼好處呢?
可以避免一些死鎖的情況,也可以讓我們更好封裝我們的代碼。
不可中斷性
不可中斷就是指,一個線程獲取鎖之後,另外一個線程處於阻塞或者等待狀態,前一個不釋放,後一個也一直會阻塞或者等待,不可以被中斷。
值得一提的是,Lock的tryLock方法是可以被中斷的。
底層實現
這裡看實現很簡單,我寫了一個簡單的類,分別有鎖方法和鎖代碼塊,我們反編譯一下位元組碼文件,就可以了。
先看看我寫的測試類:
/**
*@Description: Synchronize
*@Author: 敖丙
*@date: 2020-05-17
**/
public class Synchronized {
public synchronized void husband(){
synchronized(new Volatile()){
}
}
}
編譯完成,我們去對應目錄執行 javap -c xxx.class 命令查看反編譯的文件:
MacBook-Pro-3:juc aobing$ javap -p -v -c Synchronized.class
Classfile /Users/aobing/IdeaProjects/Thanos/laogong/target/classes/juc/Synchronized.class
Last modified 2020-5-17; size 375 bytes
MD5 checksum 4f5451a229e80c0a6045b29987383d1a
Compiled from "Synchronized.java"
public class juc.Synchronized
minor version: 0
major version: 49
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#14 // java/lang/Object."<init>":()V
#2 = Class #15 // juc/Synchronized
#3 = Class #16 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Ljuc/Synchronized;
#11 = Utf8 husband
#12 = Utf8 SourceFile
#13 = Utf8 Synchronized.java
#14 = NameAndType #4:#5 // "<init>":()V
#15 = Utf8 juc/Synchronized
#16 = Utf8 java/lang/Object
{
public juc.Synchronized();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ljuc/Synchronized;
public synchronized void husband();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // 這裡
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class juc/Synchronized
2: dup
3: astore_1
4: monitorenter // 這裡
5: aload_1
6: monitorexit // 這裡
7: goto 15
10: astore_2
11: aload_1
12: monitorexit // 這裡
13: aload_2
14: athrow
15: return
Exception table:
from to target type
5 7 10 any
10 13 10 any
LineNumberTable:
line 10: 0
line 12: 5
line 13: 15
LocalVariableTable:
Start Length Slot Name Signature
0 16 0 this Ljuc/Synchronized;
}
SourceFile: "Synchronized.java"
同步代碼
大家可以看到幾處我標記的,我在最開始提到過對象頭,他會關聯到一個monitor對象。
-
當我們進入一個人方法的時候,執行monitorenter,就會獲取當前對象的一個所有權,這個時候monitor進入數為1,當前的這個線程就是這個monitor的owner。
-
如果你已經是這個monitor的owner了,你再次進入,就會把進入數+1.
-
同理,當他執行完monitorexit,對應的進入數就-1,直到為0,才可以被其他線程持有。
所有的互斥,其實在這裡,就是看你能否獲得monitor的所有權,一旦你成為owner就是獲得者。
同步方法
不知道大家注意到方法那的一個特殊標誌位沒,ACC_SYNCHRONIZED。
同步方法的時候,一旦執行到這個方法,就會先判斷是否有標誌位,然後,ACC_SYNCHRONIZED會去隱式調用剛才的兩個指令:monitorenter和monitorexit。
所以歸根究底,還是monitor對象的爭奪。
monitor
我說了這麼多次這個對象,大家是不是以為就是個虛無的東西,其實不是,monitor監視器源碼是C++寫的,在虛擬機的ObjectMonitor.hpp文件中。
我看了下源碼,他的數據結構長這樣:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 線程重入次數
_object = NULL; // 存儲Monitor對象
_owner = NULL; // 持有當前線程的owner
_WaitSet = NULL; // wait狀態的線程列表
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 單向列表
FreeNext = NULL ;
_EntryList = NULL ; // 處於等待鎖狀態block狀態的線程列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
這塊c++代碼,我也放到了我的開源項目了,大家自行查看。
synchronized底層的源碼就是引入了ObjectMonitor,這一塊大家有興趣可以看看,反正我上面說的,還有大家經常聽到的概念,在這裡都能找到源碼。

大家說熟悉的鎖升級過程,其實就是在源碼裏面,調用了不同的實現去獲取獲取鎖,失敗就調用更高級的實現,最後升級完成。
1.5 重量級鎖
大家在看ObjectMonitor源碼的時候,會發現Atomic::cmpxchg_ptr,Atomic::inc_ptr等內核函數,對應的線程就是park()和upark()。
這個操作涉及用戶態和內核態的轉換了,這種切換是很耗資源的,所以知道為啥有自旋鎖這樣的操作了吧,按道理類似死循環的操作更費資源才是對吧?其實不是,大家了解一下就知道了。
那用戶態和內核態又是啥呢?
Linux系統的體系結構大家大學應該都接觸過了,分為用戶空間(應用程序的活動空間)和內核。
我們所有的程序都在用戶空間運行,進入用戶運行狀態也就是(用戶態),但是很多操作可能涉及內核運行,比我I/O,我們就會進入內核運行狀態(內核態)。

這個過程是很複雜的,也涉及很多值的傳遞,我簡單概括下流程:
-
用戶態把一些數據放到寄存器,或者創建對應的堆棧,表明需要操作系統提供的服務。 -
用戶態執行系統調用(系統調用是操作系統的最小功能單位)。 -
CPU切換到內核態,跳到對應的內存指定的位置執行指令。 -
系統調用處理器去讀取我們先前放到內存的數據參數,執行程序的請求。 -
調用完成,操作系統重置CPU為用戶態返回結果,並執行下個指令。
所以大家一直說,1.6之前是重量級鎖,沒錯,但是他重量的本質,是ObjectMonitor調用的過程,以及Linux內核的複雜運行機制決定的,大量的系統資源消耗,所以效率才低。
還有兩種情況也會發生內核態和用戶態的切換:異常事件和外圍設備的中斷 大家也可以了解下。
1.6 優化鎖升級
那都說過了效率低,官方也是知道的,所以他們做了升級,大家如果看了我剛才提到的那些源碼,就知道他們的升級其實也做得很簡單,只是多了幾個函數調用,不過不得不設計還是很巧妙的。
我們就來看一下升級後的鎖升級過程:

簡單版本:

升級方向:

Tip:切記這個升級過程是不可逆的,最後我會說明他的影響,涉及使用場景。
看完他的升級,我們就來好好聊聊每一步怎麼做的吧。
偏向鎖
之前我提到過了,對象頭是由Mark Word和Klass pointer 組成,鎖爭奪也就是對象頭指向的Monitor對象的爭奪,一旦有線程持有了這個對象,標誌位修改為1,就進入偏向模式,同時會把這個線程的ID記錄在對象的Mark Word中。
這個過程是採用了CAS樂觀鎖操作的,每次同一線程進入,虛擬機就不進行任何同步的操作了,對標誌位+1就好了,不同線程過來,CAS會失敗,也就意味着獲取鎖失敗。
偏向鎖在1.6之後是默認開啟的,1.5中是關閉的,需要手動開啟參數是xx:-UseBiasedLocking=false。

偏向鎖關閉,或者多個線程競爭偏向鎖怎麼辦呢?
輕量級鎖
還是跟Mark Work 相關,如果這個對象是無鎖的,jvm就會在當前線程的棧幀中建立一個叫鎖記錄(Lock Record)的空間,用來存儲鎖對象的Mark Word 拷貝,然後把Lock Record中的owner指向當前對象。
JVM接下來會利用CAS嘗試把對象原本的Mark Word 更新會Lock Record的指針,成功就說明加鎖成功,改變鎖標誌位,執行相關同步操作。
如果失敗了,就會判斷當前對象的Mark Word是否指向了當前線程的棧幀,是則表示當前的線程已經持有了這個對象的鎖,否則說明被其他線程持有了,繼續鎖升級,修改鎖的狀態,之後等待的線程也阻塞。

自旋鎖
我不是在上面提到了Linux系統的用戶態和內核態的切換很耗資源,其實就是線程的等待喚起過程,那怎麼才能減少這種消耗呢?
自旋,過來的現在就不斷自旋,防止線程被掛起,一旦可以獲取資源,就直接嘗試成功,直到超出閾值,自旋鎖的默認大小是10次,-XX:PreBlockSpin可以修改。
自旋都失敗了,那就升級為重量級的鎖,像1.5的一樣,等待喚起咯。

至此我基本上吧synchronized的前後概念都講到了,大家好好消化。
資料參考:《高並發編程》《黑馬程序員講義》《深入理解JVM虛擬機》
用synchronized還是Lock呢?
我們先看看他們的區別:
-
synchronized是關鍵字,是JVM層面的底層啥都幫我們做了,而Lock是一個接口,是JDK層面的有豐富的API。
-
synchronized會自動釋放鎖,而Lock必須手動釋放鎖。
-
synchronized是不可中斷的,Lock可以中斷也可以不中斷。
-
通過Lock可以知道線程有沒有拿到鎖,而synchronized不能。
-
synchronized能鎖住方法和代碼塊,而Lock只能鎖住代碼塊。
-
Lock可以使用讀鎖提高多線程讀效率。
-
synchronized是非公平鎖,ReentrantLock可以控制是否是公平鎖。
兩者一個是JDK層面的一個是JVM層面的,我覺得最大的區別其實在,我們是否需要豐富的api,還有一個我們的場景。
比如我現在是滴滴,我早上有打車高峰,我代碼使用了大量的synchronized,有什麼問題?鎖升級過程是不可逆的,過了高峰我們還是重量級的鎖,那效率是不是大打折扣了?這個時候你用Lock是不是很好?
場景是一定要考慮的,我現在告訴你哪個好都是扯淡,因為脫離了業務,一切技術討論都沒有了價值。

我是敖丙,一個在互聯網苟且偷生的工具人。
你知道的越多,你不知道的越多,人才們的 【三連】 就是丙丙創作的最大動力,我們下期見!
註:如果本篇博客有任何錯誤和建議,歡迎人才們留言!
文章持續更新,可以微信搜索「 三太子敖丙 」第一時間閱讀,回復【資料】有我準備的一線大廠面試資料和簡歷模板,本文 GitHub //github.com/JavaFamily 已經收錄,有大廠面試完整考點,歡迎Star。
