天天看點

一夜搞懂 | JVM 線程安全與鎖優化

一夜搞懂 | JVM 線程安全與鎖優化

學習導圖#

一.為什麼要學習記憶體模型與線程?#

之前我們學習了記憶體模型和線程,了解了 JMM 和線程,初步探究了 JVM 怎麼實作并發,而本篇文章,我們的關注點是 JVM 如何實作高效

并發程式設計的目的是為了讓程式運作得更快,提高程式的響應速度,雖然我們希望通過多線程執行任務讓程式運作得更快,但是同時也會面臨非常多的挑戰,比如像線程安全問題、線程上下文切換的問題、硬體和軟體資源限制等問題,這些都是并發程式設計給我們帶來的難題。

其中線程安全問題是我們最關心的問題之一,我們接下來主要就圍繞着線程安全的問題來展開。

二.核心知識點歸納#

2.1 線程安全#

2.1.1 定義#

當多個線程通路一個對象時,如果不用考慮這些線程在運作時環境下的排程和交替執行,也不需要進行額外的同步,或者在調用方進行任何其他的協調操作,調用這個對象的行為都可以獲得正确的結果,那這個對象是線程安全的

要求線程安全的代碼都必須具備一個特征:

代碼本身封裝了所有必要的正确性保障手段(如互斥同步等),令調用者無須關心多線程的問題,更無須自己采取任何措施來保證多線程的正确調用。

2.1.2 分類#

下面将按照線程安全的程度由強至弱分成五類

不可變:外部的可見狀态永遠不會改變,在多個線程之中永遠是一緻的狀态

一定是線程安全的

如何實作:

1.如果共享資料是一個基本資料類型,隻要在定義時用 final 關鍵字修飾

2.如果共享資料是一個對象,最簡單的方法是把對象中帶有狀态的變量都聲明為 final(例如 String 類的實作)

絕對線程安全:完全滿足之前給出的線程安全的定義,即達到『不管運作時環境如何,調用者都不需要任何額外的同步措施』

相對線程安全:能保證對該對象單獨的操作是線程安全的,在調用時無需做額外保障措施,但對于一些特定順序的連續調用,可能需要在調用端使用額外的同步措施來保證調用的正确性

是通常意義上所講的線程安全

大部分的線程安全類都屬于這種類型,如 Vector、HashTable、Collections#synchronizedCollection() 包裝的集合等

線程相容:對象本身非線程安全的,但可以通過在調用端正确地使用同步手段來保證對象在并發環境中可以安全地使用

是通常意義上所講的非線程安全

Java API 中大部分類都是屬于線程相容的,如 ArrayList 和 HashMap等

線程對立:無論調用端是否采取了同步措施,都無法在多線程環境中并發使用的代碼

例子:Thread 類的 suspend() 和 resume() ,一個嘗試中斷線程,一個嘗試恢複線程,在并發條件下,有可能會造成死鎖

2.1.3 實作#

可分成兩大手段:

通過代碼編寫實作線程安全

通過虛拟機本身實作同步與鎖

本篇重點在虛拟機本身

1.互斥同步

含義:

同步:在多個線程并發通路共享資料時,保證共享資料在同一個時刻隻被一個線程使用

互斥:是實作同步的一種手段,臨界區、互斥量和信号量都是主要的互斥實作方式

兩者關系:互斥是因,同步是果;互斥是方法,同步是目的

屬于悲觀并發政策(悲觀鎖),即認為隻要不做正确的同步措施就肯定會出現問題,是以無論共享資料是否真的會出現競争,都要加鎖

最大的問題是進行線程阻塞和喚醒所帶來的性能問題,也稱為阻塞同步

使用方式:

A.使用 synchronized 關鍵字:

原理:編譯後會在同步塊的前後分别形成 monitorenter 和 monitorexit 這兩個位元組碼指令,并通過一個 reference 類型的參數來指明要鎖定和解鎖的對象

注意:

​ 1.若明确指定了對象參數,則取該對象的 reference

​ 2.否則,會根據 synchronized 修飾的是執行個體方法還是類方法去取對應的對象執行個體或 Class 對象來作為鎖對象

過程:執行 monitorenter 指令時先要嘗試擷取對象的鎖。若該對象沒被鎖定或者已被目前線程擷取,那麼鎖計數器 + 1;而在執行 monitorexit 指令時,鎖計數器 - 1;當鎖計數器 = 0 時,鎖就被釋放;若擷取對象鎖失敗,那目前線程會一直被阻塞等待,直到對象鎖被另外一個線程釋放為止

特别注意:

1.synchronized 同步塊對同一條線程來說是可重入的,不會出現自我鎖死的問題

2.同步塊在已進入的線程執行完之前,會阻塞後面其他線程的進入

​ B.使用重入鎖 ReentrantLock:

