synchronize早已經沒那麼笨重
我發現一些同學在網路上有看不少synchronize的文章,可能有些同學沒深入瞭解,只看了部分內容,就急急忙忙認為不能使用它,很笨重,因為是採用作業系統同步互斥訊號量來實現的。關於這類的對於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
開開啟,在JDK1.6中預設開啟。同時自旋的預設次數為10次,可以通過引數-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會先在當前執行緒的棧楨中建立用於儲存鎖記錄的空間,並將物件頭中的Mark
Word複製到鎖記錄中,官方稱為Displaced Mark Word。然後執行緒嘗試使用CAS將物件頭中的Mark
Word替換為指向鎖記錄的指標。如果成功,當前執行緒獲得鎖,如果失敗,表示其他執行緒競爭鎖,當前執行緒便
嘗試使用自旋來獲取鎖。
複製程式碼
輕量級鎖解鎖:
輕量級解鎖時,會使用原子的CAS操作來將Displaced Mark
Word替換回到物件頭,如果成功,則表示沒有競爭發生。如果失敗(表示有競爭),表示當前鎖存在競爭,鎖
就會膨脹成重量級鎖。
複製程式碼
關於輕量級鎖的加鎖和解鎖過程簡單來說就是:
- 嘗試CAS修改mark word:如果這步能直接成功,則代價較小,可以直接獲取鎖
- 獲取鎖失敗則採用自旋鎖來獲取鎖(CAS修改嘗試失敗後採取的策略)
- 自旋鎖嘗試失敗,鎖膨脹,成為重量級鎖:自旋鎖也嘗試失敗,不得不使用重量級鎖,執行緒也被阻塞。
總結
所以synchronize並有沒像之前想象的那麼笨重,其實大家可以在大量的原始碼中都能看到它的身影,包括juc包下的工具類等等,總之存在必有合理之處,望大家善用它。(當然前提必須理解它)