《實戰 Java 高併發程式設計》筆記——第4章 鎖的優化及注意事項(二)

bm1998發表於2020-12-31

宣告:

本部落格是本人在學習《實戰 Java 高併發程式設計》後整理的筆記,旨在方便複習和回顧,並非用作商業用途。

本部落格已標明出處,如有侵權請告知,馬上刪除。

4.4 無鎖

就人的性格而言,我們可以分為樂天派和悲觀派。對於樂天派來說,總是會把事情往好的方面想。他們認為所有事情總是不太容易發生問題,出錯是小概率的,所以我們可以肆無忌憚地做事。如果真的不幸遇到了問題,則有則改之無則加勉。而對於悲觀的人群來說,他們總是擔驚受怕,認為出錯是一種常態,所以無論鉅細,都考慮得面面俱到,滴水不漏,確保為人處世,萬無一失。

對於併發控制而言,鎖是一種悲觀的策略。它總是假設每一次的臨界區操作會產生衝突,因此,必須對每次操作都小心翼翼。如果有多個執行緒同時需要訪問臨界區資源,就寧可犧牲效能讓執行緒進行等待,所以說鎖會阻塞執行緒執行。而無鎖是一種樂觀的策略,它會假設對資源的訪問是沒有衝突的。既然沒有衝突,自然不需要等待,所以所有的執行緒都可以在不停頓的狀態下持續執行。那遇到衝突怎麼辦呢?無鎖的策略使用一種叫做比較交換的技術(CAS Compare And Swap)來鑑別執行緒衝突,一旦檢測到衝突產生,就重試當前操作直到沒有衝突為止

4.4.1 與眾不同的併發策略:比較交換(CAS)

與鎖相比,使用比較交換(下文簡稱 CAS)會使程式看起來更加複雜一些。但由於其非阻塞性,它對死鎖問題天生免疫,並且,執行緒間的相互影響也遠遠比基於鎖的方式要小。更為重要的是,使用無鎖的方式完全沒有鎖競爭帶來的系統開銷,也沒有執行緒間頻繁排程帶來的開銷,因此,它要比基於鎖的方式擁有更優越的效能

CAS 演算法的過程是這樣:它包含三個引數 CAS(V,E,N)。V 表示要更新的變數,E 表示預期值,N 表示新值。僅當 V 值等於 E 值時,才會將 V 的值設為 N,如果 V 值和 E 值不同,則說明已經有其他執行緒做了更新,則當前執行緒什麼都不做。最後,CAS 返回當前 V 的真實值。CAS 操作是抱著樂觀的態度進行的,它總是認為自己可以成功完成操作。當多個執行緒同時使用 CAS 操作一個變數時,只有一個會勝出,併成功更新,其餘均會失敗。失敗的執行緒不會被掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許失敗的執行緒放棄操作。基於這樣的原理,CAS 操作即使沒有鎖,也可以發現其他執行緒對當前執行緒的干擾,並進行恰當的處理

簡單地說,CAS 需要你額外給出一個期望值,也就是你認為這個變數現在應該是什麼樣子的。如果變數不是你想象的那樣,那說明它已經被別人修改過了。你就重新讀取,再次嘗試修改就好了。

在硬體層面,大部分的現代處理器都已經支援原子化的 CAS 指令。在 JDK 5.0 以後,虛擬機器便可以使用這個指令來實現併發操作和併發資料結構,並且,這種操作在虛擬機器中可以說是無處不在。

4.4.2 無鎖的執行緒安全整數:AtomicInteger

為了讓 Java 程式設計師能夠受益於 CAS 等 CPU 指令,JDK 併發包中有一個 atomic 包,裡面實現了一些直接使用 CAS 操作的執行緒安全的型別

其中,最常用的一個類,應該就是 AtomicInteger。你可以把它看做是一個整數。但是與 Integer 不同,它是可變的,並且是執行緒安全的。對其進行修改等任何操作,都是用 CAS 指令進行的。這裡簡單列舉一下 AtomicInteger 的一些主要方法,對於其他原子類,操作也是非常類似的:

