13 張圖,深入理解 Synchronize
前言
在併發編程中Synchronized
一直都是元老級的角色,Jdk 1.6
以前大家都稱呼它爲重量級鎖,相對於J U C
包提供的Lock
,它會顯得笨重,不過隨着Jdk 1.6
對Synchronized
進行各種優化後,Synchronized
性能已經非常快了。
內容大綱
Synchronized 使用方式
Synchronized
是Java
提供的同步關鍵字,在多線程場景下,對共享資源代碼段進行讀寫操作(必須包含寫操作,光讀不會有線程安全問題,因爲讀操作天然具備線程安全特性),可能會出現線程安全問題,我們可以使用Synchronized
鎖定共享資源代碼段,達到互斥(mutualexclusion
)效果,保證線程安全。
共享資源代碼段又稱爲臨界區(critical section
),保證臨界區互斥,是指執行臨界區(critical section
)的只能有一個線程執行,其他線程阻塞等待,達到排隊效果。
Synchronized
的食用方式有三種
-
修飾普通函數,監視器鎖(
monitor
)便是對象實例(this
) -
修飾靜態靜態函數,視器鎖(
monitor
)便是對象的Class
實例(每個對象只有一個Class
實例) -
修飾代碼塊,監視器鎖(
monitor
)是指定對象實例
普通函數使用Synchronized
的方式很簡單,在訪問權限修飾符與函數返回類型間加上Synchronized
。
多線程場景下,thread
與threadTwo
兩個線程執行incr
函數,incr
函數作爲共享資源代碼段被多線程讀寫操作,我們將它稱爲臨界區,爲了保證臨界區互斥,使用Synchronized
修飾incr
函數即可。
public class SyncTest {
private int j = 0;
/**
* 自增方法
*/
public synchronized void incr(){
//臨界區代碼--start
for (int i = 0; i < 10000; i++) {
j++;
}
//臨界區代碼--end
}
public int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
SyncTest syncTest = new SyncTest();
Thread thread = new Thread(() -> syncTest.incr());
Thread threadTwo = new Thread(() -> syncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最終打印結果是20000,如果不使用synchronized修飾,就會導致線程安全問題,輸出不確定結果
System.out.println(syncTest.getJ());
}
}
代碼十分簡單,incr
函數被synchronized
修飾,函數邏輯是對j
進行10000
次累加,兩個線程執行incr
函數,最後輸出j
結果。
被synchronized
修飾函數我們簡稱同步函數,線程執行稱同步函數前,需要先獲取監視器鎖,簡稱鎖,獲取鎖成功才能執行同步函數,同步函數執行完後,線程會釋放鎖並通知喚醒其他線程獲取鎖,獲取鎖失敗「則阻塞並等待通知喚醒該線程重新獲取鎖」,同步函數會以this
作爲鎖,即當前對象,以上面的代碼段爲例就是syncTest
對象。
-
線程
thread
執行syncTest.incr()
前 -
線程
thread
獲取鎖成功 -
線程
threadTwo
執行syncTest.incr()
前 -
線程
threadTwo
獲取鎖失敗 -
線程
threadTwo
阻塞並等待喚醒 -
線程
thread
執行完syncTest.incr()
,j
累積到10000
-
線程
thread
釋放鎖,通知喚醒threadTwo
線程獲取鎖 -
線程
threadTwo
獲取鎖成功 -
線程
threadTwo
執行完syncTest.incr()
,j
累積到20000
-
線程
threadTwo
釋放鎖
靜態函數
靜態函數顧名思義,就是靜態的函數,它使用Synchronized
的方式與普通函數一致,唯一的區別是鎖的對象不再是this
,而是Class
對象。
多線程執行Synchronized
修飾靜態函數代碼段如下。
public class SyncTest {
private static int j = 0;
/**
* 自增方法
*/
public static synchronized void incr(){
//臨界區代碼--start
for (int i = 0; i < 10000; i++) {
j++;
}
//臨界區代碼--end
}
public static int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
Thread thread = new Thread(() -> SyncTest.incr());
Thread threadTwo = new Thread(() -> SyncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最終打印結果是20000,如果不使用synchronized修飾,就會導致線程安全問題,輸出不確定結果
System.out.println(SyncTest.getJ());
}
}
Java
的靜態資源可以直接通過類名調用,靜態資源不屬於任何實例對象,它只屬於Class
對象,每個Class
在J V M
中只有唯一的一個Class
對象,所以同步靜態函數會以Class
對象作爲鎖,後續獲取鎖、釋放鎖流程都一致。
代碼塊
前面介紹的普通函數與靜態函數粒度都比較大,以整個函數爲範圍鎖定,現在想把範圍縮小、靈活配置,就需要使用代碼塊了,使用{}
符號定義範圍給Synchronized
修飾。
下面代碼中定義了syncDbData
函數,syncDbData
是一個僞同步數據的函數,耗時2
秒,並且邏輯不涉及共享資源讀寫操作(非臨界區),另外還有兩個函數incr
與incrTwo
,都是在自增邏輯前執行了syncDbData
函數,只是使用Synchronized
的姿勢不同,一個是修飾在函數上,另一個是修飾在代碼塊上。
public class SyncTest {
private static int j = 0;
/**
* 同步庫數據,比較耗時,代碼資源不涉及共享資源讀寫操作。
*/
public void syncDbData() {
System.out.println("db數據開始同步------------");
try {
//同步時間需要2秒
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("db數據開始同步完成------------");
}
//自增方法
public synchronized void incr() {
//start--臨界區代碼
//同步庫數據
syncDbData();
for (int i = 0; i < 10000; i++) {
j++;
}
//end--臨界區代碼
}
//自增方法
public void incrTwo() {
//同步庫數據
syncDbData();
synchronized (this) {
//start--臨界區代碼
for (int i = 0; i < 10000; i++) {
j++;
}
//end--臨界區代碼
}
}
public int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
//incr同步方法執行
SyncTest syncTest = new SyncTest();
Thread thread = new Thread(() -> syncTest.incr());
Thread threadTwo = new Thread(() -> syncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最終打印結果是20000
System.out.println(syncTest.getJ());
//incrTwo同步塊執行
thread = new Thread(() -> syncTest.incrTwo());
threadTwo = new Thread(() -> syncTest.incrTwo());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最終打印結果是40000
System.out.println(syncTest.getJ());
}
}
先看看incr
同步方法執行,流程和前面沒區別,只是Synchronized
鎖定的範圍太大,把syncDbData()
也納入臨界區中,多線程場景執行,會有性能上的浪費,因爲syncDbData()
完全可以讓多線程並行或併發執行。
我們通過代碼塊的方式,來縮小範圍,定義正確的臨界區,提升性能,目光轉到incrTwo
同步塊執行,incrTwo
函數使用修飾代碼塊的方式同步,只對自增代碼段進行鎖定。
代碼塊同步方式除了靈活控制範圍外,還能做線程間的協同工作,因爲Synchronized ()
括號中能接收任何對象作爲鎖,所以可以通過Object
的wait、notify、notifyAll
等函數,做多線程間的通信協同(本文不對線程通信協同做展開,主角是Synchronized
,而且也不推薦去用這些方法,因爲LockSupport
工具類會是更好的選擇)。
-
wait:當前線程暫停,釋放鎖
-
notify:釋放鎖,喚醒調用了 wait 的線程(如果有多個隨機喚醒一個)
-
notifyAll:釋放鎖,喚醒調用了 wait 的所有線程
Synchronized 原理
public class SyncTest {
private static int j = 0;
/**
* 同步庫數據,比較耗時,代碼資源不涉及共享資源讀寫操作。
*/
public void syncDbData() {
System.out.println("db數據開始同步------------");
try {
//同步時間需要2秒
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("db數據開始同步完成------------");
}
//自增方法
public synchronized void incr() {
//start--臨界區代碼
//同步庫數據
syncDbData();
for (int i = 0; i < 10000; i++) {
j++;
}
//end--臨界區代碼
}
//自增方法
public void incrTwo() {
//同步庫數據
syncDbData();
synchronized (this) {
//start--臨界區代碼
for (int i = 0; i < 10000; i++) {
j++;
}
//end--臨界區代碼
}
}
public int getJ() {
return j;
}
}
爲了探究Synchronized
原理,我們對上面的代碼進行反編譯,輸出反編譯後結果,看看底層是如何實現的(環境 Java 11、win 10 系統)。
只截取了incr與incrTwo函數內容
public synchronized void incr();
Code:
0: aload_0
1: invokevirtual #11 // Method syncDbData:()V
4: iconst_0
5: istore_1
6: iload_1
7: sipush 10000
10: if_icmpge 27
13: getstatic #12 // Field j:I
16: iconst_1
17: iadd
18: putstatic #12 // Field j:I
21: iinc 1, 1
24: goto 6
27: return
public void incrTwo();
Code:
0: aload_0
1: invokevirtual #11 // Method syncDbData:()V
4: aload_0
5: dup
6: astore_1
7: monitorenter //獲取鎖
8: iconst_0
9: istore_2
10: iload_2
11: sipush 10000
14: if_icmpge 31
17: getstatic #12 // Field j:I
20: iconst_1
21: iadd
22: putstatic #12 // Field j:I
25: iinc 2, 1
28: goto 10
31: aload_1
32: monitorexit //正常退出釋放鎖
33: goto 41
36: astore_3
37: aload_1
38: monitorexit //異步退出釋放鎖
39: aload_3
40: athrow
41: return
ps: 對上面指令感興趣的讀者,可以百度或 google 一下 “JVM 虛擬機字節碼指令表”
先看incrTwo
函數,incrTwo
是代碼塊方式同步,在反編譯後的結果中,我們發現存在monitorenter
與monitorexit
指令(獲取鎖、釋放鎖)。
monitorenter
指令插入到同步代碼塊的開始位置,monitorexit
指令插入到同步代碼塊的結束位置,J V M
需要保證每一個 monitorenter
都有monitorexit
與之對應。
任何對象都有一個監視器鎖(monitor
)關聯,線程執行monitorenter
指令時嘗試獲取monitor
的所有權。
-
如果
monitor
的進入數爲0
,則該線程進入monitor
,然後將進入數設置爲1
,該線程爲monitor
的所有者 -
如果線程已經佔有該
monitor
,重新進入,則monitor
的進入數加1
-
線程執行
monitorexit
,monitor
的進入數 - 1,執行過多少次monitorenter
,最終要執行對應次數的monitorexit
-
如果其他線程已經佔用
monitor
,則該線程進入阻塞狀態,直到monitor
的進入數爲 0,再重新嘗試獲取monitor
的所有權
回過頭看incr
函數,incr
是普通函數方式同步,雖然在反編譯後的結果中沒有看到monitorenter
與monitorexit
指令,但是實際執行的流程與incrTwo
函數一樣,通過monitor
來執行,只不過它是一種隱式的方式來實現,最後放一張流程圖。
Synchronized 優化
Jdk 1.5
以後對Synchronized
關鍵字做了各種的優化,經過優化後Synchronized
已經變得原來越快了,這也是爲什麼官方建議使用Synchronized
的原因,具體的優化點如下。
-
鎖粗化
-
鎖消除
-
鎖升級
鎖粗化
互斥的臨界區範圍應該儘可能小,這樣做的目的是爲了使同步的操作數量儘可能縮小,縮短阻塞時間,如果存在鎖競爭,那麼等待鎖的線程也能儘快拿到鎖。
但是加鎖解鎖也需要消耗資源,如果存在一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,鎖粗化就是將「多個連續的加鎖、解鎖操作連接在一起」,擴展成一個範圍更大的鎖,避免頻繁的加鎖解鎖操作。
J V M
會檢測到一連串的操作都對同一個對象加鎖(for
循環10000
次執行j++
,沒有鎖粗化就要進行10000
次加鎖 / 解鎖),此時J V M
就會將加鎖的範圍粗化到這一連串操作的外部(比如for
循環體外),使得這一連串操作只需要加一次鎖即可。
鎖消除
Java
虛擬機在JIT
編譯時 (可以簡單理解爲當某段代碼即將第一次被執行時進行編譯,又稱即時編譯),通過對運行上下文的掃描,經過逃逸分析(對象在函數中被使用,也可能被外部函數所引用,稱爲函數逃逸),去除不可能存在共享資源競爭的鎖,通過這種方式消除沒有必要的鎖,可以節省毫無意義的時間消耗。
代碼中使用Object
作爲鎖,但是Object
對象的生命週期只在incrFour()
函數中,並不會被其他線程所訪問到,所以在J I T
編譯階段就會被優化掉(此處的Object
屬於沒有逃逸的對象)。
鎖升級
Java
中每個對象都擁有對象頭,對象頭由Mark World
、指向類的指針、以及數組長度三部分組成,本文,我們只需要關心Mark World
即可,Mark World
記錄了對象的HashCode
、分代年齡和鎖標誌位信息。
Mark World 簡化結構
- 鎖狀態 存儲內容 鎖標記
- 無鎖 對象的 hashCode、對象分代年齡、是否是偏向鎖(0) 01
- 偏向鎖 偏向線程 ID、偏向時間戳、對象分代年齡、是否是偏向鎖(1) 01
- 輕量級鎖 指向棧中鎖記錄的指針 00
- 重量級鎖 指向互斥量(重量級鎖)的指針 10
讀者們只需知道,鎖的升級變化,體現在鎖對象的對象頭Mark World
部分,也就是說Mark World
的內容會隨着鎖升級而改變。
Java1.5
以後爲了減少獲取鎖和釋放鎖帶來的性能消耗,引入了偏向鎖和輕量級鎖,Synchronized
的升級順序是 「無鎖 --> 偏向鎖 --> 輕量級鎖 --> 重量級鎖,只會升級不會降級」
偏向鎖
在大多數情況下,鎖總是由同一線程多次獲得,不存在多線程競爭,所以出現了偏向鎖,其目標就是在只有一個線程執行同步代碼塊時,降低獲取鎖帶來的消耗,提高性能(可以通過 J V M 參數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之後程序默認會進入輕量級鎖狀態)。
線程執行同步代碼或方法前,線程只需要判斷對象頭的Mark Word
中線程ID
與當前線程ID
是否一致,如果一致直接執行同步代碼或方法,具體流程如下
-
無鎖狀態,存儲內容「是否爲偏向鎖(
0
)」,鎖標識位01
-
CAS
設置當前線程 ID 到Mark Word
存儲內容中 -
是否爲偏向鎖
0
=> 是否爲偏向鎖1
-
執行同步代碼或方法
-
偏向鎖狀態,存儲內容「是否爲偏向鎖(
1
)、線程 ID」,鎖標識位01
-
對比線程
ID
是否一致,如果一致執行同步代碼或方法,否則進入下面的流程 -
如果不一致,
CAS
將Mark Word
的線程ID
設置爲當前線程ID
,設置成功,執行同步代碼或方法,否則進入下面的流程 -
CAS
設置失敗,證明存在多線程競爭情況,觸發撤銷偏向鎖,當到達全局安全點,偏向鎖的線程被掛起,偏向鎖升級爲輕量級鎖,然後在安全點的位置恢復繼續往下執行。
輕量級鎖
輕量級鎖考慮的是競爭鎖對象的線程不多,持有鎖時間也不長的場景。因爲阻塞線程需要C P U
從用戶態轉到內核態,代價較大,如果剛剛阻塞不久這個鎖就被釋放了,那這個代價就有點得不償失,所以乾脆不阻塞這個線程,讓它自旋一段時間等待鎖釋放。
當前線程持有的鎖是偏向鎖的時候,被另外的線程所訪問,偏向鎖就會升級爲輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,從而提高性能。輕量級鎖的獲取主要有兩種情況:① 當關閉偏向鎖功能時;② 多個線程競爭偏向鎖導致偏向鎖升級爲輕量級鎖。
-
無鎖狀態,存儲內容「是否爲偏向鎖(
0
)」,鎖標識位01
-
關閉偏向鎖功能時
-
CAS
設置當前線程棧中鎖記錄的指針到Mark Word
存儲內容 -
鎖標識位設置爲
00
-
執行同步代碼或方法
-
釋放鎖時,還原來
Mark Word
內容 -
輕量級鎖狀態,存儲內容「線程棧中鎖記錄的指針」,鎖標識位
00
(存儲內容的線程是指 "持有輕量級鎖的線程") -
CAS
設置當前線程棧中鎖記錄的指針到Mark Word
存儲內容,設置成功獲取輕量級鎖,執行同步塊代碼或方法,否則執行下面的邏輯 -
設置失敗,證明多線程存在一定競爭,線程自旋上一步的操作,自旋一定次數後還是失敗,輕量級鎖升級爲重量級鎖
-
Mark Word
存儲內容替換成重量級鎖指針,鎖標記位10
重量級鎖
輕量級鎖膨脹之後,就升級爲重量級鎖,重量級鎖是依賴操作系統的MutexLock
(互斥鎖)來實現的,需要從用戶態轉到內核態,這個成本非常高,這就是爲什麼Java1.6
之前Synchronized
效率低的原因。
升級爲重量級鎖時,鎖標誌位的狀態值變爲10
,此時Mark Word
中存儲內容的是重量級鎖的指針,等待鎖的線程都會進入阻塞狀態,下面是簡化版的鎖升級過程。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/zP1XnF_wvUD0HRS4cDCdVg