《實戰 Java 高併發程式設計》筆記——第2章 Java 並行程式基礎(二)

bm1998發表於2020-12-22

宣告:

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

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

2.3 volatile 與 Java 記憶體模型(JMM)

之前已經簡單介紹了 Java 記憶體模型(JMM),Java 記憶體模型都是圍繞著原子性、有序性和可見性展開的。大家可以先回顧一下上一章中的相關內容。為了在適當的場合,確保執行緒間的有序性、可見性和原子性。Java 使用了一些特殊的操作或者關鍵字來申明、告訴虛擬機器,在這個地方,要尤其注意,不能隨意變動優化目標指令。關鍵字 volatile 就是其中之一。

如果你查閱一下英文字典,有關 volatile 的解釋,你會得到最常用的解釋是 “易變的,不穩定的”。這也正是使用 volatile 關鍵字的語義。

當你用 volatile 去申明一個變數時,就等於告訴了虛擬機器,這個變數極有可能會被某些程式或者執行緒修改。為了確保這個變數被修改後,應用程式範圍內的所有執行緒都能夠 “看到” 這個改動,虛擬機器就必須採用一些特殊的手段,保證這個變數的可見性等特點

比如,根據編譯器的優化規則,如果不使用 volatile 申明變數,那麼這個變數被修改後,其他執行緒可能並不會被通知到,甚至在別的執行緒中,看到變數的修改順序都會是反的。但一旦使用 volatile,虛擬機器就會特別小心地處理這種情況。

大家應該對上一章中介紹原子性時,給出的 MultiThreadLong 案例還記憶猶新吧!我想,沒有人願意就這麼把資料 “寫壞”。那這種情況,應該怎麼處理才能保證每次寫進去的資料不壞呢?最簡單的一種方法就是加入 volatile 申明,告訴編譯器,這個 long 型資料,你要格外小心,因為他會不斷地被修改。

下面的程式碼片段顯示了 volatile 的使用,限於篇幅,這裡不再給出完整程式碼:

在這裡插入圖片描述

從這個案例中,我們可以看到,volatile 對於保證操作的原子性是有非常大的幫助的。但是需要注意的是,volatile 並不能代替鎖,它也無法保證一些複合操作的原子性。比如下面的例子,通過 volatile 是無法保證 i++ 的原子性操作的:

在這裡插入圖片描述

執行上述程式碼,如果第 6 行 i++ 是原子性的,那麼最終的值應該是 100000(10 個執行緒各累加 10000 次)。但實際上,上述程式碼的輸出總是會小於 100000。

此外,volatile 也能保證資料的可見性和有序性。下面再來看一個簡單的例子:

在這裡插入圖片描述

上述程式碼中,ReaderThread 執行緒只有在資料準備好時(ready 為 true),才會列印 number 的值。它通過 ready 變數判斷是否應該列印。在主執行緒中,開啟 ReaderThread 後,就為 number 和 ready 賦值,並期望 ReaderThread 能夠看到這些變化並將資料輸出。

在虛擬機器的 Client 模式下,由於 JIT 並沒有做足夠的優化,在主執行緒修改 ready 變數的狀態後,ReaderThread 可以發現這個改動,並退出程式。但是在 Server 模式下,由於系統優化的結果,ReaderThread 執行緒無法 “看到” 主執行緒中的修改,導致 ReaderThread 永遠無法退出(因為程式碼第 7 行判斷永遠不會成立),這顯然不是我們想看到的結果。這個問題就是一個典型的可見性問題

注意: 可以使用 Java 虛擬機器引數 -server 切換到 Server 模式。

和原子性問題一樣,我們只要簡單地使用 volatile 來申明 ready 變數,告訴 Java 虛擬機器,這個變數可能會在不同的執行緒中修改。這樣,就可以順利解決這個問題了

2.4 分門別類的管理:執行緒組

在一個系統中,如果執行緒數量很多,而且功能分配比較明確,就可以將相同功能的執行緒放置在一個執行緒組裡。打個比方,如果你有一個蘋果,你就可以把它拿在手裡,但是如果你有十個蘋果,你就最好還有一個籃子,否則不方便攜帶。對於多執行緒來說,也是這個道理。想要輕鬆處理幾十個甚至上百個執行緒,最好還是將它們都裝進對應的籃子裡。

