Java鎖系列——3、JVM 對 Synchronized 鎖優化

概述

在上篇博客中,我們提到輕量級鎖、偏向鎖、重量級鎖等概念。在早期的 java 虛擬機中,synchronized 鎖基於 monitor 管程對象實現,而 monitor 對象又基於底層操作系統互斥量來保證同步。這就意味着,所有線程切換時需要從 用戶態 轉化爲 核心態,而線程的轉化過程比較緩慢,這也是早期 synchronized 鎖效率低下的主要原因。在 jdk6 之後,jvm 對 syncrhonized 鎖進行了一系列優化。本篇博客我們就來整理一下 jvm 都對 synchronized 鎖進行了哪些優化。


JVM 對 Synchronized 的優化

本篇博客分以下六個模塊展開:

  1. 偏向鎖
  2. 輕量級鎖
  3. 鎖轉化關係圖
  4. 自旋鎖
  5. 鎖消除
  6. 鎖粗化

1、偏向鎖

偏向鎖是 jdk6 新引入的一種鎖優化。其中它的“偏”就是指偏向某個線程。翻譯過來也就是說,當某個線程被鎖 偏向 後:在後序的執行過程中,如果沒有其他線程獲取該鎖,那麼鎖對象永遠偏向該線程,當該線程獲取偏向鎖時,無須進行任何同步操作。

也就是說,偏向鎖優化的原理就是消除數據在無競爭情況下的同步操作,進而提高效率

一般情況下,偏向鎖總是偏向第一次獲取鎖的線程。當鎖對象第一次被線程獲取後,虛擬機將該同步對象 Mark Word 區域的鎖標識設置爲“01”,即偏向模式,同時使用 CAS 將獲取鎖的線程 ID 保存到 Mark Word 之中。如果設置成功,後序該線程獲取鎖對象時,無須進行任何同步操作。

當另一個線程嘗試獲取鎖資源時,偏向模式宣告結束。

  • 如果當前鎖對象處於鎖定狀態,通過 CAS 將該鎖標識置位“00”,即升級爲“輕量級鎖”。

  • 如果當前鎖對象處於未鎖定狀態,則撤銷偏向,將偏向標識置位0,即非偏向鎖階段。

偏向鎖可以提高同步、但無競爭的程序性能。如果程序中鎖總是被不同的線程獲取,那偏向模式是多餘的,如果程序中的鎖總是被某一個線程獲取,那它可以大幅提高程序性能。


2、輕量級鎖

輕量級鎖是 JDK6 中引入的新型鎖機制。它的本意不是爲了替代重量級鎖,而是爲了在沒有多線程競爭的場景下,減少重量級鎖使用操作系統互斥量產生的性能損耗

和重量級鎖相同,輕量級鎖也基於 HotSpot 虛擬機對象頭的 Mark Word 模塊實現。之前提到鎖標識爲“10” 時表示當前鎖爲重量級鎖,而輕量級鎖的鎖標識爲“00”。

下面我簡單描述一下輕量級鎖的加鎖過程:

  1. 在代碼進入同步塊時,如果當前同步對象還沒有被 鎖定(鎖標誌爲“01”,即無鎖狀態),虛擬機首先在線程的棧幀中創建鎖記錄,用於存儲同步對象的 Mark Word 拷貝。

  2. 虛擬機嘗試通過 CAS 操作將同步對象的 Mark Word 更新指向上一步棧幀中的鎖記錄。如果更新成功,那麼該線程就擁有了同步對象的鎖,並將棧幀中的鎖標識修改爲“00”,標識此對象處於輕量級鎖狀態。

  3. 如果操作失敗,虛擬機首先檢查同步對象的 Mark Word 是否指向當前線程的鎖記錄。如果是,說明當前線程已經擁有了該對象鎖,繼續向下執行。否則說明鎖已經被其他線程搶佔了。如果有兩個線程爭搶同一個鎖,那輕量級鎖就不再生效,膨脹爲重量級鎖。