​ 之前在 進階之路 | 奇妙的 Thread 之旅中也提到過重入鎖的使用,相信看過的讀者還有一些印象

與 synchronized 的相同:用法與 synchronized 很相似,且都可重入

與 synchronized 的不同:

1.等待可中斷:當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改為處理其他事情

2.公平鎖:多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖。而 synchronized 是非公平的,即在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。ReentrantLock 預設情況下也是非公平的,但可以通過帶布爾值的構造函數改用公平鎖

3.鎖綁定多個條件:一個 ReentrantLock 對象可以通過多次調用 newCondition() 同時綁定多個 Condition 對象。而在 synchronized 中,鎖對象的 wait() 和 notify() 或 notifyAl() 隻能實作一個隐含的條件,若要和多于一個的條件關聯不得不額外地添加一個鎖

選擇:在 synchronized 能實作需求的情況下,優先考慮使用它來進行同步。理由如下:

synchronized 是 Java文法層面的同步,足夠清晰簡單

Lock 必須由程式員確定在 finally 塊中釋放鎖,而 synchronized 可以由 JVM 確定鎖的自動釋放

2.非阻塞同步

定義:基于沖突檢測的樂觀并發政策(樂觀鎖),即先進行操作,若無其他線程争用共享資料,操作成功;反之産生了沖突再去采取其他的補償措施

為了保證操作和沖突檢測這兩步具備原子性,需要用到硬體指令集,比如:

測試并設定

擷取并增加

交換

比較并交換(CAS)

加載連結 / 條件存儲

3.無同步方案

定義:不用同步的方式保證線程安全,因為有些代碼天生就是線程安全的。

例子:

A.可重入代碼/ 純代碼

含義:可在代碼執行的任何時刻中斷它去執行另外一段代碼,當控制權傳回後原來的程式并不會出現任何錯誤

共同特征:不依賴存儲在堆上的資料和公用的系統資源、用到的狀态量都由參數中傳入、不調用非可重入的方法

判定依據:如果一個方法,它的傳回結果是可預測的,隻要輸入相同的資料就都能傳回相同的結果,就滿足可重入性

注意:滿足可重入性的代碼一定是線程安全的,反之,滿足線程安全的代碼不一定是可重入的

B.線程本地存儲

含義:把共享資料的可見範圍限制在同一個線程之内,無須同步就能保證線程之間不出現資料争用的問題

想詳細了解 ThreadLocal 的讀者,可以看下筆者之前寫的一篇文章:進階之路 | 奇妙的 Handler 之旅

2.2 鎖優化#

解決并發的正确性之後,為了能線上程之間更『高效』地共享資料、解決競争問題、提高程式的執行效率,下面介紹五種鎖優化技術

2.2.1 适應性自旋#

背景:互斥同步在實作阻塞和喚醒時需要挂起線程和恢複線程的操作,都需要轉入核心态中完成,很影響系統的并發性能;同時,在許多應用上共享資料的鎖定狀态隻是暫時,沒必要去挂起和恢複線程

自旋鎖:當實體機器有多個處理器使得多個線程同時并行執行時,先讓後請求鎖的線程等待,但不放棄處理器的執行時間,看看持有鎖的線程是否很快就會釋放鎖,這時隻需讓線程執行一個忙循環,即自旋

注意:自旋等待不能代替阻塞,它雖然能避免線程切換的開銷,但會占用處理器時間,是以自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數(預設10次)仍未成功獲鎖,就需要挂線程了

自适應自旋鎖:自旋的時間不再固定,而是由該鎖上的上次自旋時間及鎖的擁有者的狀态共同決定。具體表現是:

如果對于某個鎖,自旋等待剛剛成功獲得,且持有鎖的線程正在運作中,那麼虛拟機很可能允許自旋等待的時間更久點

如果對于某個鎖,自旋很少成功獲得過,那麼很可能以後将省略自旋等待這個鎖,避免浪費處理器資源

2.2.2 鎖消除#

定義:指虛拟機即時編譯器在運作時,對一些代碼上要求同步,但是被檢測到不可能存在共享資料競争的鎖進行消除

判定依據:如果一段代碼中堆上的所有資料都不會逃逸出去被其他線程通路到,可把它們當做棧上資料對待,即線程私有的,無須同步加鎖

2.2.3 鎖粗化#

一般情況下,會将同步塊的作用範圍限制到隻在共享資料的實際作用域中才進行同步,使得需要同步的操作數量盡可能變小,保證就算存在鎖競争,等待鎖的線程也能盡快拿到鎖

但如果反複操作對同一個對象進行加鎖和解鎖,即使沒有線程競争,頻繁地進行互斥同步操作也會導緻不必要的性能損耗,此時,虛拟機将會把加鎖同步的範圍粗化到整個操作序列的外部,這樣隻需加一次鎖