在這裡插入圖片描述

就內部實現上來說,AtomicInteger 中儲存一個核心欄位:

在這裡插入圖片描述

它就代表了 AtomicInteger 的當前實際取值。此外還有一個:

在這裡插入圖片描述

它儲存著 value 欄位在 AtomicInteger 物件中的偏移量。後面你會看到,這個偏移量是實現 AtomicInteger 的關鍵。

AtomicInteger 的使用非常簡單,這裡給出一個示例:

在這裡插入圖片描述

第 6 行的 AtomicInteger.incrementAndGet() 方法會使用 CAS 操作將自己加 1,同時也會返回當前值(這裡忽略了當前值)。如果你執行這段程式碼,你會看到程式輸出了 100000。這說明程式正常執行,沒有錯誤。如果不是執行緒安全,i 的值應該會小於 100000 才對。

使用 AtomicInteger 會比使用鎖具有更好的效能。出於篇幅限制,這裡不再給出 AtomicInteger 和鎖的效能對比的測試程式碼,相信寫一段簡單的小程式碼測試兩者的效能應該不是難事。這裡讓我們關注一下 incrementAndGet() 的內部實現(我們基於 JDK 1.7 分析,JDK 1.8 與 1.7 的實現有所不同)。

在這裡插入圖片描述

其中 get() 方法非常簡單,就是返回內部資料 value。

在這裡插入圖片描述

這裡讓人映像深刻的,應該是 incrementAndGet() 方法的第 2 行 for 迴圈吧!如果你是初次看到這樣的程式碼,可能會覺得很奇怪,為什麼連設定一個值那麼簡單的操作都需要一個死迴圈呢?原因就是:CAS 操作未必是成功的,因此對於不成功的情況,我們就需要進行不斷的嘗試。第 3 行的 get() 取得當前值,接著加 1 後得到新值 next。這裡,我們就得到了 CAS 必需的兩個引數:期望值以及新值。使用 compareAndSet() 方法將新值 next 寫入,成功的條件是在寫入的時刻,當前的值應該要等於剛剛取得的 current。如果不是這樣,就說明 AtomicInteger 的值在第 3 行到第 5 行程式碼之間,又被其他執行緒修改過了。當前執行緒看到的狀態就是一個過期狀態。因此,compareAndSet 返回失敗,需要進行下一次重試,直到成功。

以上就是 CAS 操作的基本思想。在後面我們會看到,無論程式多麼複雜,其基本原理總是不變的。

和 AtomicInteger 類似的類還有 AtomicLong 用來代表 long 型,AtomicBoolean 表示 boolean 型,AtomicReference 表示物件引用。

4.4.3 Java 中的指標:Unsafe 類

如果你對技術有著不折不撓的追求,應該還會特別在意 incrementAndGet() 方法中 compareAndSet() 的實現。現在,就讓我們更進一步看一下它吧!

在這裡插入圖片描述

在這裡,我們看到一個特殊的變數 unsafe,它是 sun.misc.Unsafe 型別。從名字看,這個類應該是封裝了一些不安全的操作。那什麼操作是不安全的呢?學習過 C 或者 C++ 的話,大家應該知道,指標是不安全的,這也是在 Java 中把指標去除的重要原因。如果指標指錯了位置,或者計算指標偏移量時出錯,結果可能是災難性的,你很有可能會覆蓋別人的記憶體,導致系統崩潰。

而這裡的 Unsafe 就是封裝了一些類似指標的操作。compareAndSwapInt() 方法是一個 navtive 方法,它的幾個引數含義如下:

在這裡插入圖片描述

第一個引數 o 為給定的物件,offset 為物件內的偏移量(其實就是一個欄位到物件頭部的偏移量,通過這個偏移量可以快速定位欄位),expected 表示期望值,x 表示要設定的值。如果指定的欄位的值等於 expected,那麼就會把它設定為 x。

