從0學習java併發編程實戰-讀書筆記-性能與可伸縮性(10)

線程的最主要目的是提升程序的運行性能。雖然咱們但願得到更好的性能,可是始終須要把安全性放在第一位。首先須要保證程序能正確運行,而後僅當程序的性能需求和測試結果要求程序執行的更快時,才應該設法提升它的運行速度。在設計併發程序時,最重要的一般不是把性能提至極限。java

對性能的思考

提高性能意味着用更少的資源作更多的事。當操做性能因爲某種特定的資源而受到限制時,咱們一般將該操做稱爲資源密集型的操做,例如CPU密集型,數據庫密集型,IO密集型等。
儘管使用多個線程的目標是提高總體性能,可是與單線程方法相比,使用多個線程總會引入一些額外的開銷。例如:web

  • 線程間的協調(例如加鎖,觸發信號,內存同步等)
  • 上下文切換
  • 線程的建立與銷燬
  • 線程的調度

若是過分使用線程,那麼這些開銷甚至會超過因爲提升吞吐量、響應性或計算能力所帶來的提高。另外一方面,若是一個設計的不好的併發程序,其性能也許比同功能的單線程的串行程序還要差。算法

要想要經過併發來得到更好的性能,須要努力作到兩件事:數據庫

  • 更有效地利用現有處理資源
  • 在出現新的處理資源的時候使程序儘量利用這些新資源

從性能視角,CPU要儘量保持忙碌狀態,若是程序是計算密集型的,那麼能夠經過增長處理器來提高性能。若是程序沒法使現有的CPU保持忙碌,那麼增長再多的CPU也無濟於事。數組

性能與可伸縮性

應用程序的性能能夠採用多個指標來衡量,例如:緩存

  • 服務時間
  • 延遲時間
  • 吞吐率
  • 效率
  • 可伸縮性
  • 容量
可伸縮性是指:當增長計算資源時,例如(CPU、內存、存儲容量、IO帶寬),程序的吞吐量或者處理能力能相應的增長。
在併發應用程序中針對可伸縮性進行設計和調整時採用的方法與傳統性能調優大相徑庭。
  • 當進行性能調優時,目的一般是使用更小的代價完成相同的工做,例如使用緩存、優化算法。
  • 而對可伸縮性進行調優時,其目的是將問題的計算並行化,從而能利用更多的計算資源來完成更多的工做。

性能在多快多少這兩方面是徹底獨立的,有時候甚至是互相矛盾的。要實現更高的可伸縮性或硬件利用率,一般會增長各個任務的所要處理的工做量,例如把任務分解爲多個「流水線」子任務。
咱們熟悉的三層程序模型,即在模型中的表現層,業務邏輯層和持久化層是彼此獨立的,而且可能由不一樣的系統來處理。這很好的說明了提升可伸縮性一般會形成性能損失的緣由。若是把表現層,業務邏輯層和持久化層都融合到單個應用程序中,那麼在處理第一個工做單元時,其性能確定要高於將應用程序分爲多層並將不一樣層次分佈到多個系統時的性能。單一的應用程序能減小開銷,例如:安全

  • 不一樣層次之間傳遞任務的網絡延時
  • 不須要分解計算過程到多個層次(例如任務排隊,線程協調,數據複製時都存在開銷)

然而,一旦單一的系統到達自身處理能力的極限時,會遇到一個更嚴重的問題:要進一步提高它的處理能力很是困難。所以咱們會接受每一個工做單元執行更長的時間或者消耗更多的計算資源,以換取應用程序在增長更多資源的狀況下處理更高的負載。
對於服務器應用程序來講,「多少」這個方面(可伸縮性,吞吐量,生存量)每每比「多快」更受重視。(在交互式應用程序中,延遲也許更加劇要,這樣用戶就不用等待進度條)。性能優化

評估各類性能權衡因素

在幾乎全部的工程決策中都會涉及某些形式的權衡。在作出正確的權衡時一般會缺乏相應的信息。例如,快速排序算法在大規模數據集上執行效率很是高,可是對小規模數據集來講,冒泡排序實際上更加高效。服務器

