5實戰java高併發程序設計--並行模式與算法

由於並行程序設計比串行程序設計複雜得多,因此我強烈建議大家瞭解一些常見的設計方法。就好像練習武術,一招一式都是要經過學習的。如果自己胡亂打,效果不見得好。前人會總結一些武術套路,對於初學者來說,不需要發揮自己的想象力,只要按照武術套路出拳就可以了。等練到了一定的高度,就不必拘泥於套路了。這些武術套路和招數,對應到軟件開發中來就是設計模式。在這一章中,我將重點向大家介紹一些有關並行的設計模式及算法。這些都是前人的經驗總結,大家可以在熟知其思想和原理的基礎上,再根據自己的需求進行擴展,可能會達到更好的效果。

5.1 探討單例模式

單例模式是設計模式中使用最爲普遍的模式之一。它是一種對象創建模式,用於產生一個對象的具體實例,它可以確保系統中一個類只產生一個實例。在Java中,這樣的行爲能帶來兩大好處。

(1)對於頻繁使用的對象,可以省略new操作花費的時間,這對於那些重量級對象而言,是非常可觀的一筆系統開銷。

(2)由於new操作的次數減少,因而對系統內存的使用頻率也會降低,這將減輕GC壓力,縮短GC停頓時間。

使用以上方式創建單例有幾點必須特別注意。因爲我們要保證系統中不會有人意外創建多餘的實例,因此,我們把Singleton的構造函數設置爲private。這點非常重要,這就警告所有的開發人員,不能隨便創建這個類的實例,從而有效避免該類被錯誤創建。

首先,instance對象必須是private並且static的。如果不是private,那麼instance的安全性無法得到保證。一個小小的意外就可能使得instance變成null。其次,因爲工廠方法getInstance()必須是static的,因此對應的instance也必須是static。\

這個單例的性能是非常好的,由於getInstance()方法只是簡單地返回instance,並沒有任何鎖操作,因此它在並行程序中會有良好的表現。但是這種方式有一個明顯不足就是Singleton構造函數,或者說Singleton實例在什麼時候創建是不受控制的。對於靜態成員instance,它會在類第一次初始化的時候被創建。這個時刻並不一定是getInstance()方法第一次被調用的時候。

注意,這個單例還包含一個表示狀態的靜態成員STATUS。此時,在任何地方引用這個STATUS都會導致instance實例被創建(任何對Singleton方法或者字段的引用,都會導致類初始化,並創建instance實例,但是類初始化只有一次,因此instance實例永遠只會被創建一次)

但如果你想精確控制instance的創建時間,那麼這種方式就不太友善了。我們需要尋找一種新的方法,一種支持延遲加載的策略,它只會在instance第一次使用時創建對象,具體實現如下:

這個LazySingleton的核心思想是:最初,我們並不需要實例化instance,而當getInstance()方法被第一次調用時,創建單例對象。爲了防止對象被多次創建,我們不得不使用synchronized關鍵字進行方法同步。這種實現的好處是,充分利用了延遲加載,只在真正需要時創建對象。但壞處也很明顯,併發環境下加鎖,競爭激烈的場合對性能可能產生一定的影響。但總體上,這是一個非常易於實現和理解的方法。

此外,還有一種被稱爲雙重檢查模式的方法可以用於創建單例。但我並不打算在這裏介紹它,因爲這是一種非常醜陋、複雜的方法,甚至在低版本的JDK中都不能保證正確性。因此,絕不推薦大家使用。如果大家閱讀到相關文檔,我也強烈建議大家不要在這種方法上花費太多時間。

上面介紹的兩種單例實現可以說是各有千秋。有沒有一種方法可以結合二者的優勢呢?答案是肯定的。

上述代碼實現了一個單例,並且同時擁有前兩種方式的優點。首先getInstance()方法中沒有鎖,這使得在高併發環境下性能優越。其次,只有在getInstance()方法第一次被調用時,StaticSingleton的實例纔會被創建。因爲這種方法巧妙地使用了內部類和類的初始化方式。內部類SingletonHolder被聲明爲private,這使得我們不可能在外部訪問並初始化它。而我們只可能在getInstance()方法內部對SingletonHolder類進行初始化,利用虛擬機的類初始化機制創建單例。


5.2 不變模式

在並行軟件開發過程中,同步操作似乎是必不可少的。當多線程對同一個對象進行讀寫操作時,爲了保證對象數據的一致性和正確性,有必要對對象進行同步,但是同步操作對系統性能有損耗。爲了儘可能地去除這些同步操作,提高並行程序性能可以使用一種不可改變的對象,依靠對象的不變性,可以確保其在沒有同步操作的多線程環境中依然保持內部狀態的一致性和正確性。這就是不變模式