輕量級鎖提升程序性能的依據是:對於絕大多數鎖,在整個同步週期內是不存在競爭的,這是一個經驗數據。如果不存在競爭,輕量級鎖 CAS 操作避免了使用互斥量的開銷。但一旦存在競爭,除了互斥量的開銷外,還額外進行了 CAS 操作。因此在有競爭的情況下,輕量級鎖的性能不如重量級鎖


3、鎖轉化關係圖

有了偏向鎖、輕量級鎖,重量級鎖(上篇博客所有原理都是針對重量級鎖)的描述,我們以同步對象Mark Word 區域的變化爲基礎,描述各種鎖之間的轉化關係。具體如下圖所示:
Mark Word 轉換圖
通過上圖我們可以總結出以下幾點:

  • 偏向鎖在非偏向線程訪問,並此時處於非鎖定狀態時撤銷偏向,恢復爲未鎖定狀態
  • 偏向鎖在非偏向線程訪問,並此時鎖正處於鎖定狀態時升級爲輕量級鎖
  • 輕量級鎖在存在競爭時膨脹爲重量級鎖
  • synchronized 通常由偏向鎖開始,撤銷偏後升級到 輕量級鎖,輕量級鎖 膨脹爲 重量級鎖
  • 整個鎖升級的過程是不可逆的

我認爲(個人理解,不一定正確)偏向鎖和輕量級鎖其實差不多,都會在線程競爭時升級。主要區別有以下幾點:

  • 偏向鎖通過 CAS 記錄偏向線程,後序偏向線程訪問時省去同步操作、輕量級鎖通過 CAS 讓同步對象 Mark Word 指向棧幀中的鎖記錄,省去操作系統互斥量(也就是重量級鎖處理方式)所帶來的消耗。
  • 偏向鎖一勞永逸,只在第一次線程訪問時記錄,而輕量級鎖每次訪問都需要 CAS 操作嘗試修改通過對象 Mark Word 指向。

也就是說兩者可以提升效率的場景是類似的,都是同步且沒有競爭。只是輕量級鎖還支持線程間的交替執行,偏向鎖更像輕量級鎖的一種特殊情況。


4、自旋鎖

當多線程場景下出現多個線程同時獲取同一鎖資源時,synchronized 鎖從輕量級鎖膨脹爲重量級鎖。此時等待鎖的線程不會立即切換到核心態阻塞,而是先嚐試 “忙循環”(自旋) 一段時間,在自旋的過程中請求鎖資源。如果獲取到鎖,就繼續相下執行,否則切換到內核態阻塞。我們把這種通過“自旋”一段時間避免線程切換到內核態阻塞的處理方式稱爲自旋鎖

自旋鎖的背景:早期的 java 虛擬機開發者發現:絕大多數情況下線程佔用鎖的時間很短,爲了等待這段很短的時間讓 java 線程 切換到內核態掛起很不划算。如果物理機有一個以上的處理器,線程A 獲取到鎖在處理器a 上執行。線程B 獲取鎖失敗時,可以先在處理器b 上自旋一段時間等待鎖資源。如果此時線程A 釋放鎖資源,線程B 就可以獲取鎖向下執行,省去切換爲核心態線程掛起操作所帶的損耗。

自旋鎖的缺陷:自旋鎖從 jdk4 就已經引入,不過當時默認是關閉的,可以通過配置參數開啓,當時自旋鎖有以下缺陷:

  • 首先是處理器數量,自旋鎖在單處理器模式下會降低性能:因爲獲取到鎖的線程得不到執行,沒有獲取鎖的線程搶佔 CPU 資源執行自旋操作,這種處理方式反而會影響同步操作的效率。

  • 其次是自旋時間的設定:自旋時間過短時,效果不明顯,絕大多數線程還是會切換到核心態掛起等待喚醒。自旋時間過長時,因爲大量的線程執行自旋操作浪費CPU資源,當CPU自旋浪費過多時,可能還不如切換到內核態掛起等待喚醒,讓獲取到鎖的線程優先執行。

