Java多線程編程核心技術之同步鎖(1)

深入瞭解Synchronized同步鎖的優化方法

在併發編程中,多個線程訪問同一個共享資源時,我們必須考慮如何維護數據的原子性。在 JDK1.5 之前,Java 是依靠 Synchronized 關鍵字實現鎖功能來做到這點的。Synchronized 是 JVM 實現的一種內置鎖,鎖的獲取和釋放是由 JVM 隱式實現。

到了 JDK1.5 版本,併發包中新增了 Lock 接口來實現鎖功能,它提供了與 Synchronized 關鍵字類似的同步功能,只是在使用時需要顯示獲取和釋放鎖。

Lock 同步鎖是基於 Java 實現的,而 Synchronized 是基於底層操作系統的 Mutex Lock 實現的,每次獲取和釋放鎖操作都會帶來用戶態和內核態的切換,從而增加系統性能開銷。因此,在鎖競爭激烈的情況下,Synchronized 同步鎖在性能上就表現得非常糟糕,它也常被大家稱爲重量級鎖。

特別是在單個線程重複申請鎖的情況下,JDK1.5 版本的 Synchronized 鎖性能要比 Lock 的性能差很多。例如,在 Dubbo 基於 Netty 實現的通信中,消費端向服務端通信之後,由於接收返回消息是異步,所以需要一個線程輪詢監聽返回信息。而在接收消息時,就需要用到鎖來確保 request session 的原子性。如果我們這裏使用 Synchronized 同步鎖,那麼每當同一個線程請求鎖資源時,都會發生一次用戶態和內核態的切換。

到了 JDK1.6 版本之後,Java 對 Synchronized 同步鎖做了充分的優化,甚至在某些場景下,它的性能已經超越了 Lock 同步鎖。這一講我們就來看看 Synchronized 同步鎖究竟是通過了哪些優化,實現了性能地提升。

Synchronized 同步鎖實現原理

瞭解 Synchronized 同步鎖優化之前,我們先來看看它的底層實現原理,這樣可以幫助我們更好地理解後面的內容。

通常 Synchronized 實現同步鎖的方式有兩種,一種是修飾方法,一種是修飾方法塊。以下就是通過 Synchronized 實現的兩種同步方法加鎖的方式:

// 關鍵字在實例方法上,鎖爲當前實例
public synchronized void method1() {
// code
}

// 關鍵字在代碼塊上,鎖爲括號裏面的對象
public void method2() {
Object o = new Object();
synchronized (o) {
// code
}
}

下面我們可以通過反編譯看下具體字節碼的實現,運行以下反編譯命令,就可以輸出我們想要的字節碼:

javac -encoding UTF-8 SyncTest.java //先運行編譯class文件命令

javap -v SyncTest.class //再通過javap打印出字節文件

通過輸出的字節碼,你會發現:Synchronized 在修飾同步代碼塊時,是由 monitorenter 和 monitorexit 指令來實現同步的。進入 monitorenter 指令後,線程將持有 Monitor 對象,退出 monitorenter 指令後,線程將釋放該 Monitor 對象。

