1. 程式人生 > 程式設計 >你必須知道的Synchronized (中篇:鎖升級)

你必須知道的Synchronized (中篇:鎖升級)

在上篇我們聊了sync的基本使用區別和實現原理,本篇繼續來聊sync的鎖升級過程,JDK1.6之後,JVM對sync關鍵字做了相當複雜的優化,當然目的就是為了提升sync的效能

本篇測試環境:
JDK版本 :java version "1.8.0_221"
JDK模式 :Java HotSpot(TM) 64-Bit Server VM (build 25.221-b11,mixed mode)
作業系統:Windows 10 企業版  x64 筆記本
記憶體容量:8G DDR3
CPU型號 :Intel i7-5500U 2.4GHz
JVM引數 :-Xmx512m -Xms512m -XX:+UseParallelGC複製程式碼

物件頭

在聊sync的升級過程之前,首先我們必須要了解一個東西,物件頭,在JVM的實現中每一個物件都會有一個物件頭,它是用於儲存物件的系統資訊,物件頭中有一個官方稱為 MarkDown 的部分,他就是實現各種鎖的關鍵。在32位系統裡,MarkDown 是32位的資料,64位系統中是64位的資料,他存放了物件的雜湊值、物件年齡、鎖的指標等等資訊。簡言之,一個物件的鎖是否被佔用、佔用的是哪種鎖,就記錄在MarkDown中。

偏向鎖

偏向鎖的核心思想

如果系統中不存線上程競爭情況,就會取消已經持有鎖的執行緒同步操作;簡言之,如果有一個執行緒 t1,它獲取鎖之後會首先進入偏向鎖模式,如果當 t1 再次請求持有這把鎖的時候,則不需要再進行獲取鎖的操作,這樣就節省了申請鎖的操作,從而提升了效能。在處於偏向鎖的期間,如果有其他執行緒來獲取鎖,則 t1 會退出偏向鎖模式。

偏向鎖的表示

當鎖物件處於偏向鎖模式時,物件頭會儲存的資訊:

【持有偏向鎖的執行緒 | 偏向鎖的時間戳 | 物件年齡 | 固定為1,表示偏向鎖 | 最後兩位為01,表示可偏向/未鎖定】
【thread_id | epoch | age | 1 | 01】 當 t1 再次嘗試獲取鎖的時候,JVM通過以上資訊就可以直接判斷當前執行緒是否持有偏向鎖了。

偏向鎖的效能測試

那麼,JVM對偏向鎖的效能優化效果到底如何呢?我們接下來就來上測試程式碼看一下實際效果

private static List<Integer> list = new Vector<>();
public
static void main(String[] args)
{ long start = System.currentTimeMillis(); for (int i = 0; i < 10000000; i++) { list.add(i + 2); } long end = System.currentTimeMillis(); System.out.println(end - start); }複製程式碼

為了測試結果的準確性,我們需要額外配置兩個引數: -XX:+UseBiasedLocking 表示設定開啟偏向鎖 -XX:BiasedLockingStartupDelay=0 表示JVM在啟動後立即啟動偏向鎖,如果不設定,JVM預設會在啟動後4秒才會啟動偏向鎖 上面的程式碼我們使用一個Vector進行寫入操作,並做好初始化準備,眾所周知Vector內部的訪問操作是使用的同步鎖也就是sync控制,每次執行add方法都會請求list物件的鎖,然後我連續執行十次,最後輸出數值大概在 440-470左右,而關閉偏向鎖(-XX:-UseBiasedLocking)之後,同樣的程式碼也運行了十次,最後輸出的數值大概在670-690左右,去掉其他因素,估算效能差距在百分之20左右。

偏向鎖的問題

偏向鎖是為了在資源沒有被多執行緒競爭的情況下儘量的減少鎖帶來的效能開銷,但是請不要忽略一個問題,就是當我們的應用程式內部的執行緒競爭比較激烈的時候,大量的執行緒會不斷的來請求鎖,也就會導致鎖很難持續的保持在偏向鎖模式,這個時候使用偏向鎖不僅不會提升效能,反而可能會降低系統效能,所以,如果我們系統內執行緒競爭比較激烈時,不如直接關閉偏向鎖 :-XX:-UseBiasedLocking

輕量級鎖

