1. 程式人生 > >Java多執行緒引發的效能問題以及調優策略

Java多執行緒引發的效能問題以及調優策略

無限制建立執行緒

Web伺服器中,在正常負載情況下,為每個任務分配一個執行緒,能夠提升序列執行條件下的效能。只要請求的到達率不超出伺服器的請求處理能力,那麼這種方法可以同時帶來更快的響應性更高的吞吐率。如果請求的到達速率非常高,且請求的處理過程是輕量級的,那麼為每個請求建立一個新執行緒將消耗大量的計算資源。

引發的問題

  1. 執行緒的生命週期開銷非常高

  2. 消耗過多的CPU資源

    如果可執行的執行緒數量多於可用處理器的數量,那麼有執行緒將會被閒置。大量空閒的執行緒會佔用許多記憶體,給垃圾回收器帶來壓力,而且大量的執行緒在競爭CPU資源時還將產生其他效能的開銷。

  3. 降低穩定性

    JVM在可建立執行緒的數量上存在一個限制

    ,這個限制值將隨著平臺的不同而不同,並且承受著多個因素制約,包括JVM的啟動引數、Thread建構函式中請求棧的大小,以及底層作業系統對執行緒的限制等。如果破壞了這些限制,那麼可能丟擲OutOfMemoryError異常。

調優策略

可以使用執行緒池,是指管理一組同構工作執行緒的資源池。

執行緒池的本質就是:有一個佇列,任務會被提交到這個佇列中。一定數量的執行緒會從該佇列中取出任務,然後執行。任務的結果可以發回客戶端、可以寫入資料庫、也可以儲存到內部資料結構中,等等。但是任務執行完成後,這個執行緒會返回任務佇列,檢索另一個任務並執行。

使用執行緒池可以帶來以下的好處:

  1. 通過重用現有的執行緒
    而不是建立新執行緒,可以在處理多個請求時分攤線上程建立和銷燬過程中產生的巨大開銷
  2. 當請求到達時,工作執行緒已經存在,因此不會由於等待建立執行緒而延遲任務的執行,從而提高了響應性
  3. 通過適當調整執行緒池大小,可以建立足夠多的執行緒以便使處理器保持忙碌狀態,同時還可以防止過多執行緒相互競爭資源而使應用程式耗盡記憶體或失敗

執行緒同步

引發的問題

降低可伸縮性

在有些問題中,如果可用資源越多,那麼問題的解決速度就越快。如果使用多執行緒主要是為了發揮多個處理器的處理能力,那麼就必須對問題進行合理的並行分解,並使得程式能夠有效地使用這種潛在的並行能力

不過大多數的併發程式都是由一系列的並行工作

序列工作組成的。因此Amdhl定律描述的是:在增加計算資源的情況下,程式在理論上能夠實現最高加速度比,這個值取決於程式中可並行元件(1-F)序列元件(F)所佔的比重。

Speedup1F+1FN
  • 當N趨近於無窮大時,最大的加速度比趨近於1/F
    • 如果程式有50%的計算資源需要序列執行,那麼最高的加速度比是能是2(而不管有多少個執行緒可用)。
    • 如果在程式中有10%的計算需要序列執行,那麼最高的加速度比將接近10。
  • 如果程式中有10%的部分需要序列執行
    • 在擁有10個處理器的系統中,那麼最高的加速度比為5.3(53%的使用率);
    • 在擁有100個處理器的系統中,加速度比可以達到9.2(9%的使用率);

因此,隨著F值的增大(也就是說有更多的程式碼是序列執行的),那麼引入多執行緒帶來的優勢也隨之降低。所以也說明了限制序列塊的程式碼量非常重要。

上下文切換開銷

如果主執行緒是唯一的執行緒,那麼它基本上不會被排程出去。如果可執行的執行緒數大於CPU的數量,那麼作業系統最終會將某個正在執行的執行緒排程出來,從而使其他執行緒能夠使用CPU。這將導致一次上下文切換,這個過程將儲存當前執行執行緒的執行上下文,並將新排程進來的執行緒的執行上下文設定為當前上下文