執行緒組的使用非常簡單,如下:

在這裡插入圖片描述

上述程式碼第 3 行,建立一個名為 “PrintGroup” 的執行緒組,並將 T1 和 T2 兩個執行緒加入這個組中。第 8、9 兩行,展示了執行緒組的兩個重要的功能,activeCount() 可以獲得活動執行緒的總數,但由於執行緒是動態的,因此這個值只是一個估計值,無法確定精確,list() 方法可以列印這個執行緒組中所有的執行緒資訊,對除錯有一定幫助。程式碼中第 4、5 兩行建立了兩個執行緒,使用 Thread 的建構函式,指定執行緒所屬的執行緒組,將執行緒和執行緒組關聯起來。

執行緒組還有一個值得注意的方法 stop() ,它會停止執行緒組中所有的執行緒。這看起來是一個很方便的功能,但是它會遇到和 Thread.stop() 相同的問題,因此使用時也需要格外謹慎。

此外,對於編碼習慣,我還想再多說幾句。強烈建議大家在建立執行緒和執行緒組的時候,給它們取一個好聽的名字。對於計算機來說,也許名字並不重要,但是在系統出現問題時,你很有可能會匯出系統內所有執行緒,你拿到的如果是一連串的 Thread-0、Thread-1、Thread-2,我想你一定會抓狂。但取而代之,你看到的如果是類似 HttpHandler、FTPService 這樣的名字,會讓你心情倍爽。

2.5 駐守後臺:守護執行緒(Daemon)

守護執行緒是一種特殊的執行緒,就和它的名字一樣,它是系統的守護者,在後臺默默地完成一些系統性的服務,比如垃圾回收執行緒、JIT 執行緒就可以理解為守護執行緒。與之相對應的是使用者執行緒,使用者執行緒可以認為是系統的工作執行緒,它會完成這個程式應該要完成的業務操作。如果使用者執行緒全部結束,這也意味著這個程式實際上無事可做了。守護執行緒要守護的物件已經不存在了,那麼整個應用程式就自然應該結束。因此,當一個 Java 應用內,只有守護執行緒時,Java 虛擬機器就會自然退出。

下面簡單地看一下守護執行緒的使用:

在這裡插入圖片描述

上述程式碼第 16 行,將執行緒 t 設定為守護執行緒。這裡注意,設定守護執行緒必須線上程 start() 之前設定,否則你會得到一個類似以下的異常,告訴你守護執行緒設定失敗。但是你的程式和執行緒依然可以正常執行。只是被當做使用者執行緒而已。因此,如果不小心忽略了下面的異常資訊,你就很可能察覺不到這個錯誤。那你就會詫異為什麼程式永遠停不下來了呢?

在這裡插入圖片描述

在這個例子中,由於 t 被設定為守護執行緒,系統中只有主執行緒 main 為使用者執行緒,因此在 main 執行緒休眠 2 秒後退出時,整個程式也隨之結束。但如果不把執行緒 t 設定為守護執行緒,main 執行緒結束後,t 執行緒還會不停地列印,永遠不會結束。

2.6 先乾重要的事:執行緒優先順序

Java 中的執行緒可以有自己的優先順序。優先順序高的執行緒在競爭資源時會更有優勢,更可能搶佔資源,當然,這只是一個概率問題。如果運氣不好,高優先順序執行緒可能也會搶佔失敗。由於執行緒的優先順序排程和底層作業系統有密切的關係,在各個平臺上表現不一,並且這種優先順序產生的後果也可能不容易預測,無法精準控制,比如一個低優先順序的執行緒可能一直搶佔不到資源,從而始終無法執行,而產生飢餓(雖然優先順序低,但是也不能餓死它呀)。因此,在要求嚴格的場合,還是需要自己在應用層解決執行緒排程問題。

在 Java 中,使用 1 到 10 表示執行緒優先順序。一般可以使用內建的三個靜態標量表示:

在這裡插入圖片描述