不變模式天生就是多線程友好的,它的核心思想是,一個對象一旦被創建,它的內部狀態將永遠不會發生改變。沒有一個線程可以修改其內部狀態和數據,同時其內部狀態也絕不會自行發生改變。基於這些特性,對不變對象的多線程操作不需要進行同步控制。

同時還需要注意,不變模式和只讀屬性是有一定的區別的。不變模式比只讀屬性具有更強的一致性和不變性。對只讀屬性的對象而言,對象本身不能被其他線程修改,但是對象的自身狀態卻可能自行修改。

比如,一個對象的存活時間(對象創建時間和當前時間的時間差)是隻讀的,任何一個第三方線程都不能修改這個屬性,但是這是一個可變的屬性,因爲隨着時間的推移,存活時間時刻都在發生變化。而不變模式則要求,無論出於什麼原因,對象自創建後,其內部狀態和數據保持絕對的穩定。

因此,不變模式的主要使用場景需要滿足以下兩個條件。

● 當對象創建後,其內部狀態和數據不再發生任何變化。

● 對象需要被共享,被多線程頻繁訪問。

在Java語言中,不變模式的實現很簡單。爲確保對象被創建後,不發生任何改變,並保證不變模式正常工作,只需要注意以下四點即可。

● 去除setter方法及所有修改自身屬性的方法。

● 將所有屬性設置爲私有,並用final標記,確保其不可修改。

● 確保沒有子類可以重載修改它的行爲。

● 有一個可以創建完整對象的構造函數。以下代碼實現了一個不變的產品對象,它擁有序列號、名稱和價格三個屬性

以下代碼實現了一個不變的產品對象,它擁有序列號、名稱和價格三個屬性。

在不變模式的實現中,final關鍵字起到了重要的作用。對屬性的final定義確保所有數據只能在對象被構造時賦值1次。之後,就永遠不發生改變。而對class的final確保了類不會有子類。根據里氏代換原則,子類可以完全替代父類。如果父類是不變的,那麼子類也必須是不變的,但實際上我們無法約束這點,爲了防止子類做出一些意外的行爲,這裏乾脆把子類都禁用了。

在JDK中,不變模式的應用非常廣泛。其中,最爲典型的就是java.lang.String類。此外,所有的元數據類、包裝類都是使用不變模式實現的。主要的不變模式類型如下。

由於基本數據類型和String類型在實際的軟件開發中應用極其廣泛,使用不變模式後,所有實例的方法均不需要進行同步操作,保證了它們在多線程環境下的性能。

注意:不變模式通過迴避問題而不是解決問題的態度來處理多線程併發訪問控制,不變對象是不需要進行同步操作的。由於併發同步會對性能產生不良的影響,因此,在需求允許的情況下,不變模式可以提高系統的併發性能和併發量。


5.3 生產者-消費者模式

生產者-消費者模式是一個經典的多線程設計模式,它爲多線程間的協作提供了良好的解決方案。在生產者-消費者模式中,通常有兩類線程,即若干個生產者線程和若干個消費者線程。生產者線程負責提交用戶請求,消費者線程則負責具體處理生產者提交的任務。生產者和消費者之間則通過共享內存緩衝區進行通信。

圖5.1展示了生產者-消費者模式的基本結構。三個生產者線程將任務提交到共享內存緩衝區,消費者線程並不直接與生產者線程通信,而是在共享內存緩衝區中獲取任務,並進行處理

注意:生產者-消費者模式中的內存緩衝區的主要功能是數據在多線程間的共享,此外,通過該緩衝區,可以緩解生產者和消費者間的性能差。

生產者-消費者模式的核心組件是共享內存緩衝區,它作爲生產者和消費者間的通信橋樑,避免了生產者和消費者直接通信,從而將生產者和消費者進行解耦。生產者不需要知道消費者的存在,消費者也不需要知道生產者的存在。

其中,BlockigQueue充當了共享內存緩衝區,用於維護任務或數據隊列(PCData對象)。我強烈建議大家先回顧一下第3章有關BlockingQueue的相關知識,它對於理解整個生產者和消費者結構有重要的幫助。PCData對象表示一個生產任務,或者相關任務的數據。生產者對象和消費者對象均引用同一個BlockigQueue實例。生產者負責創建PCData對象,並將它加入BlockigQueue隊列中,消費者則從BlockigQueue隊列中獲取PCData對象。

