併發編程學習筆記之可伸縮性(九)

不少改進性能的技術增長了複雜度,所以增長了安全和活躍度失敗的可能性.算法

更糟糕的是,有些技術的目的是改善性能,事實上產生了相反的做用,帶來了其餘的性能問題.數據庫

數據的正確性永遠是第一位的,保證程序是正確的,而後再讓它更快.只有當你的性能需求和評估標準須要程序運行得更快時,纔去進行改進.數組

在設計併發應用程序的時候,最大可能地改進性能,一般並非最重要的事情.安全

性能的思考

當活動的運行因某個特定資源受阻時,咱們稱之爲受限於該資源:受限於CPU,受限於數據庫.數據結構

使用線程的目的是但願全面提高性能,可是與單線程相比,使用多線程會引入一些額外的開銷.多線程

如:併發

  • 協調線程相關的開銷(加鎖、信號、內存同步)
  • 增長的上下文切換
  • 線程的建立和消亡,以及調度的開銷

當線程被過分使用後,這些開銷會超過提升後的吞吐量響應性和計算能力帶來的補償.框架

一個沒能通過良好併發設計的應用程序,甚至比相同功能的順序的程序性能更差.工具

性能"遭遇"可伸縮性

可伸縮性指的是:當增長計算資源的時候(好比增長額外CPU數量、內存、存儲器、I/O帶寬),吞吐量和生產量可以相應地得以改進.性能

對性能的權衡進行評估

避免不成熟的優化,首先使程序正確,而後再加快----若是它運行得還不夠快.

不少性能的優化會損害可讀性或可維護性--代碼越"聰明",越"晦澀",就越難理解和維護.

在多個方案之間進行選擇的時候,先問本身一些問題:

  • 你所謂的更"快"指的是什麼
  • 在什麼樣的條件下你的方案可以真正運行得更快?在輕負載仍是重負載下?大數據集仍是小數據集?是否支持你的測量標準答案?
  • 這些條件在你的環境中發生的頻率?是否支持你的測量標準的答案?
  • 這些代碼在其餘環境的不一樣條件下被用到的可能性?
  • 你用什麼樣隱含的代價,好比增長的開發風險或維護性,換取了性能的提升?這個權衡的決定是否正確?

作出任何與性能相關的工程決定時,都應該考慮這些問題.

最好選擇保守的優化方案,由於對性能的追求極可能是併發bug惟一最大的來源.經過減小同步來提升響應性,成了不遵照同步規定的經常使用的藉口,可是由於併發bug是最難追蹤和消除的,因此任何引入這類bug的行動風險都須要慎重進行.

優化改進後的代碼,必定要進行壓力測試.主觀認爲會提升性能的代碼,在實際生產環境可能會出現問題.

測評,不要臆測

Amdahl 定律

Amdahl定律描述了在一個系統中,基於可並行化和串行化的組件各自所佔的比重,程序經過得到額外的計算資源,理論上可以加速多少.

若是F是必須串行化執行的比重,那麼Amdahl定律告訴咱們,在一個N處理器的機器中,咱們最多能夠加速:

image

串行執行的比率越大,處理器越多,處理器的利用率越低:

image

線程引入的開銷

調度和線程內部的協調都要付出性能的開銷: 對於改進性能的線程來講,並行帶來的性能優點必須超過併發所引入的開銷.

切換上下文

若是可運行的線程大於CPU的數量,那麼操做系統最終會強行換出正在執行的線程,從而使其餘線程可以使用CPU,這回引發上下文切換,他會保存當前運行線程的執行上下文,並重建新調入線程的執行上下文.

切換上下文會有資源的損耗.

一個程序發生越多的阻塞(阻塞I/O,等待競爭鎖,或者等待條件變量),與受限於CPU的程序相比,就會形成越多的上下文切換,這增長了調度的開銷,並減小了吞吐量(無阻塞的算法能夠減小上下文切換).

Unix系統的vmstat命令和Windows系統的perfmon工具都能報告上下文切換次數和內核佔用的時間等信息.

阻塞

多個線程競爭加鎖的方法的時候,失敗的線程必然發生阻塞.

JVM在阻塞的時候有兩種處理方式:

  • 自旋等待(spin-waiting,不斷嘗試獲取鎖,直到成功).
  • 掛起(suspending)這個阻塞的線程.

自旋等待適合短時間的等待.掛起適合長期間等待.,有一些JVM基於過去等待時間的數據剖析來在這二者之間選擇,可是大多數等待鎖的線程都是被掛起的.

