­

一夜搞懂 | JVM 執行緒安全與鎖優化

  • 2020 年 4 月 11 日
  • 筆記

前言

本文已經收錄到我的 Github 個人部落格,歡迎大佬們光臨寒舍:

我的 GIthub 部落格

學習導圖

學習導圖

一.為什麼要學習記憶體模型與執行緒?

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

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

其中執行緒安全問題是我們最關心的問題之一,我們接下來主要就圍繞著執行緒安全的問題來展開。

二.核心知識點歸納

2.1 執行緒安全

2.1.1 定義

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

要求執行緒安全的程式碼都必須具備一個特徵
程式碼本身封裝了所有必要的正確性保障手段(如互斥同步等),令調用者無須關心多執行緒的問題,更無須自己採取任何措施來保證多執行緒的正確調用。

2.1.2 分類

下面將按照執行緒安全的程度由強至弱分成五類

  • 不可變:外部的可見狀態永遠不會改變,在多個執行緒之中永遠是一致的狀態
  • 一定是執行緒安全的

  • 如何實現

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

    2.如果共享數據是一個對象,最簡單的方法是把對象中帶有狀態的變數都聲明為 final(例如 String 類的實現)

  • 絕對執行緒安全:完全滿足之前給出的執行緒安全的定義,即達到『不管運行時環境如何,調用者都不需要任何額外的同步措施』
  • 相對執行緒安全:能保證對該對象單獨的操作是執行緒安全的,在調用時無需做額外保障措施,但對於一些特定順序的連續調用,可能需要在調用端使用額外的同步措施來保證調用的正確性
  • 通常意義上所講的執行緒安全
  • 大部分的執行緒安全類都屬於這種類型,如 VectorHashTableCollections#synchronizedCollection() 包裝的集合等
  • 執行緒兼容:對象本身非執行緒安全的,但可以通過在調用端正確地使用同步手段來保證對象在並發環境中可以安全地使用
  • 是通常意義上所講的非執行緒安全
  • Java API 中大部分類都是屬於執行緒兼容的,如 ArrayListHashMap
  • 執行緒對立:無論調用端是否採取了同步措施,都無法在多執行緒環境中並發使用的程式碼

例子:Thread 類的 suspend()resume() ,一個嘗試中斷執行緒,一個嘗試恢復執行緒,在並發條件下,有可能會造成死鎖

2.1.3 實現

可分成兩大手段:

  • 通過程式碼編寫實現執行緒安全
  • 通過虛擬機本身實現同步與鎖

本篇重點在虛擬機本身

1.互斥同步
  • 含義:
  • 同步:在多個執行緒並發訪問共享數據時,保證共享數據在同一個時刻只被一個執行緒使用

  • 互斥:是實現同步的一種手段,臨界區、互斥量和訊號量都是主要的互斥實現方式

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

  • 屬於悲觀並發策略悲觀鎖),即認為只要不做正確的同步措施就肯定會出現問題,因此無論共享數據是否真的會出現競爭,都要加鎖

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

  • 使用方式:

    A.使用 synchronized 關鍵字:

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

    注意:

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

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

    synchronized 處理邏輯

  • 過程:執行 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 能實現需求的情況下,優先考慮使用它來進行同步。理由如下:
  • synchronizedJava語法層面的同步,足夠清晰簡單
  • 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 分代年齡等
  • 另外一部分用於存儲指向方法區對象類型數據的指針,如果是數組對象還會有一個額外的部分用於存儲數組長度

Mark Word 結構

  • 加鎖過程:

    1.程式碼進入同步塊時,如果同步對象未被鎖定(鎖標誌位為 01),虛擬機會在當前執行緒的棧幀中建立一個名為 Lock Record 的空間,用於存儲鎖對象 Mark Word 的拷貝。如下圖

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

CAS後堆棧與對象的狀態

另外,如果有兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標誌位變為 10Mark 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 的狀態,後續的同步操作就如輕量級鎖執行過程。如下圖:

  • 優點:可提高帶有同步但無競爭的程式性能,但若程式中大多數鎖總被多個執行緒訪問,此模式就沒必要了

解放啦

三.碎碎念

能夠寫出高性能、高伸縮性的並發程式是一門藝術,而了解並發在底層是如何實現的,則是掌握這門藝術的前提,也是成長為高級程式設計師的必備知識!

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

沖鴨


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

本文參考鏈接: