java併發編程實踐學習(11)性能和可伸縮性

很多改進性能的技術同樣增加了複雜度,因此增加了安全和活躍度失敗的可能性。

11.1性能的思考

無論是CPU週期、內存、網絡帶寬、I/O帶寬、數據庫請求、磁盤空間、以及其他一些資源。當活動因某個特定資源受阻時,我們稱之爲受限於該性能資源:受限於CPU,受限於數據庫。
但是與單線程方法相比,使用多線程總會引入一些性能的開銷:包括與協調相關的開銷(加鎖,信號,內存同步),增加上下文切換,線程的創建和消亡,以及調度的開銷。當線程被過度使用後,這些開銷會超過提高後的吞吐量響應性和計算能力帶來的補償,從另一個方面,一個沒能經過良好併發設計的應用程序,甚至比相同功能順序的程序性能更差。

1. 性能“遭遇”可伸縮性

可伸縮性指的是:當增加計算資源的時候(比如增加額外CPU數量,內存,存儲器,I/O帶寬),吞吐量和生產量能夠相應的得以改進。
爲併發而進行的調試,通常目的是用最小的代價完成相同的工作,比如通過緩存來重用以前的計算結果,或者使用時間複雜度小的算法。

二.Amdahl定律

Amdahl定律描述了在一個系統中,基於可並行化和串行化的組件各自 所佔的比重,程序通過獲得額外的計算資源,理論上能夠加速多少。如果F 是必須串行化執行的比重,那麼Amdahl定律告訴我們,在一個N 處理器的機器中,我們最多可以加速:這裏寫圖片描述
當N 無限增大趨近無窮時,speedup 的最大值無限趨近1/F ,這意味着一個程序中如果50%的處理都需要串行進行的話,speedup 只能提升2倍(不考慮事實上有多少線程可用);如果程序的10%需要串行進行,speedup 最多能夠提高近10倍。Amdahl定律同樣量化了串行化的效率開銷。在擁有10個處理器的系統中,程序如果有10%是串行化的,那麼最多可以加速5.3 倍(53%的使用率),在擁有100個處理器的系統中,這個數字可以達到9.2(9%的使用率)。這使得無效的CPU利用永遠不可能到達10倍。
圖11.1展示了隨着串行執行和處理器數量變化,處理器最大限度的利用率的曲線。隨着處理器數量的增加,我們很明顯地看到,即使串行化執行的程度發 生細微的百分比變化,都會大大限制吞吐量隨計算資源增加。

但是爲了在多處理器系統中預知你的程序是否存在加速的可能性,你同樣需要識別你的任務中串行的部分。
Amdahl定律中不同串行化的百分比,帶來的最大的效能
假設應用程序中N 個線程正在執行doWork,從一個共享的工作隊列中取出任務,並處理;假設這裏的任務並不依賴其他任務的結果或邊界效應。忽略任務進行隊列操作的時間, 如果我們增加處理器,應用程序會隨之發生什麼樣的改進呢?乍看這個程序可能完全由並行任務組成,並不會相互等待,那麼處理器越多,更多的任務就越可能併發 處理。然而,其中也包含串行組件——從隊列中獲取任務。所有工作者線程都共享工作隊列,因此它會需要一些同步機制,從而在併發訪問中保持完整性。如果通過 加鎖來守衛隊列狀態,那麼當一個線程從隊列中取出任務的時候,其他線程想要取得下一個任務就必須等待——這便是任務處理中串行的部分。

單個任務的處理時間不僅包括執行任務Runnable的時間,也包括從共享隊列中取出任務的時間。如果工作隊列是 LinkedBlockingQueue類型的,這個取出的操作被阻塞的可能性小於使用同步的LinkedList的阻塞可能,這是因爲 LinkedBlockingQueue使用了更具伸縮性的算法,但是訪問所有共享的數據結構,本質上都會向程序引入一個串行的元素。

這個例子同樣忽略了另一個的相同的串行源(source of serialization):結果處理。所有有用的計算都產生一些結果集或者邊界效應——如果不是,它們可以當作死代碼(dead code)被遺棄掉。因爲Runnable沒有提供明確的結果處理,這些任務必須具有一些邊界效應,設定把它們的結果寫入日誌還是存入一個數據結構。日誌文件和結果容器通常由多個工作者線程共享,並且因此成爲了同源的串行部分。如果不是每個線程各自維護自己的結果的數據結構,而是在所有任務都執行完後合併所有的結果,這最終就合併成爲了一個串行源。
串行訪問任務隊列

public class WorkerThread extends Thread {  
    private final BlockingQueue<Runnable> queue;  
    public WorkerThread(BlockingQueue<Runnable> queue) {  
        this.queue = queue;  
    }  
    public void run() {  
        while (true) {  
            try {  
                Runnable task = queue.take();  
                task.run();  
            } catch (InterruptedException e) {  
                break; /* 允許線程退出 */  
            }  
        }  
    }  
}  

1.框架中隱藏的串行化

