開篇閒扯
打工人,打工魂,我們生而人上人。當“資本主義”逐漸禁錮我們人(大)上(韭)人(菜)肉體的時候,那一刻我才明白那個日不落帝國·資本主義收割機·瑞民族之光幸·瑞幸咖啡是多麼的了不起,儘管我不懂咖啡,但還是要說一聲謝謝!說到咖啡,喝完就想上廁所,對寫bug的我來說太不友好了,畢竟我不(很)喜歡帶薪上廁所。
迴歸本次的不正經Java文章,本次新聞主要內容有...tui~~嘴瓢了。上篇文章末尾處已經提到了,主要會把我對Synchronized的理解進行一次全方位的梳理,如果能幫助到大家吊打面試官,萬分榮幸。
Synchronized起源
那是個月黑風高的夜晚,Doug Lee先生像我們一樣喝了咖啡憋著尿加班到深夜,只是他在寫JDK,我們在用他的JDK寫BUG。在創作JDK1.5之前,他忘了在Java語言中提供同步可擴充套件的同步介面或者方法了,於是在1.5之前給了我們一個惡Synchronized湊合用一下,而到了JDK1.5之後,增加了Lock介面及很多原生的併發包供我們使用。因此,Synchronized作為關鍵字的形式存在了很久,且在後續JDK1.6的版本中對它做了很多優化,從而提升它的效能,使它能夠跟Lock有一戰之力。好了,講完了,再見!
Synchronized是什麼
如果我說,Synchronized是一種基於JVM中物件監視器的隱式非公平可重入重量級鎖(這頭銜跟瑞幸有一拼),加解鎖都是靠JVM內部自動實現的,吧啦吧啦...簡稱"面試八股文",很顯然我不能這麼寫,這樣還不如直接甩個部落格連結來的快。來,解釋一下上面那句話,隱式鎖是基於作業系統的MutexLock實現的,每次加解鎖操作都會帶來使用者態與核心態的切換,導致系統增加很多額外的開銷。可以自行百度學習一下使用者態與核心態的定義,這裡就不贅述了。同時Synchronized的加解鎖過程開發人員是不可控的,失去了可擴充套件性。
接下來我們通過一個例子,看一看Synchronized在編譯後到底是什麼樣子,上才(代)藝(碼):
/**
* FileName: SynchronizeDetail
* Author: RollerRunning
* Date: 2020/11/30 10:10 PM
* Description: 詳解Synchronized
*/
public class SynchronizeDetail {
public synchronized void testRoller() {
System.out.println("Roller Running!");
}
public void testRunning(){
synchronized (SynchronizeDetail.class){
System.out.println("Roller Running!");
}
}
}
將上面的原始碼進行編譯再輸出編譯後的程式碼:
public com.design.model.singleton.SynchronizeDetail();
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 9: 0
public synchronized void testRoller();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Roller Running!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 14: 0
line 15: 8
public void testRunning();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #5 // class com/design/model/singleton/SynchronizeDetail
2: dup
3: astore_1
4: monitorenter
5: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #3 // String Roller Running!
10: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
Exception table:
from to target type
5 15 18 any
18 21 18 any
LineNumberTable:
line 17: 0
line 18: 5
line 19: 13
line 20: 23
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 18
locals = [ class com/design/model/singleton/SynchronizeDetail, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
觀察一下編譯後的程式碼,在testRoller()方法中有這樣一行描述flags: ACC_PUBLIC, ACC_SYNCHRONIZED,表示著當前方法的訪問許可權為SYNCHRONIZED的狀態,而這個標誌就是編譯後由JVM根據Synchronized加鎖的位置增加的鎖標識,也稱作類鎖,凡是要執行該方法的執行緒,都需要先獲取Monitor物件,直到鎖被釋放以後才允許其他執行緒持有Monitor物件。以HotSport虛擬機器為例Monitor的底層又是基於C++ 實現的ObjectMonitor,我不懂C++,通過查資(百)料(度)查到了這個ObjectMonitor的結構如下:
ObjectMonitor::ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; //執行緒重入次數
_object = NULL;
_owner = NULL; //標識擁有該monitor的執行緒
_WaitSet = NULL; //由等待執行緒組成的雙向迴圈連結串列
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; //多執行緒競爭鎖進入時的單向連結串列
FreeNext = NULL ;
_EntryList = NULL ; //處於等待鎖block狀態的執行緒的佇列,也是一個雙向連結串列
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
那麼接下來就用一張圖說明一下多執行緒併發情況下獲取testRoller()方法鎖的過程
上文中提到了MutexLock,而圖中加解鎖獲取Monitor物件就是基於它實現的互斥操作,再次強調,在加解鎖過程中執行緒會存在核心態與使用者態的切換,因此犧牲了一部分效能。
再來說一下testRunning()方法,很顯然,在編譯後的class中出現了一對monitorenter/monitorexit,其實就是物件監視器的另一種形態,本質上是一樣的,不過區別是,物件在鎖例項方法或者例項物件時稱作內建鎖。而上面的testRoller()是對類(物件的class)的許可權控制,兩者互不影響。
到這裡就解釋Synchronized的基本概念,接下來要說一說它到底跟物件在對空間的記憶體佈局有什麼關係。
Synchronized與物件堆空間佈局
還是以64位作業系統下HotSport版本的JVM為例,看一張全網都搜的到的圖
圖中展示了MarkWord佔用的64位在不同鎖狀態下記錄的資訊,主要有物件的HashCode、偏向鎖執行緒ID、GC年齡以及指向鎖的指標等,記住這裡的GC標誌記錄的位置,將來的JVM文章也會用到它,逃不掉的。在上篇例子中檢視記憶體佈局的基礎上稍微改動一下,程式碼如下:
/**
* FileName: JavaObjectMode
* Author: RollerRunning
* Date: 2020/12/01 20:12 PM
* Description:檢視加鎖物件在記憶體中的佈局
*/
public class JavaObjectMode {
public static void main(String[] args) {
//建立物件
Student student = new Student();
synchronized(student){
// 獲得加鎖後的物件佈局內容
String s = ClassLayout.parseInstance(student).toPrintable();
// 列印物件佈局
System.out.println(s);
}
}
}
class Student{
private String name;
private String address;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
}
第一張圖是上篇文章的也就是沒加鎖時物件的記憶體佈局,第二張圖是加鎖後的記憶體佈局,觀察一下VALUE的值
其實加鎖後,就是修改了物件頭中MarkWord的值用來記錄當前鎖狀態,所以可以看到加鎖前後VALUE發生了變化。
從第一張圖的第一行VALUE值可以看出當前的鎖標記為001(這裡面涉及到一個大端序和小端序的問題,可以自己學習一下:https://blog.csdn.net/limingliang_/article/details/80815393 ),對應的表中恰好是無鎖狀態,實際程式碼也是無鎖狀態。而圖二可以看出當前鎖標記為000(提示:在上圖001同樣的位置),對應表中狀態為輕量級鎖,那麼程式碼中的的Synchronized怎麼成了輕量級鎖了呢?因為在JDK1.6以後對鎖進行了優化,Synchronized會在競爭逐漸激烈的過程中慢慢升級為重量級互斥鎖。
但是還有問題,為啥加鎖了,上來就是輕量級鎖而不是偏向鎖呢,原因是在初始化鎖標記時JVM中預設延遲4s建立偏向鎖,由-XX:BiaseedLockingStartupDelay=xxx控制。一旦建立偏向鎖,在沒有執行緒使用當前偏向鎖時,叫做匿名偏向鎖,即上表中偏向執行緒ID的值為空,當有一個執行緒過來加鎖時,就進化成了偏向鎖。
到這裡,是不是已經能看明白天天說的鎖也不過是一堆標誌位實現的,讓我寫幾個if-else就給你寫出來了
Synchronized鎖升級過程
鎖的升級過程為:偏向鎖-->偏向鎖-->輕量級鎖-->重量級鎖。這個過程是隨著執行緒競爭的激烈程度而逐漸變化的。
偏向鎖
其中匿名偏向鎖前面已經說過了,偏向鎖的作用就是當同一執行緒多次訪問同步程式碼時,這一執行緒只需要獲取MarkWord中是否為偏向鎖,再判斷偏向的執行緒ID是不是自己,就是倆if-else搞定,Doug Lee先生不過如此嘛。如果發現偏向的執行緒ID是自己的執行緒ID就去執行程式碼,不是就要通過CAS來嘗試獲取鎖,一旦CAS獲取失敗,就要執行偏向鎖撤銷的操作。而這個過程在高併發的場景會程式碼很大的效能開銷,慎重使用偏向鎖。圖為偏向鎖的記憶體佈局
輕量級鎖
輕量級鎖是一種基於CAS操作的,適用於競爭不是很激烈的場景。輕量級鎖又分為自旋鎖和自適應自旋鎖。自旋鎖:因為輕量鎖是基於CAS理論實現的,因此當資源被佔用,其他執行緒搶鎖失敗時,會被掛起進入阻塞狀態,當資源就緒時,再次被喚醒,這樣頻繁的阻塞喚醒申請資源,十分低效,因此產生了自旋鎖。JDK1.6中,JVM可以設定-XX:+UseSpinning引數來開啟自旋鎖,使用-XX:PreBlockSpin來設定自旋鎖次數。不過到了JDK1.7及以後,取消自旋鎖引數,JVM不再支援由使用者配置自旋鎖,因此出現了自適應自旋鎖。自適應自旋鎖:JVM會根據前一執行緒持有自旋鎖的時間以及鎖的擁有者的狀態進行動態決策獲取鎖失敗執行緒的自旋次數,進而優化因為過多執行緒自旋導致的大量CAS狀態的執行緒佔用資源。下圖為輕量級鎖記憶體佈局:
隨著執行緒的增多,競爭更加激烈以後,CAS等待已經不能滿足需求,因此輕量級鎖又要向重量級鎖邁進了。在JDK1.6之前升級的關鍵條件是超過了自旋等待的次數。在JDK1.7後,由於引數不可控,JVM會自行決定升級的時機,其中有幾個比較重要的因素:單個執行緒持有鎖的時間、執行緒在使用者態與核心態之間切換的時間、掛起阻塞時間、喚醒時間、重新申請資源時間等
重量級鎖
而當升級為重量級鎖的時候,就沒啥好說的了,鎖標記位為10,所有執行緒都要排隊順序執行10標記的程式碼,前面提到的每一種鎖以及鎖升級的過程,其實都伴隨著MarkWord中鎖標記位的變化。相信看到這,大家應該都理解了不同時期的鎖對應著物件在堆空間中頭部不同的標誌資訊。重量級鎖的記憶體佈局我模擬了半天也沒出效果,有興趣的大佬可以講一下。
最後附上一張圖,展示一下鎖升級的過程,畫圖不易,還請觀眾老爺們關注啊:
鎖優化
1.動態編譯實現鎖消除
通過在編譯階段,使用編譯器對已加鎖程式碼進行逃逸性分析,判斷當前同步程式碼是否是隻能被一個執行緒訪問,未被髮布到其他執行緒(其他執行緒無權訪問)。當確認後,會在編譯器,放棄生成Synchronized關鍵字對應的位元組碼。
2.鎖粗化
在編譯階段,編譯器掃描到相鄰的兩個程式碼塊都使用了Synchronized關鍵字,則會將兩者合二為一,降低同一執行緒在進出兩個同步程式碼塊過程中帶來的效能損耗。
3.減小鎖粒度
這是開發層面需要做的事,即將鎖的範圍儘量明確並降低該範圍,不能簡單粗暴的加鎖。最佳實踐:在1.7及以前的ConcurrentHashMap中的分段鎖。不過已經不用了。
最後,感謝各位觀眾老爺,還請三連!!!
更多文章請掃碼關注或微信搜尋Java棧點公眾號!