避免不成熟的優化。首先使程序正確,而後再提升運行速度(若是它還運行的不夠快)。
當進行決策的時候,有時候會經過增長某種形式的成本,來下降另外一種形式的開銷(例如用空間換時間),也會經過增長開銷來換取安全性。不少性能優化措施一般都是經過犧牲可讀性和可維護性爲代價,代碼越精巧或者越晦澀,也許就越難以理解和維護。
有時候優化措施會破壞面向對象原則,例如打破封裝,有時候,會帶來更高的錯誤風險,由於一般來講,越快的算法越抽象。
在使某個方案比另外一個方案更快以前,先問本身一些問題:
  • 「更快」的含義是什麼
  • 該方法在什麼條件下運行的更快?在高負載仍是低負載?大數據集仍是小數據集?可否經過測試結果來驗證你的答案?
  • 這些條件在運行環境中的發生頻率?可否經過測試或者數據驗證你的答案?
  • 在其餘不一樣條件的環境可否使用這裏的代碼?
  • 在實現這種策略的時候須要付出哪些隱藏的代價,例如增長開發風險仍是維護開銷,這種權衡是否合適?
以測試爲基準,不要瞎猜

Amdahl定律

在有些問題中,若是可用資源越多,那麼問題解決速度越快。
而有些任務本質上是串行的,增長再多資源也沒法提高速度。
若是使用線程主要是爲了發揮多個處理器的處理能力,那麼就必須對問題進行合理的並行分解,使得程序能有效的使用這種潛在的並行能力。
Amdahl定律描述的是:在增長計算資源的狀況下,程序理論上可以實現最高加速比,這個值取決於程序中可並行組件與串行組件所佔的比重。網絡

假定F是必須被串行執行的部分,N是處理器個數。那麼按照Amdahl定律,最高的加速比爲:
$$Speedup<=\frac{1}{F+\frac{1-F}{N}}$$

當N接近無窮大時,最大的加速比趨近於1/F。所以,若是程序有50%的計算須要串行執行,那麼最高的加速比只能是2(無論有多少線程可用),若是程序中有10%的須要串行執行,那麼最高的加速比將接近於10.

Amdahl還量化了串行化的效率開銷。在擁有10個處理器的系統中,若是程序有10%的部分須要串行執行,那麼最高加速比只有5.3(53%的使用率),在擁有100個處理器的系統中,加速比能夠達到9.2(9%的使用率),即便擁有再多的CPU,也沒法達到10的加速比。

全部的併發都擁有必定擁有一部分串行部分。

Amdahl定律的應用

若是能準確估計除執行過程當中串行部分所佔的比例,那麼Amdahl定律就能量化當有更多計算資源可用時的加速比。雖然直接測量串行部分的比例很是困難,但即便在不進行測試的狀況下Amdahl定律仍然是有用的。
隨着多核CPU成爲主流,系統可能擁有數百個甚至數千個處理器,一些在4路系統中看似可伸縮性的算法,可能有可伸縮性瓶頸,只是還沒遇到而已。

線程引入的開銷

單線程程序既不存在線程調度,也不存在同步開銷,並且不須要使用鎖來保證數據結構的一致性。在多個線程的調度和協調過程都須要必定的性能開銷:對於爲了提高性能而引入的線程來講,並行帶來的性能提高必須超過併發致使的開銷。

上下文切換

若是主線程是惟一的線程,那麼它基本不會被調度出去。另外一方面,若是可運行的線程數大於CPU的數量,那麼操做系統最終會從某個正在運行的線程調度出來,從而使其餘線程可以使用CPU。這將致使一次上下文的切換,在這個過程當中將保存當前運行線程的執行上下文,並將新調度進來的線程的執行上下文設置爲當前上下文。
切換上下文須要必定的開銷,而在線程調度過程當中須要訪問由操做系統和JVM共享的數據結構。應用程序、操做系統以及JVM都是用同一組相同的CPU。JVM和操做系統的代碼中消耗越多的CPU時鐘週期,應用程序可用的CPU時鐘週期就越少。
可是上下文的切換的開銷並不僅包含JVM和操做系統的開銷。當一個新的線程被切換進來時,它所須要的數據可能並不在當前處理器的本地緩存中,所以上下文切換可能會致使一些緩存缺失,於是線程在首次調度運行時會更加緩慢。
這就是爲何調度器會爲每一個可運行的線程分配一個最小執行時間:它將上下文切換的開銷分攤到更多不會中斷的執行時間上,以提升總體的吞吐量(以損失相應性爲代價)。
當線程因爲等待某個發生競爭的鎖而被阻塞的時候,JVM一般會將這個線程掛起,並容許它被交換出去。若是線程頻繁的發生阻塞,那它們將沒法使用完整的調度時間片。在程序中發生越多的阻塞(包括阻塞I/O、等待發生競爭的鎖、或者在條件變量上等待),與CPU密集型的程序就會發生越屢次上下文切換,從而增長調度開銷,所以下降吞吐量。(無阻塞算法一樣有助於減少上下文切換)
上下文切換的實際開銷會隨平臺的不一樣而變化,然而按照經驗來看:在大多數通用的處理器中,上下文切換的開銷至關於5000 - 10000個時鐘週期,也就是幾微秒。
UNIX系統的vmstat命令和Windows系統的perfmon工具能報告上下文切換次數以及在內核中執行時間所佔比例等信息。若是內核佔用率比較高(超過10%),那麼一般表示調動活動發生得很頻繁,這極可能是由I/O或者鎖競爭引發的。