自旋鎖的優化:從 JDK6 開始,自旋鎖設爲默認開啓狀態並引入自適應模式。自適應模式意味着線程的自旋不再是固定的,而是根據前一個在同一個鎖上自旋時間及鎖的擁有者來決定:

  • 如果一個線程在一段時間自旋後成功獲取鎖資源,那麼我們就讓等待該鎖資源的線程自旋比稍微長一段時間,可能是100次循環,可能更多。虛擬機認爲此次資源會極有可能獲取到鎖資源

  • 如果對於某個鎖,自旋經常失敗,那麼我們認爲這個鎖很難通過自旋獲取到鎖資源,在以後獲取該鎖的過程中,虛擬機會嘗試省略掉自旋過程,直接切換到內核態掛起。

有了自適應的改造,自旋獲取到鎖資源的可能也越來越多,對整體性能的優化也越來越大。


5、鎖消除

鎖消除是指 java 虛擬機即時編譯期間,對一些代碼層面要求同步,但被檢測到不可能存在共享競爭的鎖進行消除

鎖消除的主要判斷依據源於數據是否會逃逸到其他線程被訪問:如果判斷在一段代碼內,堆上所有數據都不會逃逸出去被其他線程訪問,那麼我們可以將這部分數據視爲棧上數據,認爲他們是線程私有的,因此也不需要同步處理。

在實際開發過程中,用到鎖消除的場景特別多,下面我舉一個簡單的例子:

public String connectString(String s1, String s2, String s3) {
    return s1 + s2 + s3;
}

我們知道 java String 類是不可變類,對字符串的操作都會創建新的 String 對象來完成,因此編譯器在編譯上述代碼時會自動優化:在 JDK 1.5 之前該段代碼會被優化爲一個 StringBuffer 對象連續調用 append() 方法。在JDK 1.5之後的版本被優化爲 一個 StringBuilder 對象連續調用 append() 方法。也就是說上述方法中的代碼很有可能變成這樣:

public String connectString(String s1, String s2, String s3) {
    StringBuffer sb = new StringBuffer();
	sb.append(s1);
	sb.append(s2);
	sb.append(s3);
	return sb.toString();
}

而 StringBuffer 的 append() 方法默認是被同步修飾的。從這裏也就可以看出,絕大多數同步操作都是虛擬機在編譯期間自動生成的,實際應用中的同步處理比我們代碼中顯示編寫的同步處理多很多。

現在我們回到上述方法,append() 方法以調用對象爲鎖對象,也就是說上述 append() 方法每次調用都需要獲取對象 sb 的鎖對象。而 sb 對象從頭到尾都被限制在方法 connectString() 中,也就是說該對象不會逃逸出去被其他線程獲取,因此雖然這裏有鎖,也會在即時編譯階段被消除。


6、鎖粗化

原則上,我們在編寫代碼時,鎖的作用範圍越小越好,最好控制到只在共享數據可能出現線程安全問題的代碼處加鎖。這樣做的好處特別明顯:獲取到鎖的線程只需要執行較少的代碼就可以釋放鎖,等待鎖的線程可以儘快獲取鎖資源,提升整體效率。

上述原則在絕大多數情況下都是成立的。但如果出現連續多個小同步代碼塊,或者說甚至將同步操作寫在循環中時,頻繁的加解鎖操作又會給系統帶來額外的性能損耗。

就拿上述鎖消除中的代碼來說,線程執行完該方法需要頻繁的獲取釋放鎖。虛擬機在檢測到有這樣一串零碎的操作都需要獲取同一個鎖對象時,將會把加鎖同步的範圍擴大至整個同步區域外部。也就是說上述代碼只需要在第一次 append() 方法處加鎖,最後一次 append() 方法處解鎖即可。

我們把這種通過擴展鎖範圍,降低加解鎖次數的優化方式稱爲鎖粗化


參考
https://blog.csdn.net/javazejian/article/details/72828483
深入理解 Java 虛擬機
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章