那麼在上下文切換的時候將導致以下的開銷

  1. 線上程排程過程中需要訪問由作業系統和JVM共享的資料結構
  2. 應用程式、作業系統以及JVM都使用一組相同的CPU,在JVM和作業系統的程式碼中消耗越多的CPU時鐘週期,應用程式的可用CPU時鐘週期就越來越少。
  3. 當一個新的執行緒被切換進來時,它所需要的資料可能不在當前處理器的本地快取中,因此上下文切換將導致一些快取缺失,因而執行緒在首次排程執行時會更加緩慢。

這就是為什麼排程器會為每個可執行的執行緒分配一個最小執行時間,即使有許多其他的執行緒正在執行——它將上下文切換的開銷分攤到更多不會中斷的執行時間上,從而提高整體的吞吐量(以損失響應性為代價)。

當執行緒由於等待某個發生競爭的鎖而被阻塞時,JVM通常會將這個執行緒掛起,並允許它被交換出去。如果執行緒頻繁地發生阻塞,那麼它將無法獲得完整的排程時間片。在程式中發生越來越多的阻塞,與CPU密集型的程式就會發生越多的上下文切換,從而增加排程開銷,並因此降低吞吐量(無阻塞演算法同樣有助於減少上下文切換)。

記憶體同步開銷

  1. 記憶體柵欄間接帶來的影響

    synchronizedvolatile提供的可見性保證中可能會使用一些特殊指令,即記憶體柵欄(Memory Barrier),記憶體柵欄可以重新整理快取,使快取無效,重新整理硬體的寫緩衝,以及停止執行管道

    記憶體柵欄可能同樣會對效能帶來間接的影響,因為他們將抑制一些編譯器優化操作。並且在記憶體柵欄中,大多數操作都是不能被重排序的。

  2. 競爭產生的同步可能需要作業系統的介入,從而增加開銷

    在鎖上發生競爭的時候,競爭失敗的執行緒肯定會阻塞。JVM在實現阻塞行為時,可以採用自旋等待(Spin-Waiting,指通過迴圈不斷地嘗試獲取鎖,直到成功),或者通過作業系統掛起被阻塞的執行緒。這兩種方式的效率高低,取決於上下文切換的開銷以及在成功獲取鎖之前需要等待的時間。如果等待時間較短,則適合採用自旋等待的方式,而如果等待時間較長,則適合採用執行緒掛起方式。

    某個執行緒中的同步可能會影響其他執行緒的效能,同步會增加記憶體總線上的通訊量,匯流排的頻寬是有限的,並且所有的處理器都將共享這條匯流排。如果有多個執行緒競爭同步頻寬,那麼所有使用同步的執行緒都會受到影響。

  3. 無競爭的同步帶來的開銷可忽略

    synchronized機制針對無競爭的同步進行了優化,去掉一些不會發生競爭的鎖,從而減少不必要的同步開銷。所以,不要擔心非競爭同步帶來的開銷,這個基本的機制已經非常快了,並且JVM還能進行額外的優化以進一步降低或消除開銷。

    • 如果一個物件只能由當前執行緒訪問,那麼JVM就可以通過優化來去掉這個鎖獲取操作

    • 一些完備的JVM能通過逸出分析來找出不會發布到堆的本地物件引用(這些引用是執行緒本地的)

      getStoogeNames()的執行過程中,至少會將Vector上的鎖獲取釋放4次,每次呼叫add或toString時都會執行一次。然而,一個智慧的執行時編譯器通常會分析這些呼叫,從而使stooges及其內部狀態不會逸出,因此可以去掉這4次對鎖的獲取操作。

      public String getStoogeNames(){
       List<String> stooges = new Vector<>();
       stooges.add("Moe");
       stooges.add("Larry");
       stooges.add("Curly");
       return stooges.toString();
      }
    • 即使不進行逸出分析,編譯器也可以執行鎖粒度粗化操作,將臨近的同步程式碼塊用同一個鎖合併起來。在getStoogeNames中,如果JVM進行鎖粒度粗化,那麼可能會把3個add和1個toString呼叫合併為單個鎖獲取/釋放操作,並採用啟發式方法來評估同步程式碼塊中採用同步操作以及指令之間的相對開銷。這不僅減少了同步的開銷,同時還能使優化處理更大的程式碼塊,從而可能實現進一步的優化。

