前言
分享一篇優質文章給你。
本文帶讀者們由淺入深了解Synchronized,讓讀者們也能與面試官瘋狂對線,同時寫出高性能的代碼和架構。
在并發程式設計中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中存儲内容的是重量級鎖的指針,等待鎖的線程都會進入阻塞狀态,下面是簡化版的鎖更新過程。