爲了觀察並行化如何被隱藏在應用程序的構架中,我們可以比較加入線程時的吞吐量,基於觀察到的可伸縮性變化來推斷串行源。
下圖的曲線比較了倆個均爲線程安全的Queue實現:synchronizedList包裝了LinkedList,另一個是ConcurrentLinkedQueue。儘管每一次運行代表相同數量的工作,我們能夠看到,只有改變隊列的實現才能明顯的影響可伸縮性。
這裏寫圖片描述
ConcurrentLinkedQueue的吞吐量持續改進,直到它到達了處理器數量,之後會保持不變。另一個方面同步LinkedList的吞吐量在3個線程時表現了其帶來的改進,但是之後會下跌,因爲同步的開銷增加了。圖中當線程數爲4或5時,競爭是非常激烈的,以至於每次訪問隊列都要競爭鎖,並且吞吐量受控於上下文切換的次數
吞吐量的不同源自於倆個隊列實現的串行化不同。同步的LinkedList用一個鎖守護着整個隊列,在offer和remove調用時都要獲取這個鎖;concurrentLinkedQueue使用了精妙的非阻塞隊列算法,它使用了原子引用來更新各個鏈接指針。這倆者,其中一個是把整個的插入和刪除都實現爲串行化的,另一個則是把每個指針的更新變成串行化的。

三.線程引入的開銷

單線程程序既不存在調度問題,也不存在同步的開銷,不許需要用鎖來保證數據結構的一致性。調度和線程內部的協調需要付出性能的開銷;對於性能改進的線程來說,並行帶來的性能優勢必須超過併發所引入的開銷。

1.切換上下文

如果主線程是唯一可調度的線程,它不會被排除在調度之外。如果可運行的線程數大於CPU的數量,那麼OS會最終強制換出正在執行的線程,從而使其他線程能夠使用CPU。切換上下文是需要代價的;線程的調度需要操控和jvm中共享的數據結構。
當線程因爲競爭一個鎖而阻塞時,JVM通常會將這個線程掛起,運行他被換成。如果線程頻繁發生阻塞,那麼線程就不能完整的使用它調度的限額了。一個程序發生越多的阻塞(阻塞I/O、等待競爭鎖、或者等待條件變量),與受限於CPU的程序相比,就會造成越多的上下文切換,這增加了調度的開銷並且減少了吞吐量。

2.內存同步

性能開銷的幾個來源。synchronized和volatile提供的可見性保證要求使用一個特殊的、名爲存儲關卡的指令來刷新緩存,使緩存無效,刷新硬件的寫緩存,並延遲執行的傳遞。存儲關卡同樣會對性能產生影響,因爲他們抑制了編譯器的優化;在存儲關卡中,大多數操作是不能被重新排序。
現代JVM能夠通過優化,解除經確證不存在競爭的鎖,從而減少額外的同步。更加成熟的JVM可以使用逸出分析來識別本地對象的引用並沒有在堆中被暴露,並且因此成爲本地線程。編譯器同樣可以進行鎖的粗化,把臨近的synchronized塊用相同的鎖合併起來,並對toString使用單獨的鎖請求和釋放。
一個線程中的同步也可能影響到其他的線程的性能。同步造成了共享內存總線上的通信量。這個總線的帶寬是有限的,所有進程都共享這條總線。如果線程必須競爭同步帶寬,所有用到同步的縣城都會受阻。

3.阻塞

非競爭的同步可以由JVM完全掌控;而競爭的同步肯呢更需要OS的活動,這會增大開銷。當鎖爲競爭的時候,失敗的線程必然發生阻塞。JVM既能自旋等待(不斷嘗試獲取鎖,直到成功),或者在操作系統中掛起這個被阻塞的線程。哪一個效率高要取決於上下文切換的開銷,以及成功的獲取所需要等待的時間這兩者之間的關係。自旋等待更適合短期的等待,而掛起適合長時間的等待。有一些JVM基於過去等待的時間的數據剖析在這兩者之間進行選擇,但是大多數等待鎖的線程都是被掛起的。
需要掛起線程可能因爲線程無法得到鎖,或者因爲他們在等待某個條件,抑或被I/O操作阻塞。

四.減少鎖的競爭

訪問獨佔鎖守護的資源是穿行的,一次只有一個線程能訪問它。
有兩個原因影響着鎖的競爭性:鎖被請求的頻率,以及持有該鎖的時間。如果這兩者的乘積足夠小,那麼大多數請求鎖的嘗試都是非競爭的,但是請求很大的話,線程將會阻塞以等待鎖;
有三種方式來減少鎖的競爭:
- 減少鎖的持有時間
- 減少請求鎖的頻率
- 用協調機制取代獨佔鎖,從而允許更強的併發性

1.縮小鎖範圍(”快進快出”)

減小競爭發生可能性的有效方式是儘可能縮短把持鎖的時間。這可以通過把與鎖無關的代碼移出synchronized塊來實現。我們可以使用代理線程安全的技術。省去顯示的同步,也能減小忘記加相應的鎖而造成風險。
儘管縮小synchronized塊能提高可伸縮性。但是同步的開銷非零。把一個synchronized塊拆分成多個塊在某些時刻會產生反作用。