數字越大則優先順序越高,但有效範圍在 1 到 10 之間。下面的程式碼展示了優先順序的作用。高優先順序的執行緒傾向於更快地完成。

在這裡插入圖片描述

上述程式碼定義兩個執行緒,分別為 HightPriority 設定為高優先順序,LowPriority 為低優先順序。讓它們完成相同的工作,也就是把 count 從 0 加到 10000000。完成後,列印資訊給一個提示,這樣我們就知道誰先完成工作了。這裡要注意,在對 count 累加前,我們使用 synchronized 產生了一次資源競爭。目的是使得優先順序的差異表現得更為明顯。

大家可以嘗試執行上述程式碼,可以看到,高優先順序的執行緒在大部分情況下,都會首先完成任務(就這段程式碼而言,試執行多次,HightPriority 總是比 LowPriority 快,但這不能保證在所有情況下,一定都是這樣)。

2.7 執行緒安全的概念與 synchronized

並行程式開發的一大關注重點就是執行緒安全。一般來說,程式並行化是為了獲得更高的執行效率,但前提是,高效率不能以犧牲正確性為代價。如果程式並行化後,連基本的執行結果的正確性都無法保證,那麼並行程式本身也就沒有任何意義了。因此,執行緒安全就是並行程式的根本和根基。大家還記得那個多執行緒讀寫 long 型資料的案例吧!這就是一個典型的反例。但在使用 volatile 關鍵字後,這種錯誤的情況有所改善。但是,volatile 並不能真正的保證執行緒安全。它只能確保一個執行緒修改了資料後,其他執行緒能夠看到這個改動。但當兩個執行緒同時修改某一個資料時,卻依然會產生衝突。

下面的程式碼演示了一個計數器,兩個執行緒同時對 i 進行累加操作,各執行 10000000 次。我們希望的執行結果當然是最終 i 的值可以達到 20000000,但事實並非總是如此。如果你多執行幾次下述程式碼,你會發現,在很多時候,i 的最終值會小於 20000000。這就是因為兩個執行緒同時對 i 進行寫入時,其中一個執行緒的結果會覆蓋另外一個(雖然這個時候 i 被宣告為 volatile 變數)。

在這裡插入圖片描述

圖 2.8 展示了這種可能的衝突,如果在程式碼中發生了類似的情況,這就是多執行緒不安全的惡果。執行緒 1 和執行緒 2 同時讀取 i 為 0,並各自計算得到 i=1,並先後寫入這個結果,因此,雖然 i++ 被執行了 2 次,但是實際 i 的值只增加了 1。

在這裡插入圖片描述

要從根本上解決這個問題,我們就必須保證多個執行緒在對 i 進行操作時完全同步。也就是說,當執行緒 A 在寫入時,執行緒 B 不僅不能寫,同時也不能讀。因為線上程 A 寫完之前,執行緒 B 讀取的一定是一個過期資料。Java 中,提供了一個重要的關鍵字 synchronized 來實現這個功能。

關鍵字 synchronized 的作用是實現執行緒間的同步。它的工作是對同步的程式碼加鎖,使得每一次,只能有一個執行緒進入同步塊,從而保證執行緒間的安全性(也就是說在上述程式碼的第 5 行,每次應該只有一個執行緒可以執行)。

關鍵字 synchronized 可以有多種用法。這裡做一個簡單的整理

  • 指定加鎖物件:對給定物件加鎖,進入同步程式碼前要獲得給定物件的鎖。
  • 直接作用於例項方法:相當於對當前例項加鎖,進入同步程式碼前要獲得當前例項的鎖。
  • 直接作用於靜態方法:相當於對當前類加鎖,進入同步程式碼前要獲得當前類的鎖。

下述程式碼,將 synchronized 作用於一個給定物件 instance,因此,每次當執行緒進入被 synchronized 包裹的程式碼段,就都會要求請求 instance 例項的鎖。如果當前有其他執行緒正持有這把鎖,那麼新到的執行緒就必須等待。這樣,就保證了每次只能有一個執行緒執行 i++ 操作。

在這裡插入圖片描述

當然,上述程式碼也可以寫成如下形式,兩者是等價的:

在這裡插入圖片描述

上述程式碼中,synchronized 關鍵字作用於一個例項方法。這就是說在進入 increase() 方法前,執行緒必須獲得當前物件例項的鎖。在本例中就是 instance 物件。在這裡,我不厭其煩地再次給出 main 函式的實現,是希望強調第 14、15 行程式碼,也就是 Thread 的建立方式。這裡使用 Runnable 介面建立兩個執行緒,並且這兩個執行緒都指向同一個 Runnable 介面例項(instance 物件),這樣才能保證兩個執行緒在工作時,能夠關注到同一個物件鎖上去,從而保證執行緒安全

一種錯誤的同步方式如下:

在這裡插入圖片描述

上述程式碼就犯了一個嚴重的錯誤。雖然在第 3 行的 increase() 方法中,申明這是一個同步方法。但很不幸的是,執行這段程式碼的兩個執行緒都指向了不同的 Runnable 例項。由第 13、14 行可以看到,這兩個執行緒的 Runnable 例項並不是同一個物件。因此,執行緒 t1 會在進入同步方法前加鎖自己的 Runnable 例項,而執行緒 t2 也關注於自己的物件鎖。換言之,這兩個執行緒使用的是兩把不同的鎖。因此,執行緒安全是無法保證的。

但我們只要簡單地修改上述程式碼,就能使其正確執行。那就是使用 synchronized 的第三種用法,將其作用於靜態方法。將 increase() 方法修改如下:

在這裡插入圖片描述

這樣,即使兩個執行緒指向不同的 Runnable 物件,但由於方法塊需要請求的是當前類的鎖,而非當前例項,因此,執行緒間還是可以正確同步。

除了用於執行緒同步、確保執行緒安全外,synchronized 還可以保證執行緒間的可見性和有序性。從可見性的角度上講,synchronized 可以完全替代 volatile 的功能,只是使用上沒有那麼方便。就有序性而言,由於 synchronized 限制每次只有一個執行緒可以訪問同步塊,因此,無論同步塊內的程式碼如何被亂序執行,只要保證序列語義一致,那麼執行結果總是一樣的。而其他訪問執行緒,又必須在獲得鎖後方能進入程式碼塊讀取資料,因此,它們看到的最終結果並不取決於程式碼的執行過程,從而有序性問題自然得到了解決(換言之,被 synchronized 限制的多個執行緒是序列執行的)。

2.8 程式中的幽靈:隱蔽的錯誤

作為一名軟體開發人員,修復程式 BUG 應該說是基本的日常工作之一。作為 Java 程式設計師,也許你經常會被丟擲的一大堆的異常堆疊所困擾,因為這可能預示著你又有工作可做了。但我這裡想說的是,如果程式出錯,你看到了異常堆疊,那你應該感到格外的高興,因為這也意味著你極有可能可以在兩分鐘內修復這個問題(當然,並不是所有的異常都是錯誤)。最可怕的情況是:系統沒有任何異常表現,沒有日誌,也沒有堆疊,但是卻給出了一個錯誤的執行結果,這種情況下,才真會讓你抓狂。

2.8.1 無提示的錯誤案例

我在這裡想給出一個系統執行錯誤,卻沒有任何提示的案例。讓大家體會一下這種情況的可怕之處。我相信,在任何一個業務系統中,求平均值,應該是一種極其常見的操作。這裡就以求兩個整數的平均值為例。請看下面程式碼:

在這裡插入圖片描述

上述程式碼中,加粗部分試圖計算 v1 和 v2 的均值。乍看之下,沒有什麼問題。目測 v1 和 v2 的當前值,估計兩者的平均值大約在 12 億左右。但如果你執行程式碼,卻會得到以下輸出:

在這裡插入圖片描述

乍看之下,你一定會覺得非常吃驚,為什麼均值竟然反而是一個負數。但只要你有一點研發精神,就會馬上有所覺悟。這是一個典型的溢位問題!顯然,v1+v2 的結果就已經導致了 int 的溢位。

