資深架構(gòu)師解讀Java多線程與并發(fā)模型之鎖
原創(chuàng)【51CTO.com原創(chuàng)稿件】互聯(lián)網(wǎng)上充斥著對Java多線程編程的介紹,每篇文章都從不同的角度介紹并總結(jié)了該領(lǐng)域的內(nèi)容。但大部分文章都沒有說明多線程的實現(xiàn)本質(zhì),沒能讓開發(fā)者真正“過癮”。
本篇內(nèi)容從Java的線程安全鼻祖內(nèi)置鎖介紹開始,讓你了解內(nèi)置鎖的實現(xiàn)邏輯和原理以及引發(fā)的性能問題,接著說明了Java多線程編程中鎖的存在是為了保障共享變量的線程安全使用。下面讓我們進入正題。
以下內(nèi)容如無特殊說明均指代Java環(huán)境。
***部分:鎖
提到并發(fā)編程,大多數(shù)Java工程師的***反應都是synchronized關(guān)鍵字。這是Java在1.0時代的產(chǎn)物,至今仍然應用于很多的項目中,伴隨著Java的版本更新已經(jīng)存在了20多年。在如此之長的生命周期中,synchronized內(nèi)部也在進行著“自我”進化。
早期的synchronized關(guān)鍵字是Java并發(fā)問題的唯一解決方案, 伴隨引入這種“重量型”鎖,帶來的性能開銷也是很大的,早期的工程師為了解決性能開銷問題,想出了很多解決方案(例如DCL)來提升性能。好在Java1.6提供了鎖的狀態(tài)升級來解決這種性能消耗。一般通俗的說Java的鎖按照類別可以分為類鎖和對象鎖兩種,兩種鎖之間是互不影響的,下面我們一起看下這兩種鎖的具體含義。
類鎖和對象鎖
由于JVM內(nèi)存對象中需要對兩種資源進行協(xié)同以保證線程安全,JVM堆中的實例對象和保存在方法區(qū)中的類變量。因此Java的內(nèi)置鎖分為類鎖和對象鎖兩種實現(xiàn)方式實現(xiàn)。前面已經(jīng)提到類鎖和對象鎖是相互隔離的兩種鎖,它們之間不存在相互的直接影響,以不同方式實現(xiàn)對共享對象的線程安全訪問。下面根據(jù)兩種鎖的隔離方式做如下說明:
1、當有兩個(或以上)線程共同去訪問一個Object共享對象時,同一時刻只有一個線程可以訪問該對象的synchronized(this)同步方法(或同步代碼塊),也就是說,同一時刻,只能有一個線程能夠得到CPU的執(zhí)行,另一個線程必須等待當前獲得CPU執(zhí)行的線程完成之后才有機會獲取該共享對象的鎖。
2、當一個線程已經(jīng)獲得該Object對象的同步方法(或同步代碼塊)的執(zhí)行權(quán)限時,其他的線程仍然可以訪問該對象的非synchronized方法。
3、當一個線程已經(jīng)獲取該Object對象的synchronized(this)同步方法(或代碼塊)的鎖時,該對象被類鎖修飾的同步方法(或代碼塊)仍然可以被其他線程在同一CPU周期內(nèi)獲取,兩種鎖不存在資源競爭情況。
在我們對內(nèi)置鎖的類別有了基本了解后,我們可能會想JVM是如何實現(xiàn)和保存內(nèi)置鎖的狀態(tài)的,其實JVM是將鎖的信息保存在Java對象的對象頭中。首先我們看下Java的對象頭是怎么回事。
Java對象頭
為了解決早期synchronized關(guān)鍵字帶來的鎖性能開銷問題,從Java1.6開始引入了鎖狀態(tài)的升級方式用以減輕1.0時代鎖帶來的性能消耗,對象的鎖由無鎖狀態(tài) -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖狀的升級。
圖1.1:對象頭
在Hotspot虛擬機中對象頭分為兩個部分(數(shù)組還要多一部分用于存儲數(shù)組長度),其中一部分用來存儲運行時數(shù)據(jù),如HashCode、GC分代信息、鎖標志位,這部分內(nèi)容又被稱為Mark Word。在虛擬機運行期間,JVM為了節(jié)省存儲成本會對Mark Word的存儲區(qū)間進行重用,因此Mark Word的信息會隨著鎖狀態(tài)變化而改變。另外一部分用于方法區(qū)的數(shù)據(jù)類型指針存儲。
Java的內(nèi)置鎖的狀態(tài)升級實現(xiàn)是通過替換對象頭中的Mark Word的標識來實現(xiàn)的,下面具體看下內(nèi)置鎖的狀態(tài)是如何從無鎖狀態(tài)升級為重量級鎖狀態(tài)。
內(nèi)置鎖的狀態(tài)升級
JVM為了提升鎖的性能,共提供了四種量級的鎖。級別從低到高分為:無狀態(tài)的鎖、偏向鎖、輕量級的鎖和重量級的鎖。在Java應用中加鎖大多使用的是對象鎖,對象鎖隨著線程競爭的加劇,最終可能會升級為重量級的鎖。鎖可以升級但不能降級(也就是為什么我們進行任何基準測試都需要對數(shù)據(jù)進行預熱,以防止噪聲的干擾,當然噪聲還可能是其他原因)。在說明內(nèi)置鎖狀態(tài)升級之前,先介紹一個重要的鎖概念,自旋鎖。
自旋鎖
在互斥(mutex)狀態(tài)下的內(nèi)置鎖帶來的性能下降是很明顯的。沒有得到鎖的線程需要等待持有鎖的線程釋放鎖才可以爭搶運行,掛起和恢復一個線程的操作都需要從操作系統(tǒng)的用戶態(tài)轉(zhuǎn)到內(nèi)核態(tài)來完成。然而CPU為保障每個線程都能得到運行,分配的時間片是有限的,每次上下文切換都是非常浪費CPU的時間片的,在這種條件下自旋鎖發(fā)揮了優(yōu)勢。
所謂自旋,就是讓沒有得到鎖的線程自己運行一段時間,線程自旋是不會引起線程休眠的(自旋會一直占用CPU資源),所以并不是真正的阻塞。當線程狀態(tài)被其他線程改變才會進入臨界區(qū),進而被阻塞。在Java1.6版本已經(jīng)默認開啟了該設置(可以通過JVM參數(shù)-XX:+UseSpinning開啟,在Java1.7中自旋鎖的參數(shù)已經(jīng)被取消,不再支持用戶配置而是虛擬機總會默認執(zhí)行)。
雖然自旋鎖不會引起線程的休眠,減少了等待時間,但自旋鎖也存在著對CPU資源浪費的情況,自旋鎖需要在運行期間空轉(zhuǎn)CPU的資源。只有當自旋等待的時間高于同步阻塞時才有意義。因此JVM限制了自旋的時間限度,當超過這個限度時,線程就會被掛起。
在Java1.6 中提供了自適應自旋鎖,優(yōu)化了原自旋鎖限度的次數(shù)問題,改為由自旋線程時間和鎖的狀態(tài)來確定。例如,如果一個線程剛剛自旋成功獲取到鎖,那么下次獲取鎖的可能性就會很大,所以JVM準許自旋的時間相對較長,反之,自旋的時間就會很短或者忽略自旋過程,這種情況在Java1.7也得到了優(yōu)化。
自旋鎖是貫穿內(nèi)置鎖狀態(tài)始終的,作為偏向鎖,輕量級鎖以及重量級鎖的補充。
偏向鎖
偏向鎖是Java1.6 提出的一種鎖優(yōu)化機制,其核心思想是,如果當前線程沒有競爭則取消之前已經(jīng)取得鎖的線程同步操作,在JVM的虛擬機模型中減少對鎖的檢測。也就是說如果某個線程取得對象的偏向鎖,那么當這個線程在此請求該偏向鎖時,就不需要額外的同步操作了。
具體的實現(xiàn)為當一個線程訪問同步塊時會在對象頭的Mark Word中存儲鎖的偏向線程ID,后續(xù)該線程訪問該鎖時,就可以簡單的檢查下Mark Word是否為偏向鎖并且其偏向鎖是否指向當前線程。
如果測試成功則線程獲取到偏向鎖,如果測試失敗,則需要檢測下Mark Word中偏向鎖的標記是否設置成了偏向狀態(tài)(標記位為1)。如果沒有設置,則使用CAS競爭鎖。如果設置了,嘗試使用CAS將對象頭的Mark Word偏向鎖標記指向當前線程。也可以使用JVM參數(shù)-XX:-UseBiastedLocking參數(shù)來禁用偏向鎖。
因為偏向鎖使用的是存在競爭才釋放鎖的機制,所以當其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。
輕量級的鎖
如果偏向鎖獲取失敗,那么JVM會嘗試使用輕量級鎖,帶來一次鎖的升級。輕量級鎖存在的出發(fā)點是為了優(yōu)化鎖的獲取方式,在不存在多線程競爭的前提下,以減少Java 1.0時代鎖互斥帶來的性能開銷。輕量級鎖在JVM內(nèi)部是使用BasicObjectLock對象實現(xiàn)的。
其具體的實現(xiàn)為當前線程在進入同步代碼塊之前,會將BasicObjectLock對象放到Java的棧楨中,這個對象的內(nèi)部是由BasicLock對象和該Java對象的指針組成的。然后當前線程嘗試使用CAS替換對象頭中的Mark Word鎖標記指向該鎖記錄指針。如果成功則獲取到鎖,將對象的鎖標記改為00 | locked,如果失敗則表示存在其他線程競爭,當前線程使用自旋嘗試獲取鎖。
當存在兩條(或以上)的線程共同競爭一個鎖時,此時的輕量級的鎖將不再發(fā)揮作用,JVM會將其膨脹為重量級的鎖,鎖的標位為也會修改為10 | monitor 。
輕量級鎖在解鎖時,同樣是通過CAS的置換對象頭操作。如果成功,則表示成功獲取到鎖。如果失敗,則說明該對象存在其他線程競爭,該鎖會隨著膨脹為重量級的鎖。
重量級的鎖
JVM在輕量級鎖獲取失敗后,會使用重量級的鎖來處理同步操作,此時對象的Mark Word標記為 10 | monitor,在重量級鎖處理線程的調(diào)度中,被阻塞的線程會被系統(tǒng)掛起,在線程再次獲得CPU資源后,需要進行系統(tǒng)上下文的切換才能得到CPU執(zhí)行,此時效率會低很多。
通過上面的介紹我們了解了Java的內(nèi)置鎖升級策略,隨著鎖的每次升級帶來的性能的下降,因此我們在程序設計時應該盡量避免鎖的征用,可以使用集中式緩存來解決該問題。
一個小插曲:內(nèi)置鎖的繼承
內(nèi)置鎖是可以被繼承的,Java的內(nèi)置鎖在子類對父類同步方法進行方法覆蓋時,其同步標志是可以被子類繼承使用的,我們看下面的例子:
- public class Parent {
- public synchronized void doSomething() {
- System.out.println("parent do something");
- }
- }
- public class Child extends Parent {
- public synchronized void doSomething() {
- .doSomething();
- }
- public static void main(String[] args) {
- new Child().doSomething();
- }
- }
代碼1.1:內(nèi)置鎖繼承
以上的代碼可以正常的運行么?
答案是肯定的。
避免活躍度危險
Java并發(fā)的安全性和活躍度是相互影響的,我們使用鎖來保障線程安全的同時,需要避免線程活躍度的風險。Java線程不能像數(shù)據(jù)庫那樣自動排查解除死鎖,也無法從死鎖中恢復。而且程序中死鎖的檢查有時候并不是顯而易見的,必須到達相應的并發(fā)狀態(tài)才會發(fā)生,這個問題往往給應用程序帶來災難性的結(jié)果,這里介紹以下幾種活躍度危險:死鎖、線程饑餓、弱響應性、活鎖。
死鎖
當一個線程永遠的占有一個鎖,而其他的線程嘗試去獲取這個鎖時,這個線程將被***的阻塞。
一個經(jīng)典的例子就是AB鎖問題,線程1獲取到了共享數(shù)據(jù)A的鎖,同時線程2獲取到了共享數(shù)據(jù)B的鎖,此時線程1想要去獲取共享數(shù)據(jù)B的鎖,線程2獲取共享數(shù)據(jù)A的鎖。如果用圖的關(guān)系表示,那么這將是一個環(huán)路。這是死鎖是最簡單的形式。還有比如我們再對批量無序的數(shù)據(jù)做更新操作時,如果無序的行為引發(fā)了2個線程的資源爭搶也會引發(fā)該問題,解決的途徑就是排序后再進行處理。
線程饑餓
線程饑餓是指當線程訪問它所需要的資源時卻***被拒絕,以至于不能再繼續(xù)進行后面的流程,這樣就發(fā)生了線程饑餓;例如線程對CPU時間片的競爭,Java中低優(yōu)先級的線程引用不當?shù)取km然Java的API中對線程的優(yōu)先級進行了定義,這僅僅是一種向CPU自我推薦的行為(此處需要注意不同操作系統(tǒng)的線程優(yōu)先級并不統(tǒng)一,而且對應的Java線程優(yōu)先級也不統(tǒng)一),但是這并不能保障高優(yōu)先級的線程一定能夠先被CPU選擇執(zhí)行。
弱響應性
在GUI的程序中,我們一般可見的客戶端程序都是使用后臺運行,前端反饋的形式,當CPU密集型后臺任務與前臺任務共同競爭資源時,有可能造成前端GUI凍結(jié)的效果,因此我們可以降低后臺程序的優(yōu)先級,盡可能的保障***的用戶體驗性。
活鎖
線程活躍度失敗的另一種體現(xiàn)是線程沒有被阻塞,但是卻不能繼續(xù),因為不斷重試相同的操作,卻總是失敗。
線程的活躍度危險是我們在開發(fā)中應該避免的一種行為。這種行為會造成應用程序的災難性后果。
總結(jié)
關(guān)于synchronized關(guān)鍵字的所有內(nèi)容到這里全部介紹完畢了,在這一章節(jié)希望可以讓大家明白鎖之所以“重”是因為隨著線程間競爭的程度升級導致的。在真正的開發(fā)中我們可能還有別的選擇,例如Lock接口,在某些并發(fā)場景下性能優(yōu)于內(nèi)置鎖的實現(xiàn)。
不論是通過內(nèi)置鎖還是通過Lock接口都是為了保障并發(fā)的安全性,并發(fā)環(huán)境一般需要考慮的問題是如何保障共享對象的安全訪問。在第二章將詳細介紹內(nèi)置對象引發(fā)的線程安全問題以及解決之道。
作者簡介
魏靚:現(xiàn)就職于五阿哥(www.wuage.com)任職專職架構(gòu)師工作,負責平臺的基礎(chǔ)設施搭建工作。
【51CTO原創(chuàng)稿件,合作站點轉(zhuǎn)載請注明原文作者和出處為51CTO.com】