內存同步

同步操做的性能開銷包括多個方面。
在synchronized和volatile提供的可見性保證中可能會使用一些特殊的指令,即內存柵欄(Memory Barrier)。內存柵欄能夠刷新緩存,使緩存無效,刷新硬件的寫緩存,以及中止執行管道。內存柵欄可能一樣會對性能帶來間接的影響,由於它們將抑制一些編譯器的優化操做。在內存柵欄中,大多數操做使不能被重排序的。
在評估同步操做帶來的性能影響時,區分有競爭的同步和無競爭的同步很是重要。synchronized機制針對無競爭的同步進行了優化(volatile一般是無競爭的),雖然無競爭同步開銷並不爲零,可是它對總體性能的影響微乎其微。

不要過分擔憂非競爭同步帶來的開銷,這個基本的機制已經很是快了,而且JVM還能進行額外的優化以進一步的下降或消除開銷,因此應該將優化重點放在那些發生鎖競爭的地方。
某個線程中的同步可能會影響其餘線程的性能。同步會增長共享內存總線上的通訊量,總線的帶寬是有限的,而且全部的處理器都共享這條總線。若是有多個線程競爭同步帶寬,那麼全部使用了同步的線程都會受到影戲。

阻塞

非競爭的同步能夠徹底在JVM中進行處理,而競爭的同步可能須要操做系統的介入,從而增長開銷。在鎖上發生競爭時,競爭失敗的線程確定會阻塞。JVM在實現阻塞行爲的時候,能夠採用自旋等待(Spin-Waiting),或者經過操做系統掛起被阻塞的線程。這兩種方式的效率高低,要取決於上下文切換的開銷和在成功獲取鎖以前須要等待的時間。若是等待時間短,則適合採用自旋等待的方式。若是等待時間較長,則適合採用線程掛起的方式。大多數JVM在等待鎖時都是將線程掛起。
當線程沒法獲取某個鎖或者因爲在某個條件等待或在I/O操做上阻塞時,須要被掛起,在這個過程當中將包含兩次額外的上下文切換,以及全部必要的操做系統操做和緩存操做:被阻塞的線程在其執行時間片還未用完以前就被交換出去,而在隨後當要獲取的鎖或者其餘資源可用的時候,又再次被切換回來。

減小鎖的競爭

串行操做會下降可伸縮性,而且上下文切換也會下降性能。在鎖上發生競爭將同時致使這兩種問題,所以減小鎖的競爭可以提升性能和可伸縮性。
但對由某個獨佔鎖保護的資源進行訪問時候,將採用串行的方式,一次只能由一個線程能訪問它。可是得到這種安全性是須要代價的,若是在鎖上持續發生競爭,那麼將限制代碼的可伸縮性。

在併發程序中,對可伸縮性最主要的威脅就是獨佔方式的資源鎖。
有兩個因素將影響在鎖上發生競爭的可能性:
  • 鎖的請求頻率
  • 每次持有該鎖的時間

若是兩者的乘積很小,那麼大多數獲取鎖的操做都不會發生競爭,所以在該鎖上的競爭不會對可伸縮性形成嚴重影響。然而,若是在鎖上的請求量特別高,那麼須要獲取該鎖的線程被阻塞並等待。在極端狀況下,即使有大量工做須要完成,CPU仍會被閒置。

有3種方式能夠下降鎖的競爭程度:
  • 減小鎖的持有時間
  • 減小鎖的請求頻率
  • 使用帶有協調機制的獨佔鎖,這些機制容許更高的併發性。

縮小鎖範圍(「快進快出」)