減小鎖的競爭

串行化會損害可伸縮性,上下文切換會損害性能.競爭性的鎖會同時致使這兩種損失,因此減小鎖的競爭可以改進性能和可伸縮性.

訪問獨佔鎖守護的資源是串行的--一次只能有一個線程訪問它.使用鎖能夠避免過時數據,可是安全性是用很大的代價換來的,對鎖長期的競爭會限制可伸縮性.

併發程序中,對可伸縮性首要的威脅是獨佔的資源鎖.

有兩個緣由影響着鎖的競爭性:

  • 鎖被請求的頻率
  • 每次持有鎖的時間

若是這二者的乘積足夠小,那麼大多數請求鎖的嘗試都是非競爭的,這樣競爭性的鎖將不會成爲可伸縮性巨大的障礙.

可是,若是這個鎖的請求量很大,線程將會阻塞以等待鎖.在極端的狀況下,處理器將會閒置,即便仍有大量工做等待着完成.

有三種方式來減小鎖的競爭:

  • 減小持有鎖的時間;
  • 減小請求鎖的頻率;
  • 或者用協調機制取代獨佔鎖,從而容許更強的併發性.

縮小鎖的範圍("快進快出")

減小競爭發生可能性的有效方式是儘量縮短把持鎖的時間.儘可能縮小synchronized代碼塊,尤爲是那些耗時的操做,以及那些潛在的阻塞操做(I/O).

減小鎖的粒度

減小持有鎖的時間比例的另外一種方式是讓線程減小調用它的頻率(所以減小發生競爭的可能性).

能夠經過使用分拆鎖(lock splitting)和分離鎖(lock striping)來實現,也就是採用相互獨立的鎖,守衛多個獨立的狀態變量,在改變以前,它們都是由一個鎖守護的.這些技術減小了鎖發生時的粒度,潛在實現了更好的可伸縮性---可是使用更多的鎖一樣會增長死鎖的風險.

若是一個鎖 守衛數量大於1、且相互獨立的狀態變量,你可能能經過分拆鎖,使每個鎖守護不一樣的變量,從而改進可伸縮性.結果是每一個鎖被請求的頻率都減小 了.

使用相同的鎖:

public class NewLock {
    //對象A
    private final Object objA = new Object();
    //隊相比
    private final Object objB = new Object();

    public synchronized Object getObjA(){
            return objA;
    }

    public synchronized Object getObjB(){
            return objB;
    }

}

使用不一樣的鎖(分拆鎖),減小了鎖的請求頻率:

public class NewLock {
    //對象A
    private final Object objA = new Object();
    //隊相比
    private final Object objB = new Object();

    public Object getObjA(){
        synchronized (objA){
            return objA;
        }
    }

    public Object getObjB(){
        synchronized (objB){
            return objB;
        }
    }

}

分拆鎖對於競爭並不激烈的鎖,可以在性能和吞吐量方面產生一些純粹的改進,儘管這可能會在性能開始由於競爭而退化時增長負載的極限.

分拆鎖對於中等競爭強度的鎖,可以切實地把它們大部分轉化成非競爭的鎖,這個結果是性能和可伸縮性都指望獲得的.

分離鎖

分拆鎖對性能的改進有一些侷限性,不能大幅地提升多個處理器在同一系統中併發性的能力.

分拆鎖有時候能夠被擴展,分紅可大可小加鎖塊的集合,而且它們歸屬於相互獨立的對象,這樣的狀況就是分離鎖.

分離鎖的一個負面做用是:對容器加鎖,進行獨佔訪問更加困難,而且更加昂貴了.

分拆鎖和分離鎖可以改進可伸縮性,由於它們可以使不一樣的線程操做不一樣的數據(或者相同數據結構的不一樣部分),而不會發生相互干擾.

可以從分拆鎖收益的程序,一般是那些對鎖的競爭廣泛大於對鎖守護數據競爭的程序.

例如: 一個鎖守護兩個獨立變量X和Y,線程A想要訪問X,而線程B想要訪問Y,這兩個線程沒有競爭任何數據,然而它們競爭相同的鎖.

獨佔鎖的替代方法

用於減輕競爭鎖帶來的影響的第三種技術是提早使用獨佔鎖,這有助於使用更友好的併發方式進行共享狀態的管理.

這包括:

  • 使用併發容器
  • 讀-寫鎖
  • 不可變對象
  • 原子變量

讀寫鎖

