這是一篇總結Java多線程開發的文章,是從Java建立之初就存在的synchronized關鍵字引入,對Java多線程和併發模型進行了探討。但願經過此篇內容的解讀能幫助Java開發者更好的理清Java併發編程的脈絡。前端
互聯網上充斥着對Java多線程編程的介紹,每篇文章都從不一樣的角度介紹並總結了該領域的內容。但大部分文章都沒有說明多線程的實現本質,沒能讓開發者真正「過癮」。數據庫
從Java的線程安全鼻祖內置鎖介紹開始,讓你瞭解內置鎖的實現邏輯和原理以及引起的性能問題,接着說明了Java多線程編程中鎖的存在是爲了保障共享變量的線程安全使用。下面讓咱們進入正題。編程
如下內容如無特殊說明均指代Java環境。數組
提到併發編程,大多數Java工程師的第一反應都是synchronized關鍵字。這是Java在1.0時代的產物,至今仍然應用於不少的項目中,伴隨着Java的版本更新已經存在了20多年。在如此之長的生命週期中,synchronized內部也在進行着「自我」進化。緩存
早期的synchronized關鍵字是Java併發問題的惟一解決方案, 伴隨引入這種「重量型」鎖,帶來的性能開銷也是很大的,早期的工程師爲了解決性能開銷問題,想出了不少解決方案(例如DCL)來提高性能。好在Java1.6提供了鎖的狀態升級來解決這種性能消耗。通常通俗的說Java的鎖按照類別能夠分爲類鎖和對象鎖兩種,兩種鎖之間是互不影響的,下面咱們一塊兒看下這兩種鎖的具體含義。安全
因爲JVM內存對象中須要對兩種資源進行協同以保證線程安全,JVM堆中的實例對象和保存在方法區中的類變量。所以Java的內置鎖分爲類鎖和對象鎖兩種實現方式實現。前面已經提到類鎖和對象鎖是相互隔離的兩種鎖,它們之間不存在相互的直接影響,以不一樣方式實現對共享對象的線程安全訪問。下面根據兩種鎖的隔離方式作以下說明:多線程
一、當有兩個(或以上)線程共同去訪問一個Object共享對象時,同一時刻只有一個線程能夠訪問該對象的synchronized(this)同步方法(或同步代碼塊),也就是說,同一時刻,只能有一個線程可以獲得CPU的執行,另外一個線程必須等待當前得到CPU執行的線程完成以後纔有機會獲取該共享對象的鎖。併發
二、當一個線程已經得到該Object對象的同步方法(或同步代碼塊)的執行權限時,其餘的線程仍然能夠訪問該對象的非synchronized方法。性能
三、當一個線程已經獲取該Object對象的synchronized(this)同步方法(或代碼塊)的鎖時,該對象被類鎖修飾的同步方法(或代碼塊)仍然能夠被其餘線程在同一CPU週期內獲取,兩種鎖不存在資源競爭狀況。測試
在咱們對內置鎖的類別有了基本瞭解後,咱們可能會想JVM是如何實現和保存內置鎖的狀態的,其實JVM是將鎖的信息保存在Java對象的對象頭中。首先咱們看下Java的對象頭是怎麼回事。
爲了解決早期synchronized關鍵字帶來的鎖性能開銷問題,從Java1.6開始引入了鎖狀態的升級方式用以減輕1.0時代鎖帶來的性能消耗,對象的鎖由無鎖狀態 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖狀的升級。
在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"); } } Java併發編程交流組:535665367 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接口都是爲了保障併發的安全性,併發環境通常須要考慮的問題是如何保障共享對象的安全訪問,歡迎加入【Java併發編程交流組】:https://jq.qq.com/?_wv=1027&k=5mOvK7L