下降發生競爭可能性的一種有效方式就是儘量縮短鎖的持有時間。例如將一些與鎖無關的代碼移出同步代碼塊。若是持有鎖的時間過長,將會影響伸縮性。
儘管縮小同步代碼塊能提升可伸縮性,但同步代碼塊也不能太小,一些須要採用原子方式執行的操做必須在包含在一個同步塊中。
此外,同步須要必定的開銷,當把一個代碼庫塊分解爲多個代碼塊時,反而會對性能提高產生負面影響。

實際狀況下,僅當能夠講一些「大量」的計算或阻塞操做從同步代碼塊移出時,才應該考慮同步代碼塊的大小。

減少鎖的粒度

另外一種減少鎖的持有時間的方式是下降線程請求鎖的頻率,從而減少發生競爭的可能性。這能夠經過鎖分解和鎖分段等技術來實現,在這些技術中將採用多個相互獨立的鎖來保護獨立的狀態變量,從而改變這些變量在以前由單個鎖來保護的狀況。這些技術能減少鎖操做的粒度,並能實現更高的可伸縮性,然而使用的鎖越多,發生死鎖的風險也就越高。
若是一個鎖須要保護多個相互獨立的狀態變量,那麼能夠將這個鎖分解爲多個鎖,而且每一個鎖只保護一個變量,從而提升可伸縮性,並最終下降每一個鎖被請求的頻率。

對鎖進行分解

public class ServerStatus(){
    public final Set<String> users;
    public final Set<String> queries;
    ...
    public synchronized void addUser(String u){
        user.add(u);
    }
    public synchronized void addQuery(String q){
        queries.add(q);
    }
    public synchronized void removeUser(String u){
        users.remove(u);
    }
    public synchronized void removeQuery(String q){
        queries.remove(q);
    }
}

代碼能夠分解爲:

public class ServerStatus(){
    public final Set<String> users;
    public final Set<String> queries;
    ...
    public void addUser(String u){
        synchronized(users){
            user.add(u);
        }
    }
    public void addQuery(String q){
        synchronized(queries){
            queries.add(q);
        }
    }
    public void removeUser(String u){
        synchronized(users){
            users.remove(u);
        }
    }
    public void removeQuery(String q){
        synchronized(queries){
            queries.remove(q);
        }
    }
}

對競爭中的鎖進行分解,其實是把這些鎖轉變爲非競爭的鎖,從而能有效的提升性能和可伸縮性。

鎖分段

把一個競爭緊張的鎖分解爲兩個鎖時,這兩個鎖可能都存在着激烈的競爭。雖然採用兩個線程併發執行能提升一部分可伸縮性,但在一個擁有多個處理器的系統中,仍然沒法給可伸縮性帶來極大的提升。
雖然採用兩個線程併發執行能提升一部分可伸縮性,但在一個擁有多個處理器的系統中,仍然沒法給可伸縮性帶來極大的提升。
在某些狀況下,能夠將鎖分解技術進一步拓展爲一組獨立對象上的鎖進行分解,這種狀況被稱爲鎖分段
concurrentHashMap的實現中使用了一個包含16個鎖的數組,每一個鎖保護全部散列桶的1/16,其中第N個散列桶由第N mod 16(取模)來保護。假設散列函數具備合理的分佈性,而且關鍵字可以實現均勻分佈,那麼這樣大約能把鎖的競爭下降至1/16。正是這項技術使得concurrentHashMap可以支持多達16個併發的寫入器。(要使得擁有大量處理器的系統在高訪問量的狀況下實現更高的併發性,還能夠進一步增長鎖的數量,但僅當你能證實併發寫入線程的競爭足夠激烈並須要突破這個限制時,才能將鎖分段的數量超過默認的16個)。
鎖分段的劣勢在於,與採用單個鎖來實現獨佔訪問相比,要獲取多個鎖來實現獨佔訪問將更加困難且開銷更高。
一般,在執行一個操做的時候最多隻須要獲取一個鎖,但在某些狀況下須要加鎖整個容器,例如當ConcurrentHashMap須要擴展映射範圍,以及從新計算鍵值的散列值要分佈到更大的桶集合中時,就須要獲取分段鎖集合中的全部鎖(要獲取內置鎖的一個集合,能採用的惟一方式是遞歸)。

避免熱點域