把這個問題單獨拿出來研究,也許你不會有特別的感觸,但是,一旦這個問題發生在一個複雜系統的內部。由於複雜的業務邏輯,很可能掩蓋這個看起來微不足道的問題,再加上程式自始至終沒有任何日誌或異常,再加上你運氣不是太好的話,這類問題不讓你耗上幾個通宵,恐怕也是難有眉目。

所以,我們自然會恐懼這些問題,我們也希望在程式異常時,能夠得到一個異常或者相關的日誌。但是,非常不幸的是,錯誤地使用並行,會非常容易產生這類問題。它們難覓蹤影,就如同幽靈一般。

2.8.2 併發下的 ArrayList

我們都知道,ArrayList 是一個執行緒不安全的容器。如果在多執行緒中使用 ArrayList,可能會導致程式出錯。那究竟可能引起哪些問題呢?試看下面的程式碼:

在這裡插入圖片描述

上述程式碼中,t1 和 t2 兩個執行緒同時向一個 ArrayList 容器中新增容器。他們各新增 1000000 個元素,因此我們期望最後可以有 2000000 個元素在 ArrayList 中。但如果你執行這段程式碼,你可能會得到三種結果

第一,程式正常結束,ArrayList 的最終大小確實 2000000。這說明即使並行程式有問題,也未必會每次都表現出來。

第二,程式丟擲異常

在這裡插入圖片描述

這是因為 ArrayList 在擴容過程中,內部一致性被破壞,但由於沒有鎖的保護,另外一個執行緒訪問到了不一致的內部狀態,導致出現越界問題。

第三,出現了一個非常隱蔽的錯誤,比如列印如下值作為 ArrayList 的大小:

在這裡插入圖片描述

顯然,這是由於多執行緒訪問衝突,使得儲存容器大小的變數被多執行緒不正常的訪問,同時兩個執行緒也同時對 ArrayList 中的同一個位置進行賦值導致的。如果出現這種問題,那麼很不幸,你就得到了一個沒有錯誤提示的錯誤。並且,他們未必是可以復現的。

注意: 改進的方法很簡單,使用執行緒安全的 Vector 代替 ArrayList 即可

2.8.3 併發下詭異的 HashMap

HashMap 同樣不是執行緒安全的。當你使用多執行緒訪問 HashMap 時,也可能會遇到意想不到的錯誤。不過和 ArrayList 不同,HashMap 的問題似乎更加詭異。

在這裡插入圖片描述

上述程式碼使用 t1 和 t2 兩個執行緒同時對 HashMap 進行 put() 操作。如果一切正常,我們期望得到的 map.size() 就是 100000。但實際上,你可能會得到以下三種情況(注意,這裡使用 JDK 7 進行試驗):

第一,程式正常結束,並且結果也是符合預期的。HashMap 的大小為 100000

第二,程式正常結束,但結果不符合預期,而是一個小於 100000 的數字,比如 98868

第三,程式永遠無法結束

對於前兩種可能,和 ArrayList 的情況非常類似,因此,也不必過多解釋。而對於第三種情況,如果是第一次看到,我想大家一定會覺得特別驚訝,因為看似非常正常的程式,怎麼可能就結束不了呢?

注意: 請讀者謹慎嘗試以上程式碼,由於這段程式碼很可能佔用兩個 CPU 核,並使它們的 CPU 佔有率達到 100%。如果 CPU 效能較弱,可能導致當機。請先儲存資料,再進行嘗試。

開啟工作管理員,你們會發現,這段程式碼佔用了極高的 CPU,最有可能的表示是佔用了兩個 CPU 核,並使得這兩個核的 CPU 使用率達到 100%。這非常類似死迴圈的情況。

使用 jstack 工具顯示程式的執行緒資訊,如下所示。其中 jps 可以顯示當前系統中所有的 Java 程式。而 jstack 可以列印給定 Java 程式的內部執行緒及其堆疊。

在這裡插入圖片描述

我們會很容易找到我們的 t1、t2 和 main 執行緒:

在這裡插入圖片描述