調優策略

避免同步

  1. 使用執行緒區域性變數ThreadLocal

    ThreadLocal類能夠使執行緒的某個值儲存該值的執行緒物件關聯起來。ThreadLocal提供了getset等方法,這些方法使每個使用該變數的執行緒都存有一個獨立的副本,因此get總是返回由當前執行執行緒在呼叫set設定的最新值

    當某個執行緒初次呼叫ThreadLocal.get方法時,就會呼叫initialValue來獲取初始值。這些特定於執行緒的值儲存在Thread物件中,當執行緒終止後,這些值會作為垃圾回收。

    private static ThreadLocal<Connection> connectionHolder = 
           ThreadLocal.withInitial(() -> DriverManager.getConnecton(DB_URL));
    
    public static Connection getConnection(){
       return connectionHolder.get();
    }
  2. 使用基於CAS的替代方案

    在某種意義上,這不是避免同步,而是減少同步帶來的效能損失。通常情況下,在基於比較的CAS和傳統的同步時,有以下使用原則:

    • 如果訪問的是不存在競爭的資源,那麼基於CAS的保護稍快於傳統同步(完全不保護會更快);

    • 如果訪問的資源存在輕度或適度的競爭,那麼基於CAS的保護要快於傳統同步(往往是塊的多);

    • 如果訪問的資源競爭特別激烈,這時,傳統的同步是更好的選擇。

      對於該結論可以這麼理解,在其他領域依然成立:當交通擁堵時,交通訊號燈能夠實現更高的吞吐量,而在低擁堵時,環島能實現更高的吞吐量。這是因為鎖在發生競爭時會掛起執行緒,從而降低了CPU的使用率和共享記憶體總線上的同步通訊量。類似於在生產者-消費者模式中,可阻塞生產者,它能降低消費者上的工作負載,使消費者的處理速度趕上生產者的處理速度。

減少鎖競爭

序列操作會降低可伸縮性,在併發程式中,對可伸縮性的最主要威脅就是獨佔方式的資源鎖。在鎖上競爭時,將同時導致可伸縮性和上下文切換問題,因此減少鎖的競爭能夠提高效能和可伸縮性。

在鎖上發生競爭的可能性主要由兩個因素影響:鎖的請求頻率每次持有該鎖的時間

  • 如果兩者的乘積很小,那麼大多數獲取鎖的操作都不會發生競爭,因此在該鎖上的競爭不會對可伸縮性造成影響。
  • 如果在鎖上的請求量非常高,那麼需要獲取該鎖的執行緒將被阻塞並等待。