注意:生產者-消費者模式很好地對生產者線程和消費者線程進行解耦,優化了系統整體結構。同時,由於緩衝區的作用,允許生產者線程和消費者線程存在執行上的性能差異,從一定程度上緩解了性能瓶頸對系統性能的影響。


5.4 高性能的生產者-消費者模式:無鎖的實現

ConcurrentLinkedQueue是一個高性能的隊列,但是BlockingQueue隊列只是爲了方便數據共享。

而ConcurrentLinkedQueue隊列的祕訣就在於大量使用了無鎖的CAS操作。同理,如果我們使用CAS來實現生產者-消費者模式,也同樣可以獲得可觀的性能提升。不過正如大家所見,使用CAS進行編程是非常困難的,但有一個好消息是,目前有一個現成的Disruptor框架,它已經幫助我們實現了這一個功能。

5.4.1 無鎖的緩存框架:Disruptor

Disruptor框架是由LMAX公司開發的一款高效的無鎖內存隊列。它使用無鎖的方式實現了一個環形隊列(RingBuffer),非常適合實現生產者-消費者模式,比如事件和消息的發佈。Disruptor框架別出心裁地使用了環形隊列來代替普通線形隊列,這個環形隊列內部實現爲一個普通的數組。

生產者和消費者正常工作。根據Disruptor框架的官方報告,Disruptor框架的性能要比BlockingQueue隊列至少高一個數量級以上。如此誘人的性能,當然值得我們嘗試!

5.4.3 提高消費者的響應時間:選擇合適的策略

當有新數據在Disruptor框架的環形緩衝區中產生時,消費者如何知道這些新產生的數據呢?或者說,消費者如何監控緩衝區中的信息呢?爲此,Disruptor框架提供了幾種策略,這些策略由WaitStrategy接口進行封裝,主要有以下幾種實現

● BlockingWaitStrategy:這是默認的策略。使用BlockingWaitStrategy和使用BlockingQueue是非常類似的,它們都使用鎖和條件(Condition)進行數據的監控和線程的喚醒。因爲涉及線程的切換,BlockingWaitStrategy策略最節省CPU,但是在高併發下它是性能表現最糟糕的一種等待策略。

● SleepingWaitStrategy:這個策略對CPU的消耗與BlockingWaitStrategy類似。它會在循環中不斷等待數據。它會先進行自旋等待,如果不成功,則使用Thread.yield()方法方法讓出CPU,並最終使用LockSupport.parkNanos(1)進行線程休眠,以確保不佔用太多的CPU數據。因此,這個策略對於數據處理可能會產生比較高的平均延時。它比較適合對延時要求不是特別高的場合,好處是它對生產者線程的影響最小。典型的應用場景是異步日誌。

● YieldingWaitStrategy:這個策略用於低延時的場合。消費者線程會不斷循環監控緩衝區的變化,在循環內部,它會使用Thread.yield()方法讓出CPU給別的線程執行時間。如果你需要一個高性能的系統,並且對延時有較爲嚴格的要求,則可以考慮這種策略。使用這種策略時,相當於消費者線程變成了一個內部執行了Thread.yield()方法的死循環。因此,你最好有多於消費者線程數量的邏輯CPU數量(這裏的邏輯CPU指的是“雙核四線程”中的那個四線程,否則,整個應用程序恐怕都會受到影響)。

● BusySpinWaitStrategy:這個是最瘋狂的等待策略了。它就是一個死循環!消費者線程會盡最大努力瘋狂監控緩衝區的變化。因此,它會吃掉所有的CPU資源。只有對延遲非常苛刻的場合可以考慮使用它(或者說,你的系統真的非常繁忙)。因爲使用它等於開啓了一個死循環監控,所以你的物理CPU數量必須要大於消費者的線程數。注意,我這裏說的是物理CPU,如果你在一個物理核上使用超線程技術模擬兩個邏輯核,另外一個邏輯核顯然會受到這種超密集計算的影響而不能正常工作。

5.4.4 CPU Cache的優化:解決僞共享問題

什麼是僞共享問題呢?我們知道,爲了提高CPU的速度,CPU有一個高速緩存Cache。在高速緩存中,讀寫數據的最小單位爲緩存行(Cache Line),它是從主存(Memory)複製到緩存(Cache)的最小單位,一般爲32字節到128字節。

