synchronized修飾代碼塊
以上文SynchronizedTest2類為例子,其中synchronized關鍵字修飾代碼塊
獲取SynchronizedTest2.class的字節碼:
javac -encoding utf-8 SynchronizedTest2.java
javap -c -v SynchronizedTest2.class
Classfile /D:/ideaProjects/src/main/java/com/zj/ideaprojects/demo/test2/SynchronizedTest2.class
Last modified 2022-10-28; size 575 bytes
MD5 checksum ac915d460a3da67f6c76c5ed2aae01f1
Compiled from "SynchronizedTest2.java"
public class com.zj.ideaprojects.demo.test2.SynchronizedTest2
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#18 // java/lang/Object."
#2 = Fieldref #19.#20 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #21 // synchronized ???? ?????
#4 = Methodref #22.#23 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #24 // com/zj/ideaprojects/demo/test2/SynchronizedTest2
#6 = Class #25 // java/lang/Object
#7 = Utf8
我們可以發現:synchronized 同步語句塊的在字節碼中的實現,是使用了 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代碼塊的開始位置,monitorexit 指令則指明同步代碼塊的結束位置。
- 每個對象都擁有一個monitor,當monitor被占用時,就會處于鎖定狀態,線程執行monitorenter指令時會獲取monitor的所有權。
- 當monitor計數為0時,說明該monitor還未被鎖定,此時線程會進入monitor并將monitor的計數器設為1,并且該線程就是monitor的所有者。如果此線程已經獲取到了monitor鎖,再重新進入monitor鎖的話,那么會將計時器count的值加1。
- 如果有線程已經占用了monitor鎖,此時有其他的線程來獲取鎖,那么此線程將進入阻塞狀態,待monitor的計時器count變為0,這個線程才會獲取到monitor鎖。
- 只有拿到了monitor鎖對象的線程才能執行monitorexit指令。在執行 monitorexit 指令后,將鎖計數器設為 0,表明鎖被釋放,其他線程可以嘗試獲取鎖。
- 如果獲取對象鎖失敗,那當前線程就要阻塞等待,直到鎖被另外一個線程釋放為止
有個奇怪的現象不知道大家有沒有發現?為什么monitorenter指令
只出現了一次,但是monitorexit指令
卻出現了2次?
因為編譯器必須保證無論同步代碼塊中的代碼以何種方式結束,代碼中每次調用monitorenter必須執行對應的monitorexit指令。如果沒有執行 monitorexit指令,monitor一直被占用,其他線程都無法獲取,這是非常危險的。
這個就很像"try catch finally"中的
finally
,不管程序運行結果如何,必須要執行monitorexit指令
,釋放monitor所有權
小結一下:
- 同步代碼塊是通過monitorenter和monitorexit指令來實現;同步方式是通過方法中的access_flags中設置ACC_SYNCHRONIZED標識符來實現,ACC_SYNCHRONIZED標識符會去隱式調用這兩個指令:monitorenter和monitorexit
- synchronized修飾方法、修飾代碼塊 ,歸根到底,都是通過競爭monitor所有權來實現同步的
- 每個java對象都會與一個monitor相關聯,可以由線程獲取和釋放
- monitor通過維護一個計數器來記錄鎖的獲取,重入,釋放情況
鎖優化
為什么說JDK早期,Synchronized是重量級鎖呢?在JVM中monitorenter和monitorexit字節碼依賴于底層的操作系統的Mutex Lock
來實現的,但是由于使用Mutex Lock
需要將 當前線程掛起并從用戶態切換到內核態來申請鎖資源,還需要經過一個中斷的調用,申請完之后還需要從內核態返回到用戶態 。整個切換過程是非常消耗資源的,如果程序中存在大量的鎖競爭,那么會引起程序頻繁的在用戶態和內核態進行切換,嚴重影響到程序的性能。
在Linux系統架構中可以分為用戶空間和內核,我們的程序都運行在用戶空間,進入用戶運行狀態就是所謂的用戶態。在用戶態可能會涉及到某些操作如I/O調用,就會進入內核中運行,此時進程就被稱為內核運行態,簡稱內核態。
為了解決這一問題,在JDK1.6對Synchronized進行大量的優化 , 鎖自旋、鎖粗化、鎖消除,鎖膨脹等技術,在這部分擴展內容比較多,我們接下來一一道來。
自旋鎖
在jdk1.6前多線程競爭鎖時,當一個線程A獲取鎖時,它會阻塞其他所有正在競爭的線程,這樣對性能帶來了極大的影響。在掛起線程和恢復線程的操作都需要轉入內核態中完成,這些操作對系統的并發性能帶來了很大的壓力。由于在實際環境中, 很多線程的鎖定狀態只會持續很短的一段時間,會很快釋放鎖 ,為了如此短暫的時間去掛起和阻塞其他所有競爭鎖的線程,是非常浪費資源的,我們完全可以讓另一個沒有獲取到鎖的線程在門外等待一會(自旋),但 不放棄CPU的執行時間 ,等待持有鎖的線程A釋放鎖,就里面去獲得鎖。這其實就是自旋鎖
但是我們也無法保證線程獲取鎖之后,就一定很快釋放鎖。萬一遇到有線程,長時間不釋放鎖,其會帶來更多的性能開銷。因為在線程自旋時,始終會占用CPU的時間片,如果鎖占用的時間太長,那么自旋的線程會消耗掉CPU資源。 所以我們需要對鎖自旋的次數有所限制,如果自旋超過了限定的次數仍然沒有成功獲取到鎖,就應該重新使用傳統的方式去掛起線程了 。在JDK定義中,自旋鎖默認的自旋次數為10次,用戶可以使用參數-XX:PreBlockSpin
來更改。
后來也有改進型的 自適應自旋鎖, 自適應意味著自旋的次數不在固定,而是由前一次在同一個鎖上的自旋時間和鎖的擁有者的狀態共同決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運行中,那么虛擬機就會認為這次自旋也是很可能再次成功的,進而它將會允許線程自旋相對更長的時間。如果對于某個鎖,線程很少成功獲得過,則會相應減少自旋的時間甚至直接進入阻塞的狀態,避免浪費處理器資源。筆者感覺這個跟CPU的分支預測,有異曲同工之妙
鎖粗化
一般來說,同步塊的作用范圍應該盡可能小,縮短阻塞時間,如果存在鎖競爭,那么等待鎖的線程也能盡快獲取鎖 但某些情況下,可能會對同一個鎖頻繁訪問,或者有人在循環里面寫上了synchronized關鍵字,為了降低短時間內大量的鎖請求、釋放帶來的性能損耗,Java虛擬機發現了之后會 適當擴大加鎖的范圍,以避免頻繁的拿鎖釋放鎖的過程 。將多個鎖請求合并為一個請求,這就是鎖粗化
public class LockCoarseningTest {
public String test() {
StringBuffer sb = new StringBuffer();
for(int i = 0; i < 100; i++) {
sb.append("test");
}
return sb.toString();
}
}
append() 為同步方法,短時間內大量進行鎖請求、鎖釋放,JVM 會自動進行鎖粗化,將加鎖范圍擴大至 for 循環外部,從而只需要進行一次鎖請求、鎖釋放
鎖消除
鎖消除:通過運行時JIT編譯器的逃逸分析來消除一些沒有在當前同步塊以外被其他線程共享的數據的鎖保護,通過逃逸分析也可以在線程本的Stack上進行對象空間的分配(同時還可以減少Heap上的垃圾收集開銷)。其實就是即時編譯器通過對運行上下文的掃描,對不可能存在共享資源競爭的鎖進行消除,從而節約大量的資源開銷,提高效率
public class LockEliminateTest {
static int i = 0;
public void method1() {
i++;
}
public void method2() {
Object obj = new Object();
synchronized (obj) {
i++;
}
}
}
method2() 方法中的 obj 為局部變量,顯然不可能被共享,對其加鎖也毫無意義,故被即時編譯器消除
鎖膨脹
鎖膨脹方向:無鎖 → 偏向鎖 → 輕量級鎖 → 重量級鎖
偏向鎖、輕量級鎖,這兩個鎖既是一種優化策略,也是一種膨脹過程,接下來我們分別聊聊
偏向鎖
在大多數情況下雖然加了鎖,但是沒有鎖競爭的發生,甚至是同一個線程反復獲得這個鎖,那么多次的獲取鎖和釋放鎖會帶來很多不必要的性能開銷和上下文切換。偏向鎖就為了針對這種情況而出現的
偏向鎖指, 鎖偏向于第一個獲取他的線程 ,若接下來的執行過程中,該鎖一直沒有被其他線程獲取,則持有偏向鎖的線程永遠不需要再進行同步。 這樣就在無鎖競爭的情況下避免在鎖獲取過程中執行不必要的獲取鎖和釋放鎖操作 。
偏向鎖的具體過程:
- 首先JVM要設置為可用偏向鎖。然后當一個進程訪問同步塊并且獲得鎖的時候,會在對象頭和棧幀的鎖記錄里面存儲取得偏向鎖的線程ID。
- 等下一次有線程嘗試獲取鎖的時候,首先檢查這個對象頭的MarkWord是不是儲存著這個線程的ID。如果是,那么直接進去而不需要任何別的操作。
- 如果不是,那么分為兩種情況:
- 對象的偏向鎖標志位為0(當前不是偏向鎖),說明發生了競爭,已經膨脹為輕量級鎖,這時使用CAS操作嘗試獲得鎖。
- 偏向鎖標志位為1,說明還是偏向鎖不過請求的線程不是原來那個了。這時只需要使用CAS嘗試把對象頭偏向鎖從原來那個線程指向目前求鎖的線程。
輕量級鎖
在實際情況中,大部分的鎖,在整個同步生命周期內都不存在競爭,在無鎖競爭的情況下完全可以避免調用操作系統層面的 重量級互斥鎖, 可以通過CAS原子指令就可以完成鎖的獲取及釋放。當存在鎖競爭的情況下,執行CAS指令失敗的線程將調用操作系統互斥鎖進入到阻塞狀態,當鎖被釋放的時候被喚醒。當升級為輕量級鎖之后,MarkWord
的結構也會隨之變為輕量級鎖結構。JVM會利用CAS嘗試把對象原本的MarkWord
更新為Lock Record
的指針,成功就說明加鎖成功,改變鎖標志位為00,然后執行相關同步操作。輕量級鎖所適應的場景是 線程交替執行同步塊的場合 ,如果存在同一時間訪問同一鎖的場合,就會導致輕量級鎖就會失效,進而膨脹為重量級鎖。
CAS (Compare-And-Swap):顧名思義 比較并替換 。這是一個由CPU硬件提供并實現的原子操作.可以被認為是一種 樂觀鎖 ,會以一種更加樂觀的態度對待事情,認為自己可以操作成功。當多個線程操作同一個共享資源時,僅能有一個線程同一時間獲得鎖成功,在樂觀鎖中,其他線程發現自己無法成功獲得鎖,并不會像悲觀鎖那樣阻塞線程,而是直接返回,可以去選擇再次重試獲得鎖,也可以直接退出
CAS機制所保證的只是一個變量的原子性操作,無法保證整個代碼塊的原子性
最后再小結一下,鎖的優缺點對比:
鎖 | 優點 | 缺點 | 使用場景 |
---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要CAS操作,沒有額外的性能消耗,和執行非同步方法相比僅存在納秒級的差距 | 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 | 適用于只有一個線程訪問同步塊的場景 |
輕量級鎖 | 競爭的線程不會阻塞,提高了響應速度 | 如線程成始終得不到鎖競爭的線程,使用自旋會消耗CPU性能 | 追求響應時間,同步塊執行速度非常快 |
重量級鎖 | 線程競爭不適用自旋,不會消耗CPU | 線程阻塞,響應時間緩慢,在多線程下,頻繁的獲取釋放鎖,會帶來巨大的性能消耗 | 追求吞吐量,同步塊執行速度較長 |
最高效的是偏向鎖,盡量使用偏向鎖,如果不能(發生了競爭)就膨脹為輕量級鎖,當發生鎖競爭時,輕量級鎖的CAS操作會自動失效,鎖再次膨脹為重量級鎖。 鎖一般是只能升級但不能降級 ,這種鎖升級卻不能降級的策略,目的是 為了提高獲得鎖和釋放鎖的效率。( hotspot其實是可以發生鎖降級的,但觸發鎖降級的條件比較苛刻**)**
偏向鎖,輕量級鎖,只需在用戶態就可以實現,而不需要進行用戶態和內核態之間的切換
經過如此多的鎖優化,如今的 synchronized 鎖效率非常不錯,目前不論是各種開源框架還是 JDK 源碼都大量使用了 synchronized 關鍵字。
synchronized關鍵字實現單例模式
我們來看一個經典的例子,利用synchronized關鍵字
實現單例模式
/**
* 懶漢 - 雙層校驗鎖
*/
public class SingleDoubleCheck {
private static SingleDoubleCheck instance = null;
private SingleDoubleCheck(){}//將構造器 私有化,防止外部調用
public static SingleDoubleCheck getInstance() {
if (instance == null) { //part 1
synchronized (SingleDoubleCheck.class) {
if (instance == null) { //part 2
instance = new SingleDoubleCheck();//part 3
}
}
}
return instance;
}
}
對單例模式感興趣的話,見拓展:https://mp.weixin.qq.com/s/TyiCfVMeeDwa-2hd9N9XJQ
synchronized 和 volatile 的區別?
synchronized 關鍵字和 volatile 關鍵字是兩個互補的存在,而不是對立的存在
- volatile 關鍵字是線程同步的輕量級實現,所以 volatile性能肯定比synchronized關鍵字要好 。但是 volatile 關鍵字只能用于變量而 synchronized 關鍵字可以修飾方法以及代碼塊 。
- volatile 關鍵字能保證數據的可見性,但不能保證數據的原子性。synchronized 關鍵字兩者都能保證。
- volatile關鍵字主要用于解決變量在多個線程之間的可見性,而 synchronized 關鍵字解決的是多個線程之間訪問資源的同步性。
- volatile只能修飾實例變量和類變量,而synchronized可以修飾方法,以及代碼塊。
尾語
本文拓展內容確實有點多,很開心你能看到最后,我們再簡明地回顧一下synchronized 的特性
- 原子性:確保線程互斥的訪問同步代碼。synchronized保證只有一個線程拿到鎖,進入同步代碼塊操作共享資源,因此具有原子性。
- 可見性:保證共享變量的修改能夠及時可見。當某線程進入synchronized代碼塊前后,線程會獲得鎖,清空工作內存,從主內存拷貝共享變量最新的值到工作內存成為副本,執行代碼,將修改后的副本的值刷新回主內存中,線程釋放鎖。其他獲取不到鎖的線程會阻塞等待,所以變量的值一直都是最新的。
- 有序性:synchronized內的代碼和外部的代碼禁止排序,至于內部的代碼,則不會禁止排序,但是由于只有一個線程進入同步代碼塊,因此在同步代碼塊中相當于是單線程的,根據 as-if-serial 語義,即使代碼塊內發生了重排序,也不會影響程序執行的結果。
- 悲觀鎖:synchronized是悲觀鎖。每次使用共享資源時都認為會和其他線程產生競爭,所以每次使用共享資源都會上鎖。
- 獨占鎖(排他鎖):synchronized是獨占鎖(排他鎖)。該鎖一次只能被一個線程所持有,其他線程被阻塞。
- 非公平鎖:synchronized是非公平鎖。線程獲取鎖的順序可以不按照線程的阻塞順序。允許新來的線程有可能立即獲得監視器,而在等待區中等候已久的線程可能再次等待。這樣有利于提高性能,但是也可能會導致饑餓現象
- 可重入鎖:synchronized是可重入鎖。持鎖線程可以再次獲取自己的內部的鎖,可一定程度避免死鎖。
參考資料:
https://openjdk.org/groups/hotspot/docs/HotSpotGlossary.html
《深入理解java虛擬機》
《Java并發編程的藝術》
https://www.cnblogs.com/qingshan-tang/p/12698705.html
https://www.cnblogs.com/jajian/p/13681781.html
-
JAVA
+關注
關注
19文章
2974瀏覽量
105002 -
代碼
+關注
關注
30文章
4823瀏覽量
68916 -
線程安全
+關注
關注
0文章
13瀏覽量
2471
發布評論請先 登錄
相關推薦
評論