我會手動建立線程,爲何讓我使用線程池?

| 好看請贊,養成習慣html

  • 你有一個思想,我有一個思想,咱們交換後,一我的就有兩個思想
  • If you can NOT explain it simply, you do NOT understand it well enough

上一篇文章 面試問我,建立多少個線程合適?我該怎麼說 從定性到定量的分析瞭如何建立正確個數的線程來最大化利用系統資源(其實就是幾道小學數學題)。一般來說,有了個這個知識點傍身,按需手動建立相應個數的線程就好java

可是現實中,你也許聽過或者被要求:面試

儘可能避免手動建立線程,應使用線程池統一管理線程

爲何會有這樣的要求?背後的道理又是怎樣的呢?順着這個經驗理論來推斷,那確定是手動建立線程有缺點shell

手動建立線程有什麼缺點?

  1. 不受控風險
  2. 頻繁建立開銷大

不受控風險

這個缺點,相信你也能夠說出一二數據庫

系統資源有限,每一個人針對不一樣業務均可以手動建立線程,而且建立標準不同(好比線程沒有名字)。當系統運行起來,全部線程都在瘋狂搶佔資源,無組織無紀律,混亂場面可想而知(出現問題,天然也就不可能輕易的發現和解決)編程

若是有位神奇的小夥伴,爲每一個請求都建立一個線程,當大量請求鋪面而來的時候,這比如一個正規木馬程序,內存被無情榨乾耗盡(你無情,你冷酷,你無理取鬧)數組

另外,過多的線程天然也會引發上下文切換的開銷緩存

總的來講,不受控風險很大服務器

頻繁建立開銷大

面試問: 頻繁手動建立線程有什麼問題?

答: 開銷大數據結構

這貌似是一個不假思索就能夠回答出來的正確答案。那我要繼續問了

面試官: 建立一個線程幹了什麼就開銷大了?和咱們建立一個普通 Java 對象有什麼差異?

答: ... 嗯...啊

按照常規理解 new Thread() 建立一個線程和 new Object() 沒有什麼差異。Java中萬物接對象,由於 Thread 的老祖宗也是 Object

若是你真是這麼理解的,說明你對線程的生命週期還不是很理解,請回看以前的 Java線程生命週期這樣理解挺簡單的

在這篇文章中咱們明確說明,new Thread() 在操做系統層面並無建立新的線程,這是編程語言特有的。真正轉換爲操做系統層面建立一個線程,還要調用操做系統內核的API,而後操做系統要爲該線程分配一系列的資源

廢話很少說,咱們將兩者作個對比:

new Object() 過程

Object obj = new Object();

當我須要【對象】時,我就會給本身 new 一個(不知你是否和我同樣),這個過程你應該很熟悉了:

  1. 分配一塊內存 M
  2. 在內存 M 上初始化該對象
  3. 將內存 M 的地址賦值給引用變量 obj

就是這麼簡單

建立一個線程的過程

上面已經提到了,建立一個線程還要調用操做系統內核API。爲了更好的理解建立並啓動一個線程的開銷,咱們須要看看 JVM 在背後幫咱們作了哪些事情:

  1. 它爲一個線程棧分配內存,該棧爲每一個線程方法調用保存一個棧幀
  2. 每一棧幀由一個局部變量數組、返回值、操做數堆棧和常量池組成
  3. 一些支持本機方法的 jvm 也會分配一個本機堆棧
  4. 每一個線程得到一個程序計數器,告訴它當前處理器執行的指令是什麼
  5. 系統建立一個與Java線程對應的本機線程
  6. 將與線程相關的描述符添加到JVM內部數據結構中
  7. 線程共享堆和方法區域

這段描述稍稍有點抽象,用數據來講明建立一個線程(即使不幹什麼)須要多大空間呢?答案是大約 1M 左右

java -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary -XX:+PrintNMTStatistics -version

上圖是我用 Java8 的測試結果,19個線程,預留和提交的大概都是19000+KB,平均每一個線程大概須要 1M 左右的大小(Java11的結果徹底不一樣,這個你們自行測試吧)