當兩個變量存放在一個緩存行時,在多線程訪問中,可能會影響彼此的性能。在圖5.4中,假設變量X和Y在同一個緩存行,運行在CPU1上的線程更新了變量X,那麼CPU2上的緩存行就會失效,同一行的變量Y即使沒有修改也會變成無效,導致Cache無法命中。接着,如果在CPU2上的線程更新了變量Y,則導致CPU1上的緩存行失效(此時,同一行的變量X變得無法訪問)。這種情況反覆發生,無疑是一個潛在的性能殺手。如果CPU經常不能命中緩存,那麼系統的吞吐量就會急劇下降
爲了避免這種情況發生,一種可行的做法就是在變量X的前後空間都先佔據一定的位置(把它叫作padding,用來填充用的)。這樣,當內存被讀入緩存時,這個緩存行中,只有變量X一個變量實際是有效的,因此就不會發生多個線程同時修改緩存行中不同變量而導致變量全體失效的情況,如圖5.5所示。(即x佔一個緩存行,Y佔一個緩存行)


5.5 Future模式

Future模式是多線程開發中非常常見的一種設計模式,它的核心思想是異步調用。當我們需要調用一個函數方法時,如果這個函數執行得很慢,那麼我們就要進行等待。但有時候,我們可能並不急着要結果。因此,我們可以讓被調者立即返回,讓它在後臺慢慢處理這個請求。對於調用者來說,則可以先處理一些其他任務,在真正需要數據的場合再去嘗試獲得需要的數據。

對於Future模式來說,雖然它無法立即給出你需要的數據,但是它會返回一個契約給你,將來你可以憑藉這個契約去重新獲取你需要的信息

5.5.1 Future模式的主要角色

5.5.2 Future模式的簡單實現

在這個實現中,有一個核心接口Data,這就是客戶端希望獲取的數據。在Future模式中,這個Data接口有兩個重要的實現,一個是RealData,也就是真實數據,這就是我們最終需要獲得的、有價值的信息。另外一個就是FutureData,它是用來提取RealData的一個“訂單”。因此FutureData可以立即返回。

FutureData實現了一個快速返回的RealData包裝。它只是一個包裝,或者說是一個RealData的虛擬實現。因此,它可以很快被構造並返回。當使用FutrueData的getResult()方法時,如果實際的數據沒有準備好,那麼程序就會阻塞,等RealData準備好並注入FutureData中才最終返回數據。
注意:FutureData是Future模式的關鍵。它實際上是真實數據RealData的代理,封裝了獲取RealData的等待過程。


5.6 並行流水線

併發算法雖然可以充分發揮多核CPU的性能。但不幸的是,並非所有的計算都可以改造成併發的形式。那什麼樣的算法是無法使用併發進行計算的呢?簡單來說,執行過程中有數據相關性的運算都是無法完美並行化的

假如現在有兩個數,B和C,計算(B+C)×B/2,這個運行過程就是無法並行的。原因是,如果B+C沒有執行完成,則永遠算不出(B+C)×B,這就是數據相關性。如果線程執行時所需的數據存在這種依賴關係,那麼就沒有辦法將它們完美的並行化。

遇到這種情況時,有沒有什麼補救措施呢?答案是肯定的,那就是借鑑日常生產中的流水線思想。

比如,現在要生產一批小玩偶。小玩偶的製作分爲四個步驟:第一,組裝身體;第二,在身體上安裝四肢和頭部;第三,給組裝完成的玩偶穿上一件漂亮的衣服;第四,包裝出貨。爲了加快製作進度,我們不可能叫四個人同時加工一個玩具,因爲這四個步驟有着嚴重的依賴關係。如果沒有身體,就沒有地方安裝四肢;如果沒有組裝完成,就不能穿衣服;如果沒有穿上衣服,就不能包裝發貨。因此,找四個人來做一個玩偶是毫無意義的。

但是,如果你現在要製作的不是1個玩偶,而是1萬個玩偶,那情況就不同了。你可以找四個人,第一個人只負責組裝身體,完成後交給第二個人;第二個人只負責安裝頭部和四肢,完成後交付第三人;第三人只負責穿衣服,完成後交付第四人;第四人只負責包裝發貨。這樣所有人都可以一起工作,共同完成任務,而整個時間週期也能縮短到原來的1/4左右,這就是流水線的思想。一旦流水線滿載,每次只需要一步(假設一個玩偶需要四步)就可以產生一個玩偶。

P1:A=B+CP2:D=A×BP3:D=D/2上述步驟中的P1、P2和P3均在單獨的線程中計算,並且每個線程只負責自己的工作。此時,P3的計算結果就是最終需要的答案。P1接收B和C的值並求和,將結果輸入給P2。P2求乘積後輸入給P3。P3將D除以2得到最終值。一旦這條流水線建立,只需要一個計算步驟就可以得到(B+C)×B/2的結果。爲了實現這個功能,我們需要定義一個在線程間攜帶結果進行信息交換的載體


5.7 並行搜索

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章