public void method2();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: new #2
3: dup
4: invokespecial #1
7: astore_1
8: aload_1
9: dup
10: astore_2
11: monitorenter //monitorenter 指令
12: aload_2
13: monitorexit //monitorexit 指令
14: goto 22
17: astore_3
18: aload_2
19: monitorexit
20: aload_3
21: athrow
22: return
Exception table:
from to target type
12 14 17 any
17 20 17 any
LineNumberTable:
line 18: 0
line 19: 8
line 21: 12
line 22: 22
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame /
offset_delta = 17
locals = [ class com/demo/io/SyncTest, class java/lang/Object, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /
chop */
offset_delta = 4

再來看以下同步方法的字節碼,你會發現:當 Synchronized 修飾同步方法時,並沒有發現 monitorenter 和 monitorexit 指令,而是出現了一個 ACC_SYNCHRONIZED 標誌。

這是因爲 JVM 使用了 ACC_SYNCHRONIZED 訪問標誌來區分一個方法是否是同步方法。當方法調用時,調用指令將會檢查該方法是否被設置 ACC_SYNCHRONIZED 訪問標誌。如果設置了該標誌,執行線程將先持有 Monitor 對象,然後再執行方法。在該方法運行期間,其它線程將無法獲取到該 Mointor 對象,當方法執行完成後,再釋放該 Monitor 對象。

public synchronized void method1();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED // ACC_SYNCHRONIZED 標誌
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 8: 0

通過以上的源碼,我們再來看看 Synchronized 修飾方法是怎麼實現鎖原理的。

JVM 中的同步是基於進入和退出管程(Monitor)對象實現的。每個對象實例都會有一個 Monitor,Monitor 可以和對象一起創建、銷燬。Monitor 是由 ObjectMonitor 實現,而 ObjectMonitor 是由 C++ 的 ObjectMonitor.hpp 文件實現,如下所示:

ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個數
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //處於wait狀態的線程,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處於等待鎖block狀態的線程,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}

當多個線程同時訪問一段同步代碼時,多個線程會先被存放在 ContentionList 和 _EntryList 集合中,處於 block 狀態的線程,都會被加入到該列表。接下來當線程獲取到對象的 Monitor 時,Monitor 是依靠底層操作系統的 Mutex Lock 來實現互斥的,線程申請 Mutex 成功,則持有該 Mutex,其它線程將無法獲取到該 Mutex,競爭失敗的線程會再次進入 ContentionList 被掛起。

如果線程調用 wait() 方法,就會釋放當前持有的 Mutex,並且該線程會進入 WaitSet 集合中,等待下一次被喚醒。如果當前線程順利執行完方法,也將釋放 Mutex。

看完上面的講解,相信你對同步鎖的實現原理已經有個深入的瞭解了。總結來說就是,同步鎖在這種實現方式中,因 Monitor 是依賴於底層的操作系統實現,存在用戶態與內核態之間的切換,所以增加了性能開銷。

鎖升級優化

爲了提升性能,JDK1.6 引入了偏向鎖、輕量級鎖、重量級鎖概念,來減少鎖競爭帶來的上下文切換,而正是新增的 Java 對象頭實現了鎖升級功能。

當 Java 對象被 Synchronized 關鍵字修飾成爲同步鎖後,圍繞這個鎖的一系列升級操作都將和 Java 對象頭有關。

Java 對象頭

在 JDK1.6 JVM 中,對象實例在堆內存中被分爲了三個部分:對象頭、實例數據和對齊填充。其中 Java 對象頭由 Mark Word、指向類的指針以及數組長度三部分組成。Mark Word 記錄了對象和鎖有關的信息。

Mark Word 在 64 位 JVM 中的長度是 64bit,我們可以一起看下 64 位 JVM 的存儲結構是怎麼樣的。如下圖所示:

鎖升級功能主要依賴於 Mark Word 中的鎖標誌位和釋放偏向鎖標誌位,Synchronized 同步鎖就是從偏向鎖開始的,隨着競爭越來越激烈,偏向鎖升級到輕量級鎖,最終升級到重量級鎖。下面我們就沿着這條優化路徑去看下具體的內容。

  1. 偏向鎖

偏向鎖主要用來優化同一線程多次申請同一個鎖的競爭。在某些情況下,大部分時間是同一個線程競爭鎖資源,例如,在創建一個線程並在線程中執行循環監聽的場景下,或單線程操作一個線程安全集合時,同一線程每次都需要獲取和釋放鎖,每次操作都會發生用戶態與內核態的切換。

偏向鎖的作用就是,當一個線程再次訪問這個同步代碼或方法時,該線程只需去對象頭的 Mark Word 中去判斷一下是否有偏向鎖指向它的 ID,無需再進入 Monitor 去競爭對象了。當對象被當做同步鎖並有一個線程搶到了鎖時,鎖標誌位還是 01,“是否偏向鎖”標誌位設置爲 1,並且記錄搶到鎖的線程 ID,表示進入偏向鎖狀態。

一旦出現其它線程競爭鎖資源時,偏向鎖就會被撤銷。偏向鎖的撤銷需要等待全局安全點,暫停持有該鎖的線程,同時檢查該線程是否還在執行該方法,如果是,則升級鎖,反之則被其它線程搶佔。

下圖中紅線流程部分爲偏向鎖獲取和撤銷流程:

因此,在高併發場景下,當大量線程同時競爭同一個鎖資源時,偏向鎖就會被撤銷,發生 stop the word 後,開啓偏向鎖無疑會帶來更大的性能開銷,這時我們可以通過添加 JVM 參數關閉偏向鎖來調優系統性能,示例代碼如下:

-XX:-UseBiasedLocking //關閉偏向鎖(默認打開)

-XX:+UseHeavyMonitors //設置重量級鎖

  1. 輕量級鎖

當有另外一個線程競爭獲取這個鎖時,由於該鎖已經是偏向鎖,當發現對象頭 Mark Word 中的線程 ID 不是自己的線程 ID,就會進行 CAS 操作獲取鎖,如果獲取成功,直接替換 Mark Word 中的線程 ID 爲自己的 ID,該鎖會保持偏向鎖狀態;如果獲取鎖失敗,代表當前鎖有一定的競爭,偏向鎖將升級爲輕量級鎖。

輕量級鎖適用於線程交替執行同步塊的場景,絕大部分的鎖在整個同步週期內都不存在長時間的競爭。

下圖中紅線流程部分爲升級輕量級鎖及操作流程:

  1. 自旋鎖與重量級鎖

輕量級鎖 CAS 搶鎖失敗,線程將會被掛起進入阻塞狀態。如果正在持有鎖的線程在很短的時間內釋放資源,那麼進入阻塞狀態的線程無疑又要申請鎖資源。

JVM 提供了一種自旋鎖,可以通過自旋方式不斷嘗試獲取鎖,從而避免線程被掛起阻塞。這是基於大多數情況下,線程持有鎖的時間都不會太長,畢竟線程被掛起阻塞可能會得不償失。

從 JDK1.7 開始,自旋鎖默認啓用,自旋次數由 JVM 設置決定,這裏我不建議設置的重試次數過多,因爲 CAS 重試操作意味着長時間地佔用 CPU。

自旋鎖重試之後如果搶鎖依然失敗,同步鎖就會升級至重量級鎖,鎖標誌位改爲 10。在這個狀態下,未搶到鎖的線程都會進入 Monitor,之後會被阻塞在 _WaitSet 隊列中。

下圖中紅線流程部分爲自旋後升級爲重量級鎖的流程:

在鎖競爭不激烈且鎖佔用時間非常短的場景下,自旋鎖可以提高系統性能。一旦鎖競爭激烈或鎖佔用的時間過長,自旋鎖將會導致大量的線程一直處於 CAS 重試狀態,佔用 CPU 資源,反而會增加系統性能開銷。所以自旋鎖和重量級鎖的使用都要結合實際場景。

在高負載、高併發的場景下,我們可以通過設置 JVM 參數來關閉自旋鎖,優化系統性能,示例代碼如下:

-XX:-UseSpinning //參數關閉自旋鎖優化(默認打開)
-XX:PreBlockSpin //參數修改默認的自旋次數。JDK1.7後,去掉此參數,由jvm控制

動態編譯實現鎖消除 / 鎖粗化

除了鎖升級優化,Java 還使用了編譯器對鎖進行優化。JIT 編譯器在動態編譯同步塊的時候,藉助了一種被稱爲逃逸分析的技術,來判斷同步塊使用的鎖對象是否只能夠被一個線程訪問,而沒有被髮布到其它線程。

確認是的話,那麼 JIT 編譯器在編譯這個同步塊的時候不會生成 synchronized 所表示的鎖的申請與釋放的機器碼,即消除了鎖的使用。在 Java7 之後的版本就不需要手動配置了,該操作可以自動實現。

鎖粗化同理,就是在 JIT 編譯器動態編譯時,如果發現幾個相鄰的同步塊使用的是同一個鎖實例,那麼 JIT 編譯器將會把這幾個同步塊合併爲一個大的同步塊,從而避免一個線程“反覆申請、釋放同一個鎖“所帶來的性能開銷。

減小鎖粒度
除了鎖內部優化和編譯器優化之外,我們還可以通過代碼層來實現鎖優化,減小鎖粒度就是一種慣用的方法。

當我們的鎖對象是一個數組或隊列時,集中競爭一個對象的話會非常激烈,鎖也會升級爲重量級鎖。我們可以考慮將一個數組和隊列對象拆成多個小對象,來降低鎖競爭,提升並行度。

最經典的減小鎖粒度的案例就是 JDK1.8 之前實現的 ConcurrentHashMap 版本。我們知道,HashTable 是基於一個數組 + 鏈表實現的,所以在併發讀寫操作集合時,存在激烈的鎖資源競爭,也因此性能會存在瓶頸。而 ConcurrentHashMap 就很很巧妙地使用了分段鎖 Segment 來降低鎖資源競爭,如下圖所示:

總結

JVM 在 JDK1.6 中引入了分級鎖機制來優化 Synchronized,當一個線程獲取鎖時,首先對象鎖將成爲一個偏向鎖,這樣做是爲了優化同一線程重複獲取導致的用戶態與內核態的切換問題;其次如果有多個線程競爭鎖資源,鎖將會升級爲輕量級鎖,它適用於在短時間內持有鎖,且分鎖有交替切換的場景;輕量級鎖還使用了自旋鎖來避免線程用戶態與內核態的頻繁切換,大大地提高了系統性能;但如果鎖競爭太激烈了,那麼同步鎖將會升級爲重量級鎖。

減少鎖競爭,是優化 Synchronized 同步鎖的關鍵。我們應該儘量使 Synchronized 同步鎖處於輕量級鎖或偏向鎖,這樣才能提高 Synchronized 同步鎖的性能;通過減小鎖粒度來降低鎖競爭也是一種最常用的優化方法;另外我們還可以通過減少鎖的持有時間來提高 Synchronized 同步鎖在自旋時獲取鎖資源的成功率,避免 Synchronized 同步鎖升級爲重量級鎖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章