深入理解 Synchronized 的鎖優化
我們都知道 synchronized 關鍵字能實現線程安全,但是你知道這背后的原理是什么嗎?今天我們就來講一講 synchronized 實現線程同步背后的原因,以及相關的鎖優化策略吧。
背后的原理
synchronized 關鍵字經過編譯之后,會在同步塊的前后分別形成 monitorenter 和 monitorexit 這兩個字節碼指令,這兩個字節碼只需要一個指明一個要鎖定或解鎖的對象。如果 Java 程序中指明了對象參數,那么就用這個對象作為鎖。
如果沒有指定,那么就根據 synchronized 修飾的是實例方法還是類方法,去拿對應的對象實例或 Class 對象來作為鎖對象。因此我們可以知道,synchronized 關鍵字實現線程同步的背后,其實是 Java 虛擬機規范對于 monitorenter 和 monitorexit 的定義。
在 Java 虛擬機規范對 monitorenter 和 monitorexit 的行為描述中,有兩點需要特別注意。
synchronized 同步塊對同一條線程是可沖入的,也就是不會出現自己把自己鎖死的問題。
同步課在已進入的線程執行完之前,會阻塞后面其他線程的進入。
synchronized 關鍵字在 JDK1.6 版本之前,是通過操作系統的 Mutex Lock 來實現同步的。而操作系統的 Mutex Lock 是操作系統級別的方法,需要切換到內核態來執行。這就需要從用戶態轉換到內核態中,因此我們說 synchronized 同步是重量級的操作。
鎖優化
在 JDK1.6 版本中,HotSpot 虛擬機開發團隊花了很大的精力去實現各種鎖優化技術,如:適應性自旋、鎖消除、鎖粗話、偏向鎖、輕量級鎖等。其中最重要的是:自旋鎖、輕量級鎖、偏向鎖這三個,我們重點講這三個鎖優化。
自旋鎖與自適應自旋
對于重量級的同步操作來說,最大的消耗其實是內核態與用戶態的切換。很很多時候,對于共享數據的操作時間可能很短,比內核態切換到用戶態這個耗時還短。
于是有人就想:如果有多個線程并發去獲取鎖的時候,如果能讓后面那個請求鎖的線程「稍等一下」,不放棄 CPU 的執行時間,看看持有鎖的線程是否會很快釋放鎖。為了讓線程等待,我們只需讓線程執行一個忙循環(自旋),這項技術就是所謂的自旋鎖。 從理論上來看,如果所有線程都很快地獲取鎖、釋放鎖,那么自旋鎖是可以帶來較大的性能提升的。自旋鎖在 JDK 1.4.2 中就已經引入,默認自旋 10 次。但自旋鎖默認是關閉的,在 JDK 1.6 中才改為默認開啟了。
自旋等待雖然避免了線程切換的開銷,但還是要占用處理器的時間。如果鎖被占用的時間段,自旋等待的效果就會非常好。但如果鎖被長時間占用,那么自旋的線程就會白白消耗處理器的資源,從而帶來性能上的浪費。
為了解決特殊情況下自旋鎖的性能消耗問題,在 JDK1.6 的時候引入了自適應的自旋鎖。 自適應意味著自旋時間不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者狀態決定。如果在同一鎖對象上,自旋等待剛剛成功獲得過鎖,那么虛擬機認為這次自旋也很有可能再次成功,進而允許線程自旋更長時間,例如自旋 100 個循環。
但如果對于某個鎖,自旋很少成功獲得過。那虛擬機為了避免浪費 CPU 資源,有可能省略掉自旋過程。有了自旋鎖,隨著程序運行和性能監控信息的不斷完善,虛擬機對鎖的狀態預測就越準,虛擬機也會變得越來越聰明。
輕量級鎖
輕量級鎖是 JDK1.6 加入的新型鎖機制,名字中的「輕量級」是相對于操作系統互斥量這個重量級鎖而言的。輕量級鎖誕生的原因,是由于對于絕大部分的鎖而言,整個同步周期都不存在競爭。如果沒有競爭的話,那就沒必要使用重量級鎖了,于是就誕生了輕量級鎖來提高效率。
對于輕量級鎖來說,其同步的流程如下:
在代碼進入同步塊的時候,如果此同步對象沒有被鎖定(鎖標志位為 01 狀態),那么虛擬機會在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的 Mark Word 拷貝。
虛擬機將使用 CAS 操作嘗試將對象的 Mark Word 更新為指向 Lock Record 的指針。如果更新動作成功了,那么線程就泳衣了該對象的鎖,并且對象 Mark Word 的鎖標志位就變成了 00,表示此對象處于輕量級鎖定狀態。
簡單地說,輕量級鎖的同步流程可以總結為:使用 CAS 操作,在線程棧幀與鎖對象建立雙向的指針。
在沒有線程競爭的情況下,輕量級鎖使用 CAS 自旋操作避免了使用互斥量的開銷,提高了效率。但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了 CAS 操作。因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。
偏向鎖
偏向鎖是 JDK1.6 中引入的一項優化,它的意思是這個鎖會偏向于第一個獲得它的線程。如果在接下來的執行過程中,該鎖沒有被其他線程獲取,則持有偏向鎖的線程將永遠不需要再進行同步。 對于偏向鎖來說,其同步流程如下所示:
- 假設當前虛擬機啟動了偏向鎖,那么當鎖對象第一次被線程獲取的時候,虛擬機將會把對象的鎖標志位設置為 01,偏向鎖位設置為 1。同時使用 CAS 操作將線程 ID 記錄在對象的 MarkWord 之中。如果 CAS 操作成功,那么持有偏向鎖的線程進入鎖對應的同步塊時,虛擬機將不再進行任何同步操作。
- 當有另外一個線程嘗試去獲取這個鎖時,根據鎖對象目前是否處于鎖定狀態,將其恢復到未鎖定(01)或輕量級鎖定(00)狀態。隨后的同步操作,就向上面介紹的輕量級鎖那樣執行。
可以看到偏向鎖還是需要做一些 CAS 操作,但是對比起輕量級鎖來說,其要設置的內容大大減少了,因此也提高了一些效率。
偏向鎖可以提高帶有同步但無競爭的程序性能。 它同樣是一個帶有效益權衡(Trade Off)性質的優化,也就是說,它并不一定總是對程序運行有利,如果程序中大多數的鎖總是被多個不同的線程訪問,那偏向模式就是多余的。
優化后的鎖獲取流程
經過 JDK1.6 的優化,synchronized 同步機制的流程變成了:
- 首先,synchronized 會嘗試使用偏向鎖的方式去競爭鎖資源,如果能夠競爭到偏向鎖,表示加鎖成功直接返回。
- 如果競爭鎖失敗,說明當前鎖已經偏向了其他線程。需要將鎖升級到輕量級鎖,在輕量級鎖狀態下,競爭鎖的線程根據自適應自旋次數去嘗試搶占鎖資源。
- 如果在輕量級鎖狀態下還是沒有競爭到鎖,就只能升級到重量級鎖。在重量級鎖狀態下,沒有競爭到鎖的線程就會被阻塞。處于鎖等待狀態的線程需要等待獲得鎖的線程來觸發喚醒。
上面的鎖獲取流程,可以用如下的示意圖來表示:
Java 對象鎖競爭流程
總結
本文首先簡單講解了 synchronized 關鍵字實現同步的原理,其實是通過 Java 虛擬機規范對于 monitorenter 和 monitorexit 的支持,從而使得 synchronized 能夠實現同步。而 synchronized 同步本質上是通過操作系統的 mutex 鎖來實現的。由于操作操作系統 mutex 鎖太過于消耗資源,因此在 JDK1.6 后 HotSpot 虛擬機做了一系列的鎖優化,其中最重要的便是:自旋鎖、輕量級鎖、偏向鎖。這三個鎖的誕生原因,以及提升的點如下表所示。
現狀 | 鎖名稱 | 收益 | 使用場景 |
大多數情況下,等待鎖的時間比操作系統 mutex 短得多 | 自旋鎖 | 減少內核態與用戶態切換的開銷 | 線程獲取鎖時間較短的情況 |
大多數情況下,鎖同步期間沒有線程競爭 | 輕量級鎖 | 與自旋鎖相比,減少了自旋時間 | 沒有線程競爭鎖 |
大多數情況下,鎖同步期間沒有線程競爭 | 偏向鎖 | 與輕量級鎖相比,減少了多余的對象復制操作 | 沒有線程競爭鎖 |
從上面表格可以看到,自旋鎖、輕量級鎖、偏向鎖,他們的優化是逐漸深入的。
- 對于重量級鎖來說,自旋鎖減少了互斥量的內核、用戶態切換開銷。
- 對于自旋鎖來說,輕量級鎖減少了自旋等待的時間。
- 對于輕量級鎖來說,偏向于減少了多余的對象復制操作。