可以看到,主執行緒 main 正處於等待狀態,並且這個等待是由於 join() 方法引起的,符合我們的預期。而 t1 和 t2 兩個執行緒都處於 Runnable 狀態,並且當前執行語句為 HashMap.put() 方法。檢視 put() 方法的第 498 行程式碼,如下所示:

在這裡插入圖片描述

可以看到,當前這兩個執行緒正在遍歷 HashMap 的內部資料。當前所處迴圈乍看之下是一個迭代遍歷,就如同遍歷一個連結串列一樣。但在此時此刻,由於多執行緒的衝突,這個連結串列的結構已經遭到了破壞,連結串列成環了!當連結串列成環時,上述的迭代就等同於一個死迴圈,如圖 2.9 所示,展示了最簡單的一種環狀結構,Key1 和 Key2 互為對方的 next 元素。此時,通過 next 引用遍歷,將形成死迴圈。

在這裡插入圖片描述

這個死迴圈的問題,如果一旦發生,著實可以讓你鬱悶一把。本章的參考資料中也給出了一個真實的案例。但這個死迴圈的問題在 JDK 8 中已經不存在了。由於 JDK 8 對 HashMap 的內部實現了做了大規模的調整,因此規避了這個問題。但即使這樣,貿然在多執行緒環境下使用 HashMap 依然會導致內部資料不一致。最簡單的解決方案就是使用 ConcurrentHashMap 代替 HashMap

2.8.4 初學者常見問題:錯誤的加鎖

在進行多執行緒同步時,加鎖是保證執行緒安全的重要手段之一。但加鎖也必須是合理的,在 “執行緒安全的概念與 synchronized” 一節中,我已經給出了一個常見的錯誤加鎖的案例。也就是鎖的不正確使用。在本節中,我將介紹一個更加隱晦的錯誤。

現在,假設我們需要一個計數器,這個計數器會被多個執行緒同時訪問。為了確保資料正確性,我們自然會需要對計數器加鎖,因此,就有了以下程式碼:

在這裡插入圖片描述

上述程式碼的第 7~9 行,為了保證計數器 i 的正確性,每次對 i 自增前,都先獲得 i 的鎖,以此保證 i 是執行緒安全的。從邏輯上看,這似乎並沒有什麼不對,所以,我們就滿懷信心地嘗試執行我們的程式碼。如果一切正常,這段程式碼應該返回 20000000(每個執行緒各累加 10000000 次)。

但結果卻讓我們驚呆了,我得到了一個比 20000000 小很多的數字,比如 15992526。這說明什麼問題呢?一定是這段程式並沒有真正做到執行緒安全!但把鎖加在變數 i 上又有什麼問題呢?似乎加鎖的邏輯也是無懈可擊的。

要解釋這個問題,得從 Integer 說起。在 Java 中,Integer 屬於不變物件。也就是物件一旦被建立,就不可能被修改。也就是說,如果你有一個 Integer 代表 1,那麼它就永遠表示 1,你不可能修改 Integer 的值,使它為 2。那如果你需要 2 怎麼辦呢?也很簡單,新建一個 Integer,並讓它表示 2 即可。

如果我們使用 javap 反編譯這段程式碼的 run() 方法,我們可以看到:

在這裡插入圖片描述

在第 19~22 行(對位元組碼來說,這是偏移量,這裡簡稱為行),實際上使用了 Integer.valueOf() 方法新建了一個新的 Integer 物件,並將它賦值給變數 i。也就是說,i++ 在真實執行時變成了:

在這裡插入圖片描述

進一步檢視 Integer.valueOf(),我們可以看到:

在這裡插入圖片描述

Integer.valueOf() 實際上是一個工廠方法,它會傾向於返回一個代表指定數值的 Integer 例項。因此,i++ 的本質是,建立一個新的 Integer 物件,並將它的引用賦值給 i

如此一來,我們就可以明白問題所在了,由於在多個執行緒間,並不一定能夠看到同一個 i 物件(因為 i 物件一直在變),因此,兩個執行緒每次加鎖可能都加在了不同的物件例項上,從而導致對臨界區程式碼控制出現問題

修正這個問題也很容易,只要將

在這裡插入圖片描述

改為:

在這裡插入圖片描述

相關文章