1. 程式人生 > >Java執行緒安全與多執行緒開發

Java執行緒安全與多執行緒開發

網際網路上充斥著對Java多執行緒程式設計的介紹,每篇文章都從不同的角度介紹並總結了該領域的內容。但大部分文章都沒有說明多執行緒的實現本質,沒能讓開發者真正“過癮”。

從Java的執行緒安全鼻祖內建鎖介紹開始,讓你瞭解內建鎖的實現邏輯和原理以及引發的效能問題,接著說明了Java多執行緒程式設計中鎖的存在是為了保障共享變數的執行緒安全使用。下面讓我們進入正題。

以下內容如無特殊說明均指代Java環境。

第一部分:鎖

提到併發程式設計,大多數Java工程師的第一反應都是synchronized關鍵字。這是Java在1.0時代的產物,至今仍然應用於很多的專案中,伴隨著Java的版本更新已經存在了20多年。在如此之長的生命週期中,synchronized內部也在進行著“自我”進化。

早期的synchronized關鍵字是Java併發問題的唯一解決方案, 伴隨引入這種“重量型”鎖,帶來的效能開銷也是很大的,早期的工程師為了解決效能開銷問題,想出了很多解決方案(例如DCL)來提升效能。好在Java1.6提供了鎖的狀態升級來解決這種效能消耗。一般通俗的說Java的鎖按照類別可以分為類鎖和物件鎖兩種,兩種鎖之間是互不影響的,下面我們一起看下這兩種鎖的具體含義。

類鎖和物件鎖

由於JVM記憶體物件中需要對兩種資源進行協同以保證執行緒安全,JVM堆中的例項物件和儲存在方法區中的類變數。因此Java的內建鎖分為類鎖和物件鎖兩種實現方式實現。前面已經提到類鎖和物件鎖是相互隔離的兩種鎖,它們之間不存在相互的直接影響,以不同方式實現對共享物件的執行緒安全訪問。下面根據兩種鎖的隔離方式做如下說明:

1、當有兩個(或以上)執行緒共同去訪問一個Object共享物件時,同一時刻只有一個執行緒可以訪問該物件的synchronized(this)同步方法(或同步程式碼塊),也就是說,同一時刻,只能有一個執行緒能夠得到CPU的執行,另一個執行緒必須等待當前獲得CPU執行的執行緒完成之後才有機會獲取該共享物件的鎖。

2、當一個執行緒已經獲得該Object物件的同步方法(或同步程式碼塊)的執行許可權時,其他的執行緒仍然可以訪問該物件的非synchronized方法。

3、當一個執行緒已經獲取該Object物件的synchronized(this)同步方法(或程式碼塊)的鎖時,該物件被類鎖修飾的同步方法(或程式碼塊)仍然可以被其他執行緒在同一CPU週期內獲取,兩種鎖不存在資源競爭情況。

在我們對內建鎖的類別有了基本瞭解後,我們可能會想JVM是如何實現和儲存內建鎖的狀態的,其實JVM是將鎖的資訊儲存在Java物件的物件頭中。首先我們看下Java的物件頭是怎麼回事。

Java物件頭

為了解決早期synchronized關鍵字帶來的鎖效能開銷問題,從Java1.6開始引入了鎖狀態的升級方式用以減輕1.0時代鎖帶來的效能消耗,物件的鎖由無鎖狀態 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖狀的升級。

 

 

圖1.1:物件頭

在Hotspot虛擬機器中物件頭分為兩個部分(陣列還要多一部分用於儲存陣列長度),其中一部分用來儲存執行時資料,如HashCode、GC分代資訊、鎖標誌位,這部分內容又被稱為Mark Word。在虛擬機器執行期間,JVM為了節省儲存成本會對Mark Word的儲存區間進行重用,因此Mark Word的資訊會隨著鎖狀態變化而改變。另外一部分用於方法區的資料型別指標儲存。

Java的內建鎖的狀態升級實現是通過替換物件頭中的Mark Word的標識來實現的,下面具體看下內建鎖的狀態是如何從無鎖狀態升級為重量級鎖狀態。

內建鎖的狀態升級