2.減小鎖的粒度

減小持有鎖的時間比例的另一種方式是讓線程減少調用它的頻率,這可以通過分拆鎖分離鎖實現。採用互相獨立的鎖,守衛多個狀態變量。
如果一個鎖守衛數量大於一,且相互獨立的狀態變量,你可以通過分拆鎖,使每一個鎖守護不同的狀態變量。

3.分離鎖

分拆鎖有時候可以被擴展,分成可大可小的鎖塊集合,並且他們歸屬於相互獨立的對象,這樣的情況就是分離鎖。
分離鎖的負面作用是:對容器加鎖進行獨佔訪問更加困難,並且更加昂貴。

4.避免熱點域

當每一個操作都請求變量的時候,鎖的粒度很難被降低。這是性能和可伸縮性相互牽制的另一個方面;通常使用的優化方法,比如緩存常用的計算結果,會引入“熱點域(hot fields)”,從而限制可伸縮性。
如果由你來實現HashMap,你會遇到一個選擇:size方法如何計算Map條目的大小?最簡單的方法是每次調用的時候數一遍。通常使用的優化方法是在插入和移除的時候更新一個單獨的計數器;這會給put和remove方法造成很小的開銷,以保證計數器的更新,但是,這會減少size方法的開銷,從O(n)減至O(1)。
在單線程或完全同步的實現中,保存一個獨立的計數能夠很好地提高類似size和isEmpty這樣的方法的速度,但是卻使改進可伸縮性變得更難了,因爲每一個修改map的操作都要更新這個共享的計數器。即使你對每一個哈希鏈(hash chain)都使用了鎖的分離,對計數器獨佔鎖的同步訪問還是重新引入了可伸縮性問題。這看起來像是一個性能的優化——緩存size操作的結果——卻已經轉化爲一個可伸縮性問題。這種情況下,計數器被稱爲熱點域(hot field),因爲每個變化操作都要訪問它。
爲避免這個問題,ConcurrentHashMap通過枚舉每個條目獲得size,並把這個值加入到每個條目,而不是維護一個全局計數。爲了避免列舉所有元素,ConcurrentHashMap爲每一個條目維護一個獨立的計數域。同樣由分離的鎖守護。

5.獨佔鎖的替代方法

用於減輕競爭鎖帶來的影響的第三種技術是提前使用獨佔鎖,這有助於使用更友好的併發方式進行共享狀態的管理。這包括使用併發容器、讀-寫鎖、不可變對象,以及原子變量。
原子變量提供了能夠減少更新“熱點域”的方式,如果你的類只有少量熱點域,並且該類不參與其他變量的不變約束,那麼使用原子變量替代它可能會提高可伸縮性。(改變你的代碼,減少它的熱點域,這樣會提高可伸縮性,甚至——原子變量減少更新熱點域的開銷,但是它們完全消除這種開銷。)

6.監測CPU利用率

當我們測試可伸縮性的時候,我們的目標通常是保持處理器的充分利用。Unix系統的vmstat和mpstat,或者Windows系統的perfmon都能夠告訴你處理器有多“忙碌”。
如果所有的CPU都沒有被均勻地利用(有時CPU很忙碌地運行,有時候很“清閒”),那麼你的首要目標應該是增強你程序的並行性。不均勻的利用率表明,大多數計算都由很小的線程集完成,你的應用程序將不能夠利用額外的處理器資源。
如果你的CPU沒有完全利用,你需要找出原因。有下面幾種原因:
- 不充足的負載。可能被測試的程序還沒有被加入足夠多的負載。你可以增加負載,並檢查利用率、響應時間和服務運行時間的變化。產生足夠多的負載,使應用程序飽和需要計算機強大的能力;問題可能在於客戶端系統是否具有足夠的能力,而不是被測試系統。
- I/O限制。你可以通過iostat或者perfmon判定一個應用程序是否是受限於磁盤的,或者通過監測它的網絡通信量級判斷它是否有帶寬限制。
- 外部限制。如果你的應用程序取決於外部服務,比如數據庫,或者Web Service,那麼瓶頸可能不在於你自己的代碼。你可以通過使用Profiler工具,或者數據庫管理工具來判斷等待外部服務結果的用時。
- 鎖競爭。使用Profiling工具能夠告訴你,程序中存在多少個鎖的競爭,哪些鎖很“搶手”。 如果不使用剖析器(profiler),你也可以通過隨機取樣來獲得相同的信息,觸發一些線程轉儲,並尋找其中線程競爭鎖的信息。 如果線程因等待鎖被阻塞,與線程轉儲相應的棧框架會聲明“waiting to lock monitor …”。非競爭的鎖幾乎不會出現在線程轉儲中;競爭激烈的鎖幾乎總會至少有一個線程在等待獲得它,所以會頻繁出現在線程轉儲中。

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