如果偏向鎖失敗(在偏向鎖的時候有其他執行緒爭用)了,JVM並不會立刻掛起執行緒,而是會發生偏向鎖的撤銷操作,此時物件可能會處於兩種狀態, 一種是不可偏向的無鎖狀態,一種是不可偏向的已鎖(輕量級)狀態,當執行緒如果持有輕量級鎖時,那麼此時物件的 MarkDown 是這樣的:[prt | 00] locked 它的後兩位為00,在這裡它只是簡單的把物件頭部作為一個指標指向持有該鎖的執行緒棧空間的內部,當需要判斷一個執行緒是否持有該物件的輕量級鎖的時候,只需要檢查物件頭的指標是否在當前執行緒的棧地址範圍內即可。 實際上輕量級鎖在JVM的內部使用的是一個BasicObjectLock的物件來實現的,物件內部包含一個BasicLock和一個持有該鎖的物件指標,在JVM實現中,BasicLock會首先複製原物件的 MarkDown,然後使用CAS原子操作來把BasicLock的地址複製到物件頭的MarkDown,如果複製成功,表示加鎖成功,否則加鎖失敗,如果失敗了,那麼輕量級鎖就有可能會進行鎖膨脹!

自旋鎖

在進行鎖膨脹之後,這個時候執行緒很有可能是會直接在作業系統層面進行掛起操作,也就意味著會發生使用者態到核心態的一個切換過程,這時候的效能損失是比較大的!所以,在鎖膨脹之後,JVM還會做最後的努力來避免執行緒被掛起,也就是使用自旋鎖。

自旋的意思在這裡是說,如果當前執行緒沒有取得鎖,不會被掛起,而是去執行一個空迴圈(自旋),在N次迴圈後,當前執行緒會再次請求獲取鎖,如果獲取成功,則正常執行,如果依然不能獲取,才會被掛起。

自旋鎖的問題

自旋鎖在不同場景下會有不同的效能消耗,比如,對於鎖競爭不是很激烈,鎖佔用時間比價短的場景下,自旋鎖能夠有效的避免作業系統掛起鎖的次數,也就是減少了使用者態到核心態的切換次數;但是反之,如果鎖競爭比較激烈或者鎖佔用時間比較長,而且執行緒數比較多的場景下,一個執行緒獲取鎖之後,其他的N個執行緒都來競爭鎖,意味著大量的執行緒都會進行自旋,而且在一定時間內都不能獲取鎖之後,依舊被作業系統掛起,反而是更加的浪費了CPU的時間和資源(大量執行緒空旋)

自旋鎖的設定

在JDK6,JVM是提供了引數用來開啟自旋鎖的:-XX:+UseSpinning,可以搭配:-XX:PreBlockSpin引數來設定自旋鎖的自旋次數(預設自旋10次)

但是在JDK7以上的版本,JVM是廢棄了這兩個引數的,JVM預設開啟了自旋鎖,以及會自動的調整自旋次數

鎖消除

鎖消除是一種更直接的鎖優化的技術,是由JVM在JIT編譯時產生的一種優化方式,JVM在對程式的執行上下文環境做掃描,直接去除不可能存在競爭共享資源的鎖,可以節省不必要的加鎖操作

比如以下程式碼:

private static String t(String s1,String s2){
    StringBuffer buffer = new StringBuffer();
    buffer.append(s1);
    buffer.append(s2);
    return buffer.toString();
}複製程式碼

我們都知道 StringBuffer可以說是StringBuilder的加鎖版本,是執行緒安全的,但是在上述程式碼中,變數buffer的作用域僅限於方法體內部,不可能有逃逸到方法外,明顯不需要使用執行緒安全的方式來做字串的拼接,所以鎖消除可以對此類程式碼進行優化

新增如下啟動引數:

-server					// 鎖消除必須在Server模式下
-Xcomp					// 使用編譯模式
-XX:+DoEscapeAnalysis 			// 開啟逃逸分析
-XX:+EliminateLocks 			// 開啟鎖消除
-XX:BiasedLockingStartupDelay=0         // JVM啟動立刻開啟偏向鎖
-XX:+UseBiasedLocking			// 開啟偏向鎖複製程式碼

與鎖消除相關的一個配置是逃逸分析,這裡我先簡單的講解逃逸分析,大概意思就是說看某個變數是否逃出了某個作用域,在上面例子中,變數buffer是沒有逃出方法t的函式作用域的,也叫做沒有發生逃逸,這種情況下,JVM才能對變數buffer進行內部鎖消除優化,反之,如果方法t如下:

private static StringBuffer t(String s1,String s2){
    StringBuffer buffer = new StringBuffer();
    buffer.append(s1);
    buffer.append(s2);
    return buffer;
}複製程式碼

上述方法中變數buffer則發生了逃逸,由方法t內部逃逸到了外部,那麼JVM就不能消除變數buffer的鎖操作

理論上開啟鎖消除之後的效能應該會有提升,但是我使用以上配置實際測試時,開啟和關閉鎖消除的結果居然並沒有明顯差距,所以這裡就不貼測試結果了,如果有興趣的朋友可以自行測試,或者有知道原因的朋友可以留言一起討論