JVM為了提升鎖的效能,共提供了四種量級的鎖。級別從低到高分為:無狀態的鎖、偏向鎖、輕量級的鎖和重量級的鎖。在Java應用中加鎖大多使用的是物件鎖,物件鎖隨著執行緒競爭的加劇,最終可能會升級為重量級的鎖。鎖可以升級但不能降級(也就是為什麼我們進行任何基準測試都需要對資料進行預熱,以防止噪聲的干擾,當然噪聲還可能是其他原因)。在說明內建鎖狀態升級之前,先介紹一個重要的鎖概念,自旋鎖。

自旋鎖

在互斥(mutex)狀態下的內建鎖帶來的效能下降是很明顯的。沒有得到鎖的執行緒需要等待持有鎖的執行緒釋放鎖才可以爭搶執行,掛起和恢復一個執行緒的操作都需要從作業系統的使用者態轉到核心態來完成。然而CPU為保障每個執行緒都能得到執行,分配的時間片是有限的,每次上下文切換都是非常浪費CPU的時間片的,在這種條件下自旋鎖發揮了優勢。

所謂自旋,就是讓沒有得到鎖的執行緒自己執行一段時間,執行緒自旋是不會引起執行緒休眠的(自旋會一直佔用CPU資源),所以並不是真正的阻塞。當執行緒狀態被其他執行緒改變才會進入臨界區,進而被阻塞。在Java1.6版本已經預設開啟了該設定(可以通過JVM引數-XX:+UseSpinning開啟,在Java1.7中自旋鎖的引數已經被取消,不再支援使用者配置而是虛擬機器總會預設執行)。

雖然自旋鎖不會引起執行緒的休眠,減少了等待時間,但自旋鎖也存在著對CPU資源浪費的情況,自旋鎖需要在執行期間空轉CPU的資源。只有當自旋等待的時間高於同步阻塞時才有意義。因此JVM限制了自旋的時間限度,當超過這個限度時,執行緒就會被掛起。

在Java1.6 中提供了自適應自旋鎖,優化了原自旋鎖限度的次數問題,改為由自旋執行緒時間和鎖的狀態來確定。例如,如果一個執行緒剛剛自旋成功獲取到鎖,那麼下次獲取鎖的可能性就會很大,所以JVM准許自旋的時間相對較長,反之,自旋的時間就會很短或者忽略自旋過程,這種情況在Java1.7也得到了優化。

自旋鎖是貫穿內建鎖狀態始終的,作為偏向鎖,輕量級鎖以及重量級鎖的補充。

偏向鎖

偏向鎖是Java1.6 提出的一種鎖優化機制,其核心思想是,如果當前執行緒沒有競爭則取消之前已經取得鎖的執行緒同步操作,在JVM的虛擬機器模型中減少對鎖的檢測。也就是說如果某個執行緒取得物件的偏向鎖,那麼當這個執行緒在此請求該偏向鎖時,就不需要額外的同步操作了。

具體的實現為當一個執行緒訪問同步塊時會在物件頭的Mark Word中儲存鎖的偏向執行緒ID,後續該執行緒訪問該鎖時,就可以簡單的檢查下Mark Word是否為偏向鎖並且其偏向鎖是否指向當前執行緒。

如果測試成功則執行緒獲取到偏向鎖,如果測試失敗,則需要檢測下Mark Word中偏向鎖的標記是否設定成了偏向狀態(標記位為1)。如果沒有設定,則使用CAS競爭鎖。如果設定了,嘗試使用CAS將物件頭的Mark Word偏向鎖標記指向當前執行緒。也可以使用JVM引數-XX:-UseBiastedLocking引數來禁用偏向鎖。

因為偏向鎖使用的是存在競爭才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。

輕量級的鎖

如果偏向鎖獲取失敗,那麼JVM會嘗試使用輕量級鎖,帶來一次鎖的升級。輕量級鎖存在的出發點是為了優化鎖的獲取方式,在不存在多執行緒競爭的前提下,以減少Java 1.0時代鎖互斥帶來的效能開銷。輕量級鎖在JVM內部是使用BasicObjectLock物件實現的。

其具體的實現為當前執行緒在進入同步程式碼塊之前,會將BasicObjectLock物件放到Java的棧楨中,這個物件的內部是由BasicLock物件和該Java物件的指標組成的。然後當前執行緒嘗試使用CAS替換物件頭中的Mark Word鎖標記指向該鎖記錄指標。如果成功則獲取到鎖,將物件的鎖標記改為00 | locked,如果失敗則表示存在其他執行緒競爭,當前執行緒使用自旋嘗試獲取鎖。