相信到這裏你已經明白了,對於性能要求嚴苛的如今,頻繁手動建立/銷燬線程的代價是很是巨大的,解決方案天然也是你知道的線程池了

什麼是線程池?

你常見的數據庫鏈接池,實例池,還有XX池,OO池,各類池,都是一種池化(pooling)思想,簡而言之就是爲了最大化收益,並最小化風險,將資源統一在一塊兒管理的思想

Java 也提供了它本身實現的線程池模型—— ThreadPoolExecutor。套用上面池化的想象來講,Java線程池就是爲了最大化高併發帶來的性能提高,並最小化手動建立線程的風險,將多個線程統一在一塊兒管理的思想

爲了瞭解這個管理思想,咱們當前只須要關注 ThreadPoolExecutor 構造方法就能夠了

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
        if (corePoolSize < 0 ||
            maximumPoolSize <= 0 ||
            maximumPoolSize < corePoolSize ||
            keepAliveTime < 0)
            throw new IllegalArgumentException();
        if (workQueue == null || threadFactory == null || handler == null)
            throw new NullPointerException();
        this.acc = System.getSecurityManager() == null ?
                null :
                AccessController.getContext();
        this.corePoolSize = corePoolSize;
        this.maximumPoolSize = maximumPoolSize;
        this.workQueue = workQueue;
        this.keepAliveTime = unit.toNanos(keepAliveTime);
        this.threadFactory = threadFactory;
        this.handler = handler;
}

這麼複雜的構造方法在JDK中還真是很少見,爲了個更形象化的讓你們理解這幾個核心參數,咱們以多數人都經歷過的春運(北京——上海)來講明

序號 參數名稱 參數解釋 春運形象說明
1 corePoolSize 表示常駐核心線程數,若是大於0,即便本地任務執行完也不會被銷燬 平常固定的列車數輛(無論是否是春運,都要有固定這些車次運行)
2 maximumPoolSize 表示線程池可以容納可同時執行的最大線程數 春運客流量大,臨時加車,加車後,總列車次數不能超過這個最大值,不然就會出現調度不開等問題 (結合workqueue)
3 keepAliveTime 表示線程池中線程空閒的時間,當空閒時間達到該值時,線程會被銷燬,只剩下 corePoolSize 個線程位置 春運壓力事後,臨時的加車(若是空閒時間超過keepAliveTime)就會被撤掉,只保留平常固定的列車車次數量用於平常運營
4 unit keepAliveTime 的時間單位,最終都會轉換成【納秒】,由於CPU的執行速度槓槓滴 keepAliveTime 的單位,春運以【天】爲計算單位
5 workQueue 當請求的線程數大於 corePoolSize 時,線程進入該阻塞隊列 春運壓力異常大,(達到corePoolSize)也不能知足要求,全部乘坐請求都會進入該阻塞隊列中排隊, 隊列滿,還有額外請求,就須要加車了
6 threadFactory 顧名思義,線程工廠,用來生產一組相同任務的線程,同時也能夠經過它增長前綴名,虛擬機棧分析時更清晰 好比(北京——上海)就屬於該段列車全部前綴,代表列車運輸職責
7 handler 執行拒絕策略,當 workQueue 達到上限,同時也達到 maximumPoolSize 就要經過這個來處理,好比拒絕,丟棄等,這是一種限流的保護措施 workQueue排隊也達到隊列最大上線,maximumPoolSize 就要提示無票等拒絕策略了,由於咱們不能加車了,當前全部車次已經滿負載

總體來看就是這樣:

試想,若是有請求就新建一趟列車,請求結束就「銷燬」這趟列車,頻繁往復這樣操做,這樣的代價確定是不能接受的。

能夠看到,使用線程池不但能完成手動建立線程能夠作到的工做,同時也填補了手動線程不能作到的空白。概括起來講,線程池的做用包括:

  1. 利用線程池管理並服用線程,控制最大併發數(手動建立線程很可貴到保證)
  2. 實現任務線程隊列緩存策略和拒絕機制
  3. 實現某些與實踐相關的功能,如定時執行,週期執行等(好比列車指定時間運行)
  4. 隔離線程環境,好比,交易服務和搜索服務在同一臺服務器上,分別開啓兩個線程池,交易線程的資源消耗明顯要大。所以,經過配置獨立的線程池,將較慢的交易服務與搜索服務個離開,避免個服務線程互相影響