因此,有3種方式可以降低鎖的競爭程度:

  1. 減少鎖的持有時間——主要通過縮小鎖的範圍,快進快出

    • 將一個與鎖無關的操作移除同步程式碼塊,尤其是那些開銷較大的操作,以及可能被阻塞的操作。
    • 通過將執行緒安全性委託給其他執行緒安全類來進一步提升它的效能。這樣就無需使用顯式的同步,縮小了鎖範圍,並降低了將來程式碼維護無意破壞執行緒安全性的風險。
    • 儘管縮小同步程式碼塊能提高可伸縮性,但同步程式碼塊也不能過小——一些需要採用原子方式執行的操作必須包含在同一個塊中。同步還需要一定的開銷,把一個同步程式碼塊分解為多個同步程式碼塊時,反而會對效能產生負面影響。
  2. 降低鎖的請求頻率

    通過鎖分解鎖分段等技術來實現,將採用多個相互獨立的鎖來保護獨立的狀態變數,從而改變這些變數在之前由單個鎖來保護的情況。也就是說,如果一個鎖需要保護多個相互獨立的狀態變數,那麼可以將這個鎖分解為多個鎖,並且每個鎖只保護一個變數,從而提高可伸縮性,並最終降低每個鎖被請求的頻率。然而,使用的鎖越多,那麼發生死鎖的風險也就越高。

    • 如果在鎖上存在適中而不是激烈的競爭,通過將一個鎖分解為兩個鎖,能最大限度地提升效能。如果對競爭並不激烈的鎖進行分解,那麼在效能和吞吐量等方面帶來的提升將非常有限,但是也會提高效能隨著競爭而下降的拐點值。對競爭適中的鎖進行分解時,實際上是把這些鎖轉變為非競爭的鎖,從而有效地提高效能和可伸縮性。

      public class ServerStatus {
       private Set<String> users;
       private Set<String> queries;
      
       public synchronized void addUser(String u) {
           users.add(u);
       }
      
       public synchronized void addQuery(String u) {
           queries.add(u);
       }
      
       public synchronized void removeUser(String u) {
           users.remove(u);
       }
      
       public synchronized void removeQuery(String q) {
           queries.remove(q);
       }
      }
      // 使用鎖分解技術
      public class ServerStatus {
       private Set<String> users;
       private Set<String> queries;
      
       public void addUser(String u) {
           synchronized (users) {
               users.add(u);
           }
       }
      
       public void addQuery(String u) {
           synchronized (queries) {
               queries.add(u);
           }
       }
      
       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 16N mod 16)個鎖來保護。假設雜湊函式具有合理的分佈性,並且關鍵字能夠實現均勻分佈,那麼大約能把對於鎖的請求減少到原來的1/16。正是這項技術使得ConcurrentHashMap能夠支援多達16個併發的寫入器。

      public class StripedMap {
       private static final int N_LOCKS = 16;
       private final Node[] buckets;
       private final Object[] locks;
      
       static class Node<K, V> {
           final int hash;
           final K key;
           V value;
           Node<K, V> next;
      
           public Node(int hash, K key) {
               this.hash = hash;
               this.key = key;
           }
       }
      
       public StripedMap(int capacity) {
           this.buckets = new Node[capacity];
           this.locks = new Object[N_LOCKS];
           for (int i = 0; i < N_LOCKS; i++) {
               locks[i] = new Object();
           }
       }
      
       private final int hash(Object key) {
           return Math.abs(key.hashCode() % buckets.length);
       }
      
       public Object get(Object key) {
           int hash = hash(key);
           synchronized (locks[hash % N_LOCKS]) {
               for (Node n = buckets[hash]; n != null; n = n.next) {
                   if (n.key.equals(key)) {
                       return n.value;
                   }
               }
           }
           return null;
       }
      
       public void clear() {
           for (int i = 0; i < buckets.length; i++) {
               synchronized (locks[i % N_LOCKS]) {
                   buckets[i] = null;
               }
           }
       }
      }

      鎖分段的一個劣勢在於:與採用單個鎖來實現獨佔訪問相比,要獲取多個鎖來實現獨佔訪問將更加困難並且開銷更高。當ConcurrentHashMap需要擴充套件對映範圍,以及重新計算鍵值的雜湊值要分佈到更大的桶集合中時,就需要獲取分段鎖集合中的所有鎖。

    鎖分解和鎖分段技術都能提高可伸縮性,因為他們都能使不同的執行緒在不同的資料(或者同一資料的不同部分)上操作,而不會相互干擾。如果程式使用鎖分段技術,一定要表現在鎖上的競爭頻率高於在鎖保護的資料上發生競爭的頻率。

  3. 避免熱點區域

    在常見的優化措施中,就是將一個反覆計算的結果快取起來,都會引入一些熱點區域,而這些熱點區域往往會限制可伸縮性。在容器類中,為了獲得容器的元素數量,使用了一個共享的計數器來統計size。在單執行緒或者採用完全同步的實現中,使用一個獨立的計數器能很好地提高類似size和isEmpty這些方法的執行速度,但卻導致更難以提升的可伸縮性,因此每個修改map的操作都要更新這個共享的計數器。即使使用鎖分段技術來實現雜湊鏈,那麼在對計數器的訪問進行同步時,也會重新導致在使用獨佔鎖時存在的可伸縮性問題。

    為了避免這個問題,ConcurrentHashMap中的size將對每個分段進行列舉,並將每個分段中的元素數量相加,而不是維護一個全域性計數。為了避免列舉每個計數,ConcurrentHashMap為每個分段都維護了一個獨立的計數,並通過每個分段的鎖來維護這個值。

  4. 放棄使用獨佔鎖,使用一種友好併發的方式來管理共享狀態

    • ReadWriteLock:實現了一種在多個讀取操作以及單個寫入操作情況下的加鎖規則。

      如果多個讀取操作都不會修改共享資源,那麼這些讀操作可以同時訪問該共享資源,但是執行寫入操作時必須以獨佔方式來獲取鎖。

      對於讀取佔多數的資料結構,ReadWriteLock能夠提供比獨佔鎖更高的併發性。而對於只讀的資料結構,其中包含的不變形可以完全不需要加鎖操作。

    • 原子變數:提供了一種方式來降低更新熱點域時的開銷。

      靜態計數器、序列發生器、或者對連結串列資料結構中頭結點的引用。如果在類中只包含了少量的共享狀態,並且這些共享狀態不會與其他變數參與到不變性條件中,那麼用原子變數來替代他們能夠提高可伸縮性。