不難看出,compareAndSwapInt() 方法的內部,必然是使用 CAS 原子指令來完成的。此外,Unsafe 類還提供了一些方法,主要有以下幾個(以 Int 操作為例,其他資料型別是類似的):

在這裡插入圖片描述

如果大家還記得 “3.3.4 深度剖析 ConcurrentLinkedQueue” 一節中描述的 ConcurrentLinkedQueue 實現,應該對 ConcurrentLinkedQueue 中的 Node 還有些印象。Node 的一些 CAS 操作也都是使用 Unsafe 類來實現的。大家可以回顧一下,以加深對 Unsafe 類的印象。

這裡就可以看到,雖然 Java 拋棄了指標。但是在關鍵時刻,類似指標的技術還是必不可少的。這裡底層的 Unsafe 實現就是最好的例子。但是很不幸,JDK 的開發人員並不希望大家使用這個類。獲得 Unsafe 例項的方法是調動其工廠方法 getUnsafe() 。但是,它的實現卻是這樣:

在這裡插入圖片描述

注意加粗部分的程式碼,它會檢查呼叫 getUnsafe() 函式的類,如果這個類的 ClassLoader 不為 null,就直接丟擲異常,拒絕工作。因此,這也使得我們自己的應用程式無法直接使用 Unsafe 類。它是一個 JDK 內部使用的專屬類。

注意: 根據 Java 類載入器的工作原理,應用程式的類由 App Loader 載入。而系統核心類,如 rt.jar 中的類由 Bootstrap 類載入器載入。Bootstrap 載入器沒有 Java 物件的物件,因此試圖獲得這個類載入器會返回 null。所以,當一個類的類載入器為 null 時,說明它是由 Bootstrap 載入的,而這個類也極有可能是 rt.jar 中的類。

4.4.4 無鎖的物件引用:AtomicReference

AtomicReference 和 AtomicInteger 非常類似,不同之處就在於 AtomicInteger 是對整數的封裝,而 AtomicReference 則對應普通的物件引用。也就是它可以保證你在修改物件引用時的執行緒安全性。在介紹 AtomicReference 的同時,我希望同時提出一個有關原子操作的邏輯上的不足。

之前我們說過,執行緒判斷被修改物件是否可以正確寫入的條件是物件的當前值和期望值是否一致。這個邏輯從一般意義上來說是正確的。但有可能出現一個小小的例外,就是當你獲得物件當前資料後,在準備修改為新值前,物件的值被其他執行緒連續修改了兩次,而經過這兩次修改後,物件的值又恢復為舊值。這樣,當前執行緒就無法正確判斷這個物件究竟是否被修改過。如圖 4.2 所示,顯示了這種情況。

在這裡插入圖片描述

一般來說,發生這種情況的概率很小。而且即使發生了,可能也不是什麼大問題。比如,我們只是簡單地要做一個數值加法,即使在我取得期望值後,這個數字被不斷的修改,只要它最終改回了我的期望值,我的加法計算就不會出錯。也就是說,當你修改的物件沒有過程的狀態資訊,所有的資訊都只儲存於物件的數值本身。

但是,在現實中,還可能存在另外一種場景,就是我們是否能修改物件的值,不僅取決於當前值,還和物件的過程變化有關,這時,AtomicReference 就無能為力了

打一個比方,如果有一家蛋糕店,為了挽留客戶,決定為貴賓卡里餘額小於 20 元的客戶一次性贈送 20 元,刺激消費者充值和消費。但條件是,每一位客戶只能被贈送一次。

現在,我們就來模擬這個場景,為了演示 AtomicReference,我在這裡使用 AtomicReference 實現這個功能。首先,我們模擬使用者賬戶餘額。

定義使用者賬戶餘額:

在這裡插入圖片描述