當存在兩條(或以上)的執行緒共同競爭一個鎖時,此時的輕量級的鎖將不再發揮作用,JVM會將其膨脹為重量級的鎖,鎖的標位為也會修改為10 | monitor 。

輕量級鎖在解鎖時,同樣是通過CAS的置換物件頭操作。如果成功,則表示成功獲取到鎖。如果失敗,則說明該物件存在其他執行緒競爭,該鎖會隨著膨脹為重量級的鎖。

重量級的鎖

JVM在輕量級鎖獲取失敗後,會使用重量級的鎖來處理同步操作,此時物件的Mark Word標記為 10 | monitor,在重量級鎖處理執行緒的排程中,被阻塞的執行緒會被系統掛起,線上程再次獲得CPU資源後,需要進行系統上下文的切換才能得到CPU執行,此時效率會低很多。

通過上面的介紹我們瞭解了Java的內建鎖升級策略,隨著鎖的每次升級帶來的效能的下降,因此我們在程式設計時應該儘量避免鎖的徵用,可以使用集中式快取來解決該問題。

一個小插曲:內建鎖的繼承

內建鎖是可以被繼承的,Java的內建鎖在子類對父類同步方法進行方法覆蓋時,其同步標誌是可以被子類繼承使用的,我們看下面的例子:

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:內建鎖繼承

以上的程式碼可以正常的執行麼?

答案是肯定的。

避免活躍度危險

Java併發的安全性和活躍度是相互影響的,我們使用鎖來保障執行緒安全的同時,需要避免執行緒活躍度的風險。Java執行緒不能像資料庫那樣自動排查解除死鎖,也無法從死鎖中恢復。而且程式中死鎖的檢查有時候並不是顯而易見的,必須到達相應的併發狀態才會發生,這個問題往往給應用程式帶來災難性的結果,這裡介紹以下幾種活躍度危險:死鎖、執行緒飢餓、弱響應性、活鎖。

死鎖

當一個執行緒永遠的佔有一個鎖,而其他的執行緒嘗試去獲取這個鎖時,這個執行緒將被永久的阻塞。

一個經典的例子就是AB鎖問題,執行緒1獲取到了共享資料A的鎖,同時執行緒2獲取到了共享資料B的鎖,此時執行緒1想要去獲取共享資料B的鎖,執行緒2獲取共享資料A的鎖。如果用圖的關係表示,那麼這將是一個環路。這是死鎖是最簡單的形式。還有比如我們再對批量無序的資料做更新操作時,如果無序的行為引發了2個執行緒的資源爭搶也會引發該問題,解決的途徑就是排序後再進行處理。

執行緒飢餓

執行緒飢餓是指當執行緒訪問它所需要的資源時卻永久被拒絕,以至於不能再繼續進行後面的流程,這樣就發生了執行緒飢餓;例如執行緒對CPU時間片的競爭,Java中低優先順序的執行緒引用不當等。雖然Java的API中對執行緒的優先順序進行了定義,這僅僅是一種向CPU自我推薦的行為(此處需要注意不同作業系統的執行緒優先順序並不統一,而且對應的Java執行緒優先順序也不統一),但是這並不能保障高優先順序的執行緒一定能夠先被CPU選擇執行。

弱響應性

在GUI的程式中,我們一般可見的客戶端程式都是使用後臺執行,前端反饋的形式,當CPU密集型後臺任務與前臺任務共同競爭資源時,有可能造成前端GUI凍結的效果,因此我們可以降低後臺程式的優先順序,儘可能的保障最佳的使用者體驗性。

活鎖

執行緒活躍度失敗的另一種體現是執行緒沒有被阻塞,但是卻不能繼續,因為不斷重試相同的操作,卻總是失敗。

執行緒的活躍度危險是我們在開發中應該避免的一種行為。這種行為會造成應用程式的災難性後果。 

總結

關於synchronized關鍵字的所有內容到這裡全部介紹完畢了,在這一章節希望可以讓大家明白鎖之所以“重”是因為隨著執行緒間競爭的程度升級導致的。在真正的開發中我們可能還有別的選擇,例如Lock介面,在某些併發場景下效能優於內建鎖的實現。

不論是通過內建鎖還是通過Lock介面都是為了保障併發的安全性,併發環境一般需要考慮的問題是如何保障共享物件的安全訪問。

 

轉載自-- https://www.cnblogs.com/jurendage/p/8664026.html