使用偏向鎖

當鎖被爭用時,JVM可以選擇如何分配鎖。

  • 鎖可以被公平地授予,每個執行緒以輪轉排程方式獲得鎖;
  • 還有一種方案,即鎖可以偏向於對它訪問最為頻繁的執行緒

偏向鎖的理論依據是,如果一個執行緒最近用到了某個鎖,那麼執行緒下一次執行由同一把鎖保護的程式碼所需的資料可能仍然儲存在處理器的快取中。如果給這個執行緒優先獲得鎖的權利,那麼快取命中率就會增加(支援老使用者,避免新使用者相關的開銷)。那麼效能就會有所改進,因為避免了新執行緒在當前處理器建立新的快取的開銷。

但是,如果使用的程式設計模型是為了不同的執行緒池由同等機會爭用鎖,那麼禁用偏向鎖-XX:-UseBiasedLocking會改進效能。

使用自旋鎖

在處理同步鎖競爭時,JVM有兩種選擇。

  • 可以讓當前執行緒進入忙迴圈,執行一些指令,然後再次檢查這個鎖;
  • 也可以把這個執行緒放入一個佇列掛起(使得CPU供其他執行緒可用),在鎖可用時通知他。

如果多個執行緒競爭的鎖被持有時間短,那麼自旋鎖就是比較好的方案。如果鎖被持有時間長,那麼讓第二個執行緒等待通知會更好。

如果想影響JVM處理自旋鎖的方式,唯一合理的方式就是讓同步塊儘可能的短。

偽共享

引發的問題

在同步可能帶來的影響方面,就是偽共享,它的出現跟CPU處理其快取記憶體的方式有關。下面舉一個極端的例子,有一個DataHolder的類:

public class DataHolder{
  public volatile long l1;
  public volatile long l2;
  public volatile long l3;
  public volatile long l4;
}