接著,我們需要若干個後臺執行緒,它們不斷掃描資料,併為滿足條件的客戶充值。

在這裡插入圖片描述

上述程式碼第 8 行,判斷使用者餘額並給予贈送金額。如果已經被其他使用者處理,那麼當前執行緒就會失敗。因此,可以確保使用者只會被充值一次。

此時,如果很不幸,使用者正好正在進行消費,就在贈予金額到賬的同時,他進行了一次消費,使得總金額又小於 20 元,並且正好累計消費了 20 元。使得消費、贈予後的金額等於消費前、贈予前的金額。這時,後臺的贈予程式就會誤以為這個賬戶還沒有贈予,所以,存在被多次贈予的可能。下面模擬了這個消費執行緒:

在這裡插入圖片描述

上述程式碼中,消費者只要貴賓卡里的錢大於 10 元,就會立即進行一次 10 元的消費。執行上述程式,得到的輸出如下:

在這裡插入圖片描述

從這一段輸出中,可以看到,這個賬戶被先後反覆多次充值。其原因正是因為賬戶餘額被反覆修改,修改後的值等於原有的數值,使得 CAS 操作無法正確判斷當前資料狀態。

雖然說這種情況出現的概率不大,但是依然是有可能出現的。因此,當業務上確實可能出現這種情況時,我們也必須多加防範。體貼的 JDK 也已經為我們考慮到了這種情況,使用 AtomicStampedReference 就可以很好地解決這個問題

4.4.5 帶有時間戳的物件引用:AtomicStampedReference

AtomicReference 無法解決上述問題的根本因為是物件在修改過程中,丟失了狀態資訊。物件值本身與狀態被畫上了等號。因此,我們只要能夠記錄物件在修改過程中的狀態值,就可以很好地解決物件被反覆修改導致執行緒無法正確判斷物件狀態的問題。

AtomicStampedReference 正是這麼做的。它內部不僅維護了物件值,還維護了一個時間戳(我這裡把它稱為時間戳,實際上它可以使任何一個整數來表示狀態值)。當 AtomicStampedReference 對應的數值被修改時,除了更新資料本身外,還必須要更新時間戳。當 AtomicStampedReference 設定物件值時,物件值以及時間戳都必須滿足期望值,寫入才會成功。因此,即使物件值被反覆讀寫,寫回原值,只要時間戳發生變化,就能防止不恰當的寫入。

AtomicStampedReference 的幾個 API 在 AtomicReference 的基礎上新增了有關時間戳的資訊:

在這裡插入圖片描述

有了 AtomicStampedReference 這個法寶,我們就再也不用擔心物件被寫壞啦!現在,就讓我們使用 AtomicStampedReference 來修正那個貴賓卡充值的問題:

在這裡插入圖片描述

第 2 行,我們使用 AtomicStampedReference 代替原來的 AtomicReference。第 6 行獲得賬戶的時間戳,後續的贈予操作以這個時間戳為依據。如果贈予成功(第 13 行),則修改時間戳,使得系統不可能發生二次贈予的情況。消費執行緒也是類似,每次操作,都使得時間戳加 1(第 36 行),使之不可能重複。

執行上述程式碼,可以得到以下輸出:

在這裡插入圖片描述

可以看到,賬戶只被贈予了一次。

4.4.6 陣列也能無鎖:AtomicIntegerArray

除了提供基本資料型別外,JDK 還為我們準備了陣列等複合結構。當前可用的原子陣列有:AtomicIntegerArray、AtomicLongArray 和 AtomicReferenceArray,分別表示整數陣列、long 型陣列和普通的物件陣列。

這裡以 AtomicIntegerArray 為例,展示原子陣列的使用方式。

AtomicIntegerArray 本質上是對 int[] 型別的封裝,使用 Unsafe 類通過 CAS 的方式控制 int[] 在多執行緒下的安全性。它提供了以下幾個核心 API:

在這裡插入圖片描述

下面給出一個簡單的示例,展示 AtomicIntegerArray 的使用:

在這裡插入圖片描述

上述程式碼第 2 行,申明瞭一個內含 10 個元素的陣列。第 3 行定義的執行緒對陣列內 10 個元素進行累加操作,每個元素各加 1000 次。第 11 行,開啟 10 個這樣的執行緒。因此,可以預測,如果執行緒安全,陣列內 10 個元素的值必然都是 10000。反之,如果執行緒不安全,則部分或者全部數值會小於 10000。

程式的輸出結果如下:

在這裡插入圖片描述

這說明 AtomicIntegerArray 確實合理地保證了陣列的執行緒安全性。

4.4.7 讓普通變數也享受原子操作:AtomicIntegerFieldUpdater

有時候,由於初期考慮不周,或者後期的需求變化,一些普通變數可能也會有執行緒安全的需求。如果改動不大,我們可以簡單地修改程式中每一個使用或者讀取這個變數的地方。但顯然,這樣並不符合軟體設計中的一條重要原則——開閉原則也就是系統對功能的增加應該是開放的,而對修改應該是相對保守的。而且,如果系統裡使用到這個變數的地方特別多,一個一個修改也是一件令人厭煩的事情(況且很多使用場景下可能只是只讀的,並無執行緒安全的強烈要求,完全可以保持原樣)。

如果你有這種困擾,在這裡根本不需要擔心,因為在原子包裡還有一個實用的工具類 AtomicIntegerFieldUpdater。它可以讓你在不改動(或者極少改動)原有程式碼的基礎上,讓普通的變數也享受 CAS 操作帶來的執行緒安全性,這樣你可以修改極少的程式碼,來獲得執行緒安全的保證。這聽起來是不是讓人很激動呢?

根據資料型別不同,這個 Updater 有三種,分別是:

  • AtomicIntegerFieldUpdater
  • AtomicLong-FieldUpdater
  • AtomicReferenceFieldUpdater

顧名思義,它們分別可以對 int、long 和普通物件進行 CAS 修改。

現在來思考這麼一個場景。假設某地要進行一次選舉。現在模擬這個投票場景,如果選民投了候選人一票,就記為 1,否則記為 0。最終的選票顯然就是所有資料的簡單求和。

在這裡插入圖片描述

上述程式碼模擬了這個計票場景,候選人的得票數量記錄在 Candidate.score 中。注意,它是一個普通的 volatile 變數。而 volatile 變數並不是執行緒安全的。第 6~7 行定義了 AtomicIntegerFieldUpdater 例項,用來對 Candidate.score 進行寫入。而後續的 allScore 我們用來檢查 AtomicIntegerFieldUpdater 的正確性。如果 AtomicIntegerFieldUpdater 真的保證了執行緒安全,那麼最終 Candidate.score 和 allScore 的值必然是相等的。否則,就說明 AtomicIntegerFieldUpdater 根本沒有確保執行緒安全的寫入。第 12~21 行模擬了計票過程,這裡假設有大約 60% 的人投贊成票,並且投票是隨機進行的。第 17 行使用 Updater 修改 Candidate.score(這裡應該是執行緒安全的),第 18 行使用 AtomicInteger 計數,作為參考基準。

大家如果執行這段程式,不難發現,最終的 Candidate.score 總是和 allScore 絕對相等。這說明 AtomicIntegerFieldUpdater 很好地保證了 Candidate.score 的執行緒安全。

雖然 AtomicIntegerFieldUpdater 很好用,但是還是有幾個注意事項

  • 第一,Updater 只能修改它可見範圍內的變數。因為 Updater 使用反射得到這個變數。如果變數不可見,就會出錯。比如如果 score 申明為 private,就是不可行的。
  • 第二,為了確保變數被正確的讀取,它必須是 volatile 型別的。如果我們原有程式碼中未申明這個型別,那麼簡單地申明一下就行,這不會引起什麼問題。
  • 第三,由於 CAS 操作會通過物件例項中的偏移量直接進行賦值,因此,它不支援 static 欄位(Unsafe.objectFieldOffset() 不支援靜態變數)。