2.2.4 輕量級鎖#

目的:在沒有多線程競争的前提下,減少傳統的重量級鎖使用作業系統互斥量産生的性能消耗,注意不是用來代替重量級鎖的

首先先了解 HotSpot 虛拟機的對象頭的記憶體布局:分為兩部分

第一部分用于存儲對象自身的運作時資料,這部分被稱為 Mark Word,是實作輕量級鎖和偏向鎖的關鍵。如哈希碼、GC 分代年齡等

另外一部分用于存儲指向方法區對象類型資料的指針,如果是數組對象還會有一個額外的部分用于存儲數組長度

加鎖過程:

1.代碼進入同步塊時,如果同步對象未被鎖定(鎖标志位為 01),虛拟機會在目前線程的棧幀中建立一個名為 Lock Record 的空間,用于存儲鎖對象 Mark Word 的拷貝。如下圖

2.之後虛拟機會嘗試用 CAS 操作将對象的 Mark Word 更新為指向 Lock Record 的指針。若更新動作成功,那麼目前線程就擁有了該對象的鎖,且對象 Mark Word 的鎖标志位變為 00,即處于輕量級鎖定狀态;反之,虛拟機會先檢查對象的 Mark Word 是否指向目前線程的棧幀,若是,則目前線程已有該對象的鎖,可直接進入同步塊繼續執行,否則說明改對象已被其他線程搶占。如下圖:

另外,如果有兩條以上的線程争用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖标志位變為 10,Mark Word 中存儲的就是指向重量級鎖的指針,後面等待鎖的線程也要進入阻塞狀态

解鎖過程:若對象的 Mark Word 仍指向着線程的 Lock Record,就用 CAS 操作把對象目前的 Mark Word 和線程中複制的 Displaced Mark Word 替換回來。若替換成功,那麼就完成了整個同步過程;反之,說明有其他線程嘗試擷取該鎖,那麼就要在釋放鎖的同時喚醒被挂起的線程

優點:因為對于絕大部分的鎖,在整個同步周期内都是不存在競争的,是以輕量級鎖通過使用 CAS 操作消除同步使用的互斥量

自旋鎖和輕量級鎖的關系:

自旋鎖是為了減少線程挂起次數

輕量級鎖是在加鎖的時候,如何使用一種更高效的方式來加鎖

Q:處于輕量級鎖狀态時,會不會使用自旋鎖這個競争機制

A:線程首先會通過 CAS 擷取鎖,失敗後通過自旋鎖來嘗試擷取鎖,再失敗鎖就膨脹為重量級鎖。是以輕量級鎖狀态下可能會有自旋鎖的參與(CAS 将對象頭的标記指向鎖記錄指針失敗的時候)

2.2.5 偏向鎖#

目的:消除資料在無競争情況下的同步原語,進一步提高程式的運作性能

如果說輕量級鎖是在無競争的情況下使用 CAS 去消除同步使用的互斥量

那偏向鎖就是在無競争情況下把整個同步都消除掉

含義:偏向鎖會偏向于第一個獲得它的線程,如果在後面的執行中該鎖沒有被其他的線程擷取,則持有偏向鎖的線程将永遠不需要再進行同步

加鎖過程:啟用偏向鎖的鎖對象在第一次被線程擷取時,Mark Word 的鎖标志位會被設定為 01,即偏向模式,同時使用 CAS操作把擷取到這個鎖的線程 ID 記錄在對象的 Mark Word 中。若操作成功,持有偏向鎖的線程以後每次進入這個鎖相關的同步塊時都可不再進行任何同步操作

解鎖過程:當有另外的線程去嘗試擷取這個鎖時,根據鎖對象目前是否處于被鎖定的狀态,撤銷偏向後恢複到未鎖定 01 或輕量級鎖定 00 的狀态,後續的同步操作就如輕量級鎖執行過程。如下圖:

優點:可提高帶有同步但無競争的程式性能,但若程式中大多數鎖總被多個線程通路,此模式就沒必要了

三.碎碎念#

能夠寫出高性能、高伸縮性的并發程式是一門藝術,而了解并發在底層是如何實作的,則是掌握這門藝術的前提,也是成長為進階程式員的必備知識!

加油吧!騷年!以夢為馬,不負韶華!

如果文章對您有一點幫助的話,希望您能點一下贊,您的點贊,是我前進的動力

本文參考連結:

《深入了解Java虛拟機》第3版

一、聊聊并發—線程安全到底在說什麼

要點提煉 | 了解 JVM 之線程安全 & 鎖優化

自旋鎖跟輕量級鎖的關系是什麼?

一圖帶你看懂Java各種鎖,建議儲存!

作者:許朋友愛玩

出處:

https://www.cnblogs.com/xcynice/p/jvm-xian-cheng-an-quan-yu-suo-you-hua.html