讀寫鎖實行了一個多讀者-單寫者(multiple-reader,single-write)加鎖規則:只要沒有改變,多個讀者能夠併發訪問共享資源,可是寫者必須獨佔得到鎖.

對於多數操做都爲讀操做的數據結構,ReadWriteLock與獨佔的鎖相比,能夠提供更好的併發性.

對於只讀的數據結構,不變性能夠徹底消除加鎖的必要.

原子變量

原子變量類提供了針對整數或對象引用的很是精妙的原子操做,所以更具可伸縮性.

若是你的類只有少許熱點域(例如:多個方法都在調用的計數操做,就是一個熱點域),而且該類不參與其它變量的不變約束,那麼使用原子變量替代它可能會提升可伸縮性.

檢測CPU利用率

當咱們測試可伸縮性的時候,咱們的目標一般是保持處理器的充分利用.

Unix系統的vmstat和mpstat,或者Windows系統的perfmon都可以告訴你處理器有多忙碌.

若是全部的CPU都沒有被均勻地利用(有時CPU很忙碌地運行,有時很悠閒),那麼你的首要目標應該是加強你程序的並行性.

不均勻的利用率表名,大多數計算都有很小的線程集完成,你的應用程序將不可以利用額外的處理器資源.

若是你的CPU沒有徹底利用,你須要找出緣由.有如下幾種:

  • 不充足的負載. 數據量不夠多
  • I/O限制
  • 外部限制.可能你的應用程序取決於外部服務,好比數據庫或者Web Service 那麼瓶頸可能不在於你本身的代碼.
  • 鎖競爭. 使用Profiling工具可以告訴你,程序中存在多少個鎖的競爭,哪些鎖很"搶手".或者使用線程轉儲,若是線程因等待鎖被阻塞,與線程轉儲的棧框架會聲明"waiting to lock monitor...".非競爭的鎖幾乎不會出如今線程轉儲中:競爭激烈的鎖幾乎總會只要有一個線程在等待得到它,因此會頻繁出如今線程轉儲中.

向"對象池"說"不"

不要使用對象池,對象池跟線程池差很少,爲了減小建立和銷燬對象的開銷,可以重複使用對象,建立了一個對象池,可是現代的JVM對象的分配和垃圾回收已經很是快了.

若是使用對象池,那麼線程從池中請求對象,協調訪問池的數據結構的同步就成爲必然了,這便產生了線程阻塞的可能性.

又由於由鎖的競爭產生的阻塞,其代價比直接分配的代價多幾百倍,即便是很小的池競爭都會形成可伸縮性的瓶頸(甚至是非競爭的同步,其代價也會比分配一個對象大不少).

因此使用對象池有點得不償失了,反而效率更低.

比較Map的性能

單線程的時候ConcurrentHashMap的性能要比同步的HashMap的性能稍好一點,可是在併發應用中,這種做用就十分明顯了.

ConcurrentHashMap對get操做作了一些優化,提供最好的性能和併發性.

同步的Map對所用的操做用的都是一個鎖,因此同一時刻只有一個線程可以訪問map.

而ConcurrentHashMap並無對成功的讀操做加鎖,只對寫操做和真正須要鎖的讀操做使用了分離鎖的方法.所以多線程可以併發地訪問Map而不被阻塞.

image

隨着線程數的增長,併發的map吞吐量獲得增加.看ConcurrentHashMap在線程數到達16的時候,它的吞吐量不在提升,由於它的內部使用的是16個分離鎖的數組,能夠支持16個線程同時寫,當線程多餘這個數量的時候,就得不到提高了(能夠增長鎖的數量,提升並行性)

再看同步容器,線程數越多,反而吞吐量下降.

在對鎖的競爭小的境況下,每一個操做花費的時間取決於真正工做的時間,吞吐量會由於線程數的增長而增長.

一旦競爭變得激烈,每一個操做花費的時間就由上下文切換和調度延遲決定了,而且加入更多的線程不會對吞吐量有什麼幫助.

總結

  • Amdahl定律告訴咱們,程序的可伸縮性是由必須連續執行的代碼比例決定的.
  • Java程序中串行化首要的來源是獨佔的資源鎖,因此可伸縮性一般能夠經過如下這些方式提高:
  1. 減小獲取鎖的時間
  2. 減小鎖的粒度
  3. 減小鎖的佔用時間
  4. 用非獨佔或非阻塞鎖來取代獨佔鎖
相關文章
相關標籤/搜索