相信到這裏,你已經瞭解線程池的基本思想了,在使用過程當中仍是有幾個注意事項要說明一下的

線程池使用思想/注意事項

不能忽略的線程池拒絕策略

咱們很難準確的預測將來的最大併發量,因此定製合理的拒絕策略是必不可少的步驟。默認狀況, ThreadPoolExecutor 提供了四種拒絕策略:

  1. AbortPolicy:默認的拒絕策略,會 throw RejectedExecutionException 拒絕
  2. CallerRunsPolicy:提交任務的線程本身去執行該任務
  3. DiscardOldestPolicy:丟棄最老的任務,其實就是把最先進入工做隊列的任務丟棄,而後把新任務加入到工做隊列
  4. DiscardPolicy:至關大膽的策略,直接丟棄任務,沒有任何異常拋出

不一樣的框架(Netty,Dubbo)都有不一樣的拒絕策略,咱們也能夠經過實現 RejectedExecutionHandler 自定義的拒絕策略

對於採用何種策略,具體要看執行的任務重要程度。若是是一些不重要任務,能夠選擇直接丟棄;若是是重要任務,能夠採用降級(所謂降級就是在服務沒法正常提供功能的狀況下,採起的補救措施。具體採用何種降級手段,這也是要看具體場景)處理,例如將任務信息插入數據庫或者消息隊列,啓用一個專門用做補償的線程池去進行補償

沒有絕對的拒絕策略,只有適合那一個,但在設計過程當中千萬不要忽略掉拒絕策略就能夠

禁止使用Executors建立線程池

相信不少人都看到過這個問題(阿里巴巴Java開發手冊說明禁止使用 Executors 建立線程池),我把出處(P247)截圖在此:

Executors 大大的簡化了咱們建立各類類型線程池的方式,爲何還不讓使用呢?

其實,只要你打開看看它的靜態方法參數就會明白了

public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

傳入的workQueue 是一個邊界爲 Integer.MAX_VALUE 隊列,咱們也能夠變相的稱之爲無界隊列了,由於邊界太大了,這麼大的等待隊列也是很是消耗內存的

/**
 * Creates a {@code LinkedBlockingQueue} with a capacity of
 * {@link Integer#MAX_VALUE}.
 */
public LinkedBlockingQueue() {
  this(Integer.MAX_VALUE);
}

另外該 ThreadPoolExecutor方法使用的是默認拒絕策略(直接拒絕),但並非全部業務場景都適合使用這個策略,當很重要的請求過來直接選擇拒絕顯然是不合適的

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }

總的來講,使用 Executors 建立的線程池太過於理想化,並不能知足不少現實中的業務場景,因此要求咱們經過 ThreadPoolExecutor來建立,並傳入合適的參數

總結

當咱們須要頻繁的建立線程時,咱們要考慮到經過線程池統一管理線程資源,避免不可控風險以及額外的開銷

瞭解了線程池的幾個核心參數概念後,咱們也須要通過調優的過程來設置最佳線程參數值(這個過程時必不可少的)

線程池雖然彌補了手動建立線程的缺陷和空白,同時,合理的降級策略能大大增長系統的穩定性

阿里巴巴手冊都是前輩們無數填坑後總結的精華,你也應該遵照相應的指示,結合本身的實際業務場景,設定合適的參數來建立線程池

靈魂追問

  1. 咱們說了這麼多線程池的好,那使用線程池有哪些缺點或限制呢?
  2. 爲何不建議全部業務共用一個線程池?有什麼缺點?
  3. 給線程池設置指定前綴,有哪些方式?

參考

感謝前輩們總結的精華,本身所寫的併發系列好多都參考瞭如下資料

  • Java 併發編程實戰
  • Java 併發編程之美
  • 碼出高效
  • Java 併發編程的藝術
  • ifeve
  • 美團技術團隊

日拱一兵 | 原創

相關文章
相關標籤/搜索