synchronize早已經沒那麽笨重
汙點
,我打算幫它清洗下。
JVM鎖優化
其實jdk1.6對鎖的實現已經引入了大量的優化,如自旋鎖、適應性自旋鎖、鎖消除、鎖粗化、偏向鎖、輕量級鎖等技術來減少鎖操作的開銷。
鎖主要存在四中狀態,依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態、重量級鎖狀態,他們會隨著競爭的激烈而逐漸升級。註意鎖可以升級不可降級,這種策略是為了提高獲得鎖和釋放鎖的效率。
重量級鎖
重量級鎖,是JDK1.6之前,內置鎖的實現方式。簡單來說,重量級鎖就是采用互斥量來控制對互斥資源的訪問。
歷史回顧:在JDK1.6以前的版本,synchronized實現的內置鎖都比較重(這也是諸多同學們理解的版本)。JVM中monitorenter
和monitorexit
字節碼依賴於底層的操作系統的Mutex Lock
來實現的,但是由於使用Mutex Lock
需要將當前線程掛起並從用戶態切換到內核態來執行,這種切換的代價是非常昂貴的。然而在現實中的大部分情況下,同步方法是運行在單線程環境(無鎖競爭環境)如果每次都調用Mutex Lock那麽將嚴重的影響程序的性能。
自旋鎖
線程的阻塞和喚醒需要CPU從用戶態轉為核心態,頻繁的阻塞和喚醒對CPU來說是一件負擔很重的工作,勢必會給系統的並發性能帶來很大的壓力。同時我們發現在許多應用上面,對象鎖的鎖狀態只會持續很短一段時間,為了這一段很短的時間頻繁地阻塞和喚醒線程是非常不值得的。所以引入自旋鎖。
何謂自旋鎖?
所謂自旋鎖,就是讓該線程等待一段時間,不會被立即掛起,看持有鎖的線程是否會很快釋放鎖。怎麽等待呢?執行一段無意義的循環
即可(自旋)。
自旋等待不能替代阻塞,先不說對處理器數量的要求(多核,貌似現在沒有單核的處理器了),雖然它可以避免線程切換帶來的開銷,但是它占用了處理器的時間。如果持有鎖的線程很快就釋放了鎖,那麽自旋的效率就非常好,反之,自旋的線程就會白白消耗掉處理的資源,它不會做任何有意義的工作,典型的占著茅坑不拉屎,這樣反而會帶來性能上的浪費。所以說,自旋等待的時間(自旋的次數)必須要有一個限度,如果自旋超過了定義的時間仍然沒有獲取到鎖,則應該被掛起。
自旋鎖在JDK 1.4.2中引入,默認關閉,但是可以使用-XX:+UseSpinning
-XX:PreBlockSpin
來調整;
如果通過參數-XX:preBlockSpin
來調整自旋鎖的自旋次數,會帶來諸多不便。假如我將參數調整為10,但是系統很多線程都是等你剛剛退出的時候就釋放了鎖(假如你多自旋一兩次就可以獲取鎖),你是不是很尷尬。於是JDK1.6引入自適應的自旋鎖,讓虛擬機會變得越來越聰明。
適應自旋鎖
JDK 1.6引入了更加聰明的自旋鎖,即自適應自旋鎖。所謂自適應就意味著自旋的次數不再是固定的,它是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。它怎麽做呢?線程如果自旋成功了,那麽下次自旋的次數會更加多,因為虛擬機認為既然上次成功了,那麽此次自旋也很有可能會再次成功,那麽它就會允許自旋等待持續的次數更多。反之,如果對於某個鎖,很少有自旋能夠成功的,那麽在以後要或者這個鎖的時候自旋的次數會減少甚至省略掉自旋過程,以免浪費處理器資源。
有了自適應自旋鎖,隨著程序運行和性能監控信息的不斷完善,虛擬機對程序鎖的狀況預測會越來越準確,虛擬機會變得越來越聰明。
鎖消除
為了保證數據的完整性,我們在進行操作時需要對這部分操作進行同步控制,但是在有些情況下,JVM檢測到不可能存在共享數據競爭,這是JVM會對這些同步鎖進行鎖消除。鎖消除的依據是逃逸分析的數據支持。
如果不存在競爭,為什麽還需要加鎖呢?所以鎖消除可以節省毫無意義的請求鎖的時間。變量是否逃逸,對於虛擬機來說需要使用數據流分析來確定,但是對於我們程序員來說這還不清楚麽?我們會在明明知道不存在數據競爭的代碼塊前加上同步嗎?但是有時候程序並不是我們所想的那樣?我們雖然沒有顯示使用鎖,但是我們在使用一些JDK的內置API時,如StringBuffer、Vector、HashTable等,這個時候會存在隱形的加鎖操作。比如StringBuffer的append()方法,Vector的add()方法:
在運行這段代碼時,JVM可以明顯檢測到變量vector沒有逃逸出方法vectorTest()之外,所以JVM可以大膽地將vector內部的加鎖操作消除。
鎖粗化
我們知道在使用同步鎖的時候,需要讓同步塊的作用範圍盡可能小—僅在共享數據的實際作用域中才進行同步,這樣做的目的是為了使需要同步的操作數量盡可能縮小,如果存在鎖競爭,那麽等待鎖的線程也能盡快拿到鎖。
在大多數的情況下,上述觀點是正確的,LZ也一直堅持著這個觀點。但是如果一系列的連續加鎖解鎖操作,可能會導致不必要的性能損耗,所以引入鎖粗話的概念。
鎖粗話概念比較好理解,就是將多個連續的加鎖、解鎖操作連接在一起,擴展成一個範圍更大的鎖。如上面實例:vector每次add的時候都需要加鎖操作,JVM檢測到對同一個對象(vector)連續加鎖、解鎖操作,會合並一個更大範圍的加鎖、解鎖操作,即加鎖解鎖操作會移到for循環之外。
偏向鎖
既然采用了內置鎖,只要訪問了同步代碼,都會涉及獲取鎖和釋放鎖的動作。而這種動作都是存在開銷的。無論是重量級鎖去取得互斥信號量,還是輕量級鎖去compare,都會有開銷。然後很多時候,被內置鎖約束的同步代碼段往往只有一個線程去獲取“鎖”,根本不存在並發訪問。那麽這時候頻繁地加鎖和解鎖就會有額外的開銷。因此偏向鎖也應運而生。
在采用偏向鎖時,如果一個線程第一次來訪問互斥資源,則在對象頭和棧幀的鎖記錄中存儲偏向鎖的線程ID(可以理解為獲取“鎖”的動作)。偏向鎖在獲取鎖之後,直到有競爭出現才會釋放鎖。也就是說,如果長期沒有競爭,偏向鎖是一直持有鎖的。這樣,當線程下次再次進入同步塊的時候不需要進行任何獲取鎖的操作,即可訪問互斥資源。節約了頻繁獲取鎖和釋放鎖的開銷。
輕量級鎖
輕量級鎖,顧名思義,相比重量級鎖,其加鎖和解鎖的開銷會小很多。重量級鎖之所以開銷大,關鍵是其存在線程上下文切換的開銷。而輕量級鎖通過JAVA中CAS的實現方式,避免了這種上下文切換的開銷。當compare失敗的時候(理解成沒有拿到”鎖”),線程不會被掛起;當compare成功的時候,可以直接對互斥資源進行修改(就好像拿到了“鎖一樣”)。重量級鎖使用互斥信號量實現,如果沒有拿到互斥信號量(理解成沒有拿到“鎖”),線程會被掛起;如果拿到互斥信號量則可以直接對互斥資源進行訪問。
從以上分析可知,其實是否拿到“鎖”對於不同的鎖實現方式有著不同的含義。 重量級鎖基於互斥信號量實現,則認為拿到互斥信號量即為拿到鎖。而CAS操作則通過compare是否成功來判斷是否拿到“鎖”。 這裏的“鎖”都不是特指某一具體事物,而是一種“條件”,拿到了“鎖”,即意味著滿足了“條件”,可以對互斥資源進行訪問。當然本質上,無論哪種實現方式,拿到鎖之後都會去修改Mark Word,來記錄自己確實拿到了鎖;釋放鎖則會清空Mark word中自己的線程ID。
輕量級鎖和重量級鎖的重要區別是: 拿不到“鎖”時,是否有線程調度和上下文切換的開銷。
輕量級鎖加鎖:
線程在執行同步塊之前,JVM會先在當前線程的棧楨中創建用於存儲鎖記錄的空間(Lock
Record),並將對象頭中的Mark Word復制到鎖記錄中,官方稱為Displaced Mark
Word。然後線程嘗試使用CAS將對象頭中的Mark
Word替換為指向鎖記錄的指針。如果成功,當前線程獲得鎖。如果這個更新操作失敗了,虛擬機首先會檢查
對象的Mark Word是否指向當前線程的棧幀。如果指向,說明當前線程已經擁有了這個對象的鎖,那就可以直
接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程搶占了。如果有兩條以上的線程爭用同一個鎖
,那輕量級鎖就不再有效,要膨脹為重量級鎖,Mark Word中存儲的就是指向重量級(互斥量)的指針
復制代碼
輕量級鎖解鎖:
輕量級解鎖時,會使用原子的CAS操作來將Displaced Mark
Word替換回到對象頭,如果成功,則整個同步過程就完成了。如果替換失敗,說明有其他線程嘗試過獲取該
鎖,那麽其他線程就要在釋放鎖的同時,喚醒被掛起的線程。
復制代碼
關於輕量級鎖的加鎖和解鎖過程簡單來說就是:
- 嘗試CAS修改mark word:如果這步能直接成功,則代價較小,可以直接獲取鎖
- 獲取鎖失敗則采用自旋鎖來獲取鎖(CAS修改嘗試失敗後采取的策略)
- 自旋鎖嘗試失敗,鎖膨脹,成為重量級鎖:自旋鎖也嘗試失敗,不得不使用重量級鎖,線程也被阻塞。
總結
所以synchronize並有沒像之前想象的那麽笨重,其實大家可以在大量的源碼中都能看到它的身影,包括juc包下的工具類等等,總之存在必有合理之處,望大家善用它。(當然前提必須理解它)
PS:一個好消息
同學,你造嗎?阿裏雲和騰訊雲已白菜價,雲服務器低至不到300元/年。這裏有一份雲計算優惠活動列表,來不及解釋了,趕緊上車!
轉自https://juejin.im/post/5bff854b5188250e8601ec90
synchronize早已經沒那麽笨重