Java多線程引發的性能問題以及調優策略

無限制創建線程

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

引發的問題

  1. 線程的生命週期開銷非常高

  2. 消耗過多的CPU資源

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

  3. 降低穩定性

    JVM在可創建線程的數量上存在一個限制,這個限制值將隨着平臺的不同而不同,並且承受着多個因素制約,包括JVM的啓動參數、Thread構造函數中請求棧的大小,以及底層操作系統對線程的限制等。如果破壞了這些限制,那麼可能拋出OutOfMemoryError異常。

調優策略

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

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

使用線程池可以帶來以下的好處:

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

線程同步

引發的問題

降低可伸縮性

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

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

Speedup1F+1FNSpeedup≤1F+1−FN

  • 當N趨近於無窮大時,最大的加速度比趨近於1/F1/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();
      }
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
    • 即使不進行逸出分析,編譯器也可以執行鎖粒度粗化操作,將臨近的同步代碼塊用同一個鎖合併起來。在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();
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
  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);
           }
       }
      }
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49
    • 在某些情況下,可以將鎖分解技術進一步擴展爲對一組獨立對象上的鎖進行分解,這種情況被稱爲鎖分段

      在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;
               }
           }
       }
      }
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33
      • 34
      • 35
      • 36
      • 37
      • 38
      • 39
      • 40
      • 41
      • 42
      • 43
      • 44
      • 45
      • 46
      • 47
      • 48
      • 49

      鎖分段的一個劣勢在於:與採用單個鎖來實現獨佔訪問相比,要獲取多個鎖來實現獨佔訪問將更加困難並且開銷更高。當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;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

這裏的每個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; 
    }
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

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

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

    另外,對於填充的大小也很難預測,因爲不同的CPU緩存大小也不同,而且填充會增大實例,對垃圾收集影響很大。

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

線程池

引發的問題

線程飢餓死鎖

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

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

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

線程池過大對性能有不利的影響

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

調優策略

設置最大線程數

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

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

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

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

Nthreads=NcpuUcpu(1+WC)Nthreads=Ncpu∗Ucpu∗(1+WC)

  • NcpuNcpu:表示處理器數量,可以通過Runtime.getRuntime().avaliableProcessors()獲得;
  • UcpuUcpu:CPU的使用率,0Ucpu10⩽Ucpu⩽1
  • WCWC:等待時間與計算時間的比值;

另外,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
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

選擇合適的線程池

  1. newCachedThreadPool工廠方法是一種很好的默認選擇,它能夠提供比固定大小的線程池更好的排隊性能;
  2. 當需要限制當前任務的數量以滿足資源管理器需求時,那麼可以選擇固定大小的線程池,例如在接受網絡請求的服務器程序中,如果不進行限制,那麼很容易導致過載問題。
  3. 只有當任務相互獨立,爲線程池設置界限才合理;如果任務之間存在依賴性,那麼有界的線程池或隊列就可能導致線程飢餓死鎖問題,那麼此時應該使用無界的線程池。
  4. 對於提交任務並等待其結果的任務來說,還有一種配置方法,就是使用有界的線程池,並使用SynchronousQueue作爲工作隊列,以及調用者運行飽和策略。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章