這裡的每個long值都儲存在毗鄰的記憶體位置。例如,l1可能儲存在0xF20位置,l2就會儲存在0xF28位置,剩餘的以此類推。當程式要操作l2時,會有一大塊的記憶體(包括l2前後)被載入到當前所用的某個CPU核的快取行(cache line)上。

大多數情況下,這麼做是有意義的:如果程式訪問了物件的某個特定例項,那麼也可能訪問鄰接的例項變數。如果這些例項變數被載入到當前核的快取記憶體中,那麼記憶體訪問就會特別快。

那麼這種模式的缺點就是:當程式更新本地快取中的某個值時,當前執行緒所在的核必須通知其他的所有核——這個記憶體被修改了。其他核必須作廢其快取行(cache line),並重新從記憶體中載入。那麼隨著執行緒數的增多,對volatile的操作越來越頻繁,那麼效能會逐漸降低。

Java記憶體模型要求資料只是在同步原語(包括CAS和volatile構造)結束時必須寫入主記憶體。嚴格來講,偽共享不一定會涉及同步(volatile)變數,如果long變數不是volatile,那麼編譯器會將這些值放到暫存器中,這樣效能影響並沒有那麼大。然而不論何時,CPU快取中有任何資料被寫入,其他儲存了同樣範圍資料的快取都必須作廢

調優策略

很明顯這是個極端的例子,但是提出了一個問題,如何檢測並糾正偽共享?目前還不能解決偽共享,因為涉及處理器架構相關的專業知識,但是可以從程式碼入手:

  1. 避免所涉及的變數頻繁的寫入

    可以使用區域性變數代替,只有最終結果才寫回到volatile變數。隨著寫入次數的減少,對快取行的競爭就會降低。

  2. 填充相關變數,避免其被載入到相同的快取行中。

    public class DataHolder{
     public volatile long l1;
     public long[] dummy1 = new long[128/8];
     public volatile long l2;
     public long[] dummy2 = new long[128/8];
     public volatile long l3;
     public long[] dummy3 = new long[128/8];
     public volatile long l4; 
    }

    使用陣列來填充變數或許行不通,因為JVM可能會重新安排例項變數的佈局,以便使得所有陣列挨在一起,於是所有的long變數就仍然緊挨著了。

    如果使用基本型別的值來填充該結構,行之有效的可能性大,但是對於變數的數目不好把控。

    另外,對於填充的大小也很難預測,因為不同的CPU快取大小也不同,而且填充會增大例項,對垃圾收集影響很大。

    不過,如果沒有演算法上的改進方案,填充資料有時會具有明顯的優勢。

執行緒池

引發的問題

執行緒飢餓死鎖

只要執行緒池中的任務需要無限期地等待一些必須由池中其他任務才能提供的資源或條件,例如某個任務等待另一個任務的返回值或執行結果,那麼除非執行緒池足夠大,否則將發生執行緒飢餓死鎖

因此,每當提交了一個有依賴的Executor任務時,要清楚地知道可能會出現執行緒飢餓死鎖,因此需要在程式碼或配置Executor的配置檔案中記錄執行緒池的大小或配置限制。

如果任務阻塞的時間過長,那即使不出現死鎖,執行緒池的響應性也會變得糟糕。執行時間過長的任務不僅會造成執行緒池阻塞,甚至還會增加執行時間較短任務的服務時間。

執行緒池過大對效能有不利的影響

實現執行緒池有一個非常關鍵的因素:調節執行緒池的大小對獲得最好的效能至關重要。執行緒池可以設定最大和最小執行緒數,池中會有最小執行緒數目的執行緒隨時待命,如果任務量增長,可以往池中增加執行緒,最大執行緒數可以作為執行緒數的上限,防止執行太多執行緒反而造成效能的降低。

調優策略

設定最大執行緒數