鎖分解和鎖分段技術均可以提升可伸縮性,由於它們都能使不一樣的線程在不一樣的數據(或者同一數據的不一樣部分上操做),而不會相互干擾。若是程序採用鎖分段技術,那麼必定要表現出在鎖上的競爭頻率高於在鎖保護的數據上發生競爭的頻率。
當每一個操做都請求多個變量時,鎖的粒度很難下降。這是性能和可伸縮性之間相互制衡的另外一個方面,一些常見的優化措施,例如將一些反覆計算的結果緩存起來,都會引入一些熱點域,這些熱點域每每會限制可伸縮性。
當實現HashMap的時候,你須要考慮如何在size方法中計算元素的數量,最簡單的方法就是每次調用的時候都統計一下元素的數量。一種常見的優化策略是,在插入和移除元素時更新計數器。
在單線程或者採用徹底同步的實現中,使用一個獨立的計數器能很好地提升相似size和isEmpty這些方法的執行速度,但卻致使更難以提高實現的可伸縮性,由於每一個修改map的操做都要更新這個共享的計數器。即便使用鎖分段來實現散列鏈,那麼在對計數器訪問進行同步時,也會從新致使在使用獨佔鎖時存在的可伸縮性問題。一個看似性能優化的措施,緩存size的結果,已經變成了一個可伸縮性問題。在這種狀況下,計數器也被稱爲熱點域,由於致使元素數量發生變化的方法都須要訪問它。
ConcurrentHashMap中的size將對每一個分段進行枚舉並將每一個分段中的元素數量相加,而不是維護一個全局的計數,每一個分段維護了一個獨立的計數,並經過每一個分段的鎖來維護這個值。

一些替代獨佔鎖的方法

第三種下降競爭鎖的影響的技術就是放棄使用獨佔鎖,從而有助於使用一種友好併發的方式來管理共享狀態。例如使用併發容器、讀-寫鎖、不可變對象以及原子變量。

  • ReadWriteLock實現了一種在多個讀取操做以及單個寫入操做狀況下的加鎖規則:若是多個讀取操做都不會修改共享資源,那麼這些讀取操做能夠同時訪問該共享資源,可是執行寫入操做時必須以獨佔的方式來獲取鎖。對於讀取操做佔大多數的數據結構,ReadWriteLock能提供比獨佔鎖更好的併發性。而對於只讀的數據結構而言,其中包含的不變性能夠徹底不須要加鎖操做。
  • 原子變量提供了一種方式來下降更新熱點數據時的開銷,例如靜態計數器,序列發生器,或者對鏈表數據結構中頭節點的引用。原子變量類提供了在整數或者對象引用上的細粒度原子操做(所以伸縮性更高),並使用了現代處理器中提供的底層併發原語(例如比較並交換compare-and-swap)。若是在類中包含少許的熱點域,而且這些域不會與其餘變量參與到不變性條件中,那麼用原子變量來替代它們提高可伸縮性。

監測CPU利用率

若是全部CPU沒有獲得充分利用(有些CPU很忙碌,有些很空閒),那麼首要目標就是進一步找出程序中的並行性。不均勻的利用代表大多數計算都是有一小組線程完成的,而且應用程序沒有利用其餘的處理器。

  • 負載不充足:測試的程序中可能沒有足夠多的負載,由於能夠在測試的時候增長負載,並檢查利用率,響應時間和服務時間等指標的變化。若是產生足夠多的負載使應用程序達到飽和,那麼可能須要大量的計算機能耗,而且問題處於客戶端是否有足夠能力,而不是被測試系統。
  • I/O密集:判斷某個應用程序是不是IO密集的,或者經過監測網絡的通訊流量級別來判斷它是否須要高帶寬。
  • 外部限制:若是應用程序依賴外部服務,例如數據庫或者webservice,那麼性能瓶頸可能不在你本身的代碼中。
  • 鎖競爭:使用分析工具能夠知道在程序中存在何種程度的鎖競爭,以及在哪些鎖上存在激烈的競爭。

對對象池說「不」

一般,對象分配操做的開銷比同步的開銷低不少。
如今已經沒人用對象池了。

小結

因爲使用線程經常是爲了充分利用多個處理器的計算能力,所以在併發程序性能的討論中,一般更多地將側重點放在吞吐量和可伸縮性上,而不是服務時間。Amdahl定律告訴咱們,程序的可伸縮性主要取決於在全部代碼中必須被串行執行的代碼比例。由於java程序中串行操做的主要來源是獨佔方式的資源鎖,以及採用非獨佔的鎖或非阻塞的鎖來代替獨佔鎖。

相關文章
相關標籤/搜索