好了,通過 AtomicIntegerFieldUpdater,是不是讓我們可以更加隨心所欲地對系統關鍵資料進行執行緒安全的保護呢?

4.4.8 挑戰無鎖演算法:無鎖的 Vector 實現

4.4.9 讓執行緒之間互相幫助:細看 SynchronousQueue 的實現

4.5 有關死鎖的問題

在學習了無鎖之後,讓我們重新回到鎖的世界吧!在眾多的應用程式中,使用鎖的情況一般要多於無鎖。因為對於應用來說,如果業務邏輯很複雜,會極大增加無鎖的程式設計難度。但如果使用鎖,我們就不得不對一個新的問題引起重視——那就是死鎖。

那什麼是死鎖呢?通俗的說,死鎖就是兩個或者多個執行緒,相互佔用對方需要的資源,而都不進行釋放,導致彼此之間都相互等待對方釋放資源,產生了無限制等待的現象。死鎖一旦發生,如果沒有外力介入,這種等待將永遠存在,從而對程式產生嚴重的影響

用來描述死鎖問題的一個有名的場景是 “哲學家就餐” 問題。哲學家就餐問題可以這樣表述,假設有五位哲學家圍坐在一張圓形餐桌旁,做以下兩件事情之一:吃飯,或者思考。吃東西的時候,他們就停止思考,思考的時候也停止吃東西。餐桌中間有一大碗義大利麵,每兩個哲學家之間有一隻餐叉。因為用一隻餐叉很難吃到義大利麵,所以假設哲學家必須用兩隻餐叉吃東西。他們只能使用自己左右手邊的那兩隻餐叉。哲學家就餐問題有時也用米飯和筷子而不是義大利麵和餐叉來描述,因為很明顯,吃米飯必須用兩根筷子。

哲學家從來不交談,這就很危險,可能產生死鎖,每個哲學家都拿著左手的餐叉,永遠都在等右邊的餐叉(或者相反)。如圖 4.3 所示,顯示了這種情況。

在這裡插入圖片描述

最簡單的情況就是隻有兩個哲學家,假設是 A 和 B。桌面也只有兩個叉子。A 左手拿著其中一隻叉子,B 也一樣。這樣他們的右手等在等待對方的叉子,並且這種等待會一直持續,從而導致程式永遠無法正常執行。

下面讓我們用一個簡單的例子來模擬這個過程:

在這裡插入圖片描述

上述程式碼模擬了兩個哲學家互相等待對方的叉子。哲學家 A 先佔用叉子 1,哲學家 B 佔用叉子 2,接著他們就相互等待,都沒有辦法同時獲得兩個叉子用餐。

如果在實際環境中,遇到了這種情況,通常的表現就是相關的程式不再工作,並且 CPU 佔用率為 0(因為死鎖的執行緒不佔用 CPU),不過這種表面現象只能用來猜測問題。如果想要確認問題,還需要使用 JDK 提供的一套專業工具。

首先,我們可以使用 jps 命令得到 java 程式的程式 ID,接著使用 jstack 命令得到執行緒的執行緒堆疊

在這裡插入圖片描述

上面顯示了 jstack 的部分輸出。可以看到,哲學家 A 和哲學家 B 兩個執行緒發生了死鎖。並且在最後,可以看到兩者相互等待的鎖的 ID。同時,死鎖的兩個執行緒均處於 BLOCK 狀態。

如果想避免死鎖,除了使用無鎖的函式外,另外一種有效的做法是使用第三章節介紹的重入鎖,通過重入鎖的中斷或者限時等待可以有效規避死鎖帶來的問題。大家可以再回顧一下相關內容。

相關文章