執行緒池的理想大小取決於被提交任務的型別以及所部署系統的特性。同時,設定執行緒池的大小需要避免“過大”和“過小”這兩種極端情況。

  • 如果執行緒池過大,那麼大量的執行緒將在相對很少的CPU和記憶體資源上發生競爭,這不僅會導致更高的記憶體使用量,而且還可能耗盡資源。
  • 如果執行緒池過小,那麼將導致許多空閒的處理器無法執行工作,從而降低吞吐率。

因此,要想正確地設定執行緒池的大小,必須分析計算環境資源預算任務的特性。在部署的系統中有都少個CPU?多大的記憶體?任務是計算密集型、I/O密集型還是二者皆可?他們是否需要像JDBC連線這樣的稀缺資源?如果需要執行不同類別的任務,並且他們之間的行為相差很大,那麼應該考慮使用多個執行緒池,從而使每個執行緒可以根據各自的工作負載來調整。

要是處理器達到期望的使用率,執行緒池的最優大小等於:

Nthreads=NcpuUcpu(1+WC)
  • Ncpu:表示處理器數量,可以通過Runtime.getRuntime().avaliableProcessors()獲得;
  • Ucpu:CPU的使用率,0Ucpu1
  • WC:等待時間與計算時間的比值;

另外,CPU週期並不是唯一影響執行緒池大小的資源,還包括記憶體、檔案控制代碼、套接字控制代碼和資料庫連線等。通過計算每個任務對該資源的需求量,然後用該資源的可用總量除以每個任務的需求量,所得結果解釋執行緒池大小的上限。

設定最小(核心)執行緒數

可以將執行緒數設定為其他某個值,比如1。出發點是防止系統建立太多執行緒,以節省系統資源。

另外,所設定的系統大小應該能夠處理預期的最大吞吐量,而要達到最大吞吐量,系統將需要按照所設定的最大執行緒數啟動所有執行緒。

另外,指定一個最小執行緒數的負面影響非常小,即使第一次就有很多工執行,不過這種一次性成本負面影響不大。

設定額外執行緒存活時間

當執行緒數大於核心執行緒數時,多餘空閒執行緒在終止前等待新任務的最大存活時間。

一般而言,一個新執行緒一旦創建出來,至少應該留存幾分鐘,以處理任何負載飆升。如果任務達到率有比較好的模型,可以基於這個模型設定空閒時間。另外,空閒時間應該以分鐘計,而且至少在10分鐘到30分鐘之間。

選擇執行緒池佇列

  1. SynchronousQueue

    SynchronousQueue不是一個真正的佇列,沒法儲存任務,它是一種線上程之間進行移交的機制。如果要將一個元素放入SynchronousQueue中,必須有另一個執行緒正在等待接受這個元素。如果沒有執行緒等待,所有執行緒都在忙碌,並且池中的執行緒數尚未達到最大,那麼ThreadPoolExecutor將建立一個新的執行緒。否則根據飽和策略,這個任務將被拒絕。

    使用直接移交將更高效,只有當執行緒池是無界的或者可以拒絕任務時,SynchronousQueue才有實際的價值。在newCachedThreadPool工廠方法中就是用了SynchronousQueue

  2. 無界佇列

    如果ThreadPoolExecutor使用的是無界佇列,則不會拒絕任何任務。這種情況下,ThreadPoolExecutor最多僅會按最小執行緒數建立執行緒,最大執行緒數被忽略。

    如果最大執行緒數和最小執行緒數相同,則這種選擇和配置了固定執行緒數的傳統執行緒池執行機制最為接近,newFixedThreadPoolnewSingleThreadExecutor在預設情況下就是使用的一個無界的LinkedBlockingQueue

  3. 有界佇列

    一種更穩妥的資源管理策略是使用有界佇列,例如ArrayBlockingQueue、有界的LinkedBlockingQueuePriorityBlockingQueue

    在有界佇列填滿之前,最多執行的執行緒數為設定的核心執行緒數(最小執行緒數)。如果佇列已滿,而又有新任務加進來,並且沒有達到最大執行緒數限制,則會為當前新任務啟動一個新執行緒。如果達到了最大執行緒數限制,則會根據飽和策略來進行處理。

    一般的,如果執行緒池較小而佇列較大,那麼有助於減少記憶體的使用量,降低CPU的使用率,同時還可以減少上下文切換,但付出的代價是會限制吞吐量。

選擇合適的飽和策略

當有界佇列被填滿後,飽和策略將發揮作用,ThreadPoolExecutor的飽和策略可以通過呼叫setRejectedExecutionHandler來修改。如果某個任務被提交到一個已關閉的Executor,也會用到飽和策略。JDK提供了幾種不同的RejectedExecutionHandler的飽和策略實現:

  1. AbortPolicy(中止)
    • 該策略是預設的飽和策略;
    • 會丟擲未檢查的RejectedExecutionException,呼叫者可以捕獲這個異常,然後根據需求編寫自己的處理程式碼;
  2. DiscardPolicy(拋棄)
    • 當提交的任務無法儲存到佇列中等待執行時,Discard策略會悄悄拋棄該任務。
  3. DiscardOldestPolicy(拋棄最舊)
    • 會拋棄下一個將被執行的任務,然後嘗試重新提交的新任務。
    • 如果工作佇列是一個優先佇列,那麼拋棄最舊的策略,會拋棄優先順序最高的任務,因此最好不要將拋棄最舊的飽和策略和優先順序佇列放在一起使用。
  4. CallerRunsPolicy(呼叫者執行)
    • 該策略既不會拋棄任務,也不會丟擲異常,而是當執行緒池中的所有執行緒都被佔用後,並且工作佇列被填滿後,下一個任務會在呼叫execute時在主執行緒中執行,從而降低新任務的流量。由於執行任務需要一定的時間,因此主執行緒至少在一定的時間內不能提交任何任務,從而使得工作者執行緒有時間來處理正在執行的任務。
    • 另一方面,在這期間,主執行緒不會呼叫accept,那麼到達的請求將被儲存在TCP層的佇列中而不是在應用程式的佇列中。如果持續過載,那麼TCP層將最終發現他的請求佇列被填滿,因此同樣會開始拋棄請求。
    • 當伺服器過載時,這種過載情況會逐漸向外蔓延開來——從執行緒池工作佇列應用程式再到TCP層,最終到達客戶端,導致伺服器在高負載的情況下實現一種平緩的效能降低。

當工作佇列被填滿後,並沒有預定的飽和策略來阻塞execute。因此,可以通過訊號量Semaphore來限制任務的到達速率,就可以實現該功能。

public class BoundedExecutor {

    private final Executor executor;
    private final Semaphore semaphore;

    public BoundedExecutor(Executor executor, int bound) {
        this.executor = executor;
        this.semaphore = new Semaphore(bound);
    }

    public void submitTask(final Runnable command) throws InterruptedException {
        semaphore.acquire();
        try {
            executor.execute(command::run);
        } catch (RejectedExecutionException e) {
            semaphore.release();
        }
    }
}

選擇合適的執行緒池

  1. newCachedThreadPool工廠方法是一種很好的預設選擇,它能夠提供比固定大小的執行緒池更好的排隊效能;
  2. 當需要限制當前任務的數量以滿足資源管理器需求時,那麼可以選擇固定大小的執行緒池,例如在接受網路請求的伺服器程式中,如果不進行限制,那麼很容易導致過載問題。
  3. 只有當任務相互獨立,為執行緒池設定界限才合理;如果任務之間存在依賴性,那麼有界的執行緒池或佇列就可能導致執行緒飢餓死鎖問題,那麼此時應該使用無界的執行緒池。
  4. 對於提交任務並等待其結果的任務來說,還有一種配置方法,就是使用有界的執行緒池,並使用SynchronousQueue作為工作佇列,以及呼叫者執行飽和策略。