為什麼使用線程池
對於服務端的程式,經常面對的是用戶端傳入的短小(執行時間短、工作內容較為單一)任務,需要服務端快速處理並返回結果。如果服務端每次接受到一個任務,建立一個線程,然後進行執行,這在原型階段是個不錯的選擇,但是面對成千上萬的任務遞交進伺服器時,如果還是採用一個任務一個線程的方式,那麼將會建立數以萬記的線程,這不是一個好的選擇。因為這會使作業系統頻繁的進行線程環境切換,無故增加系統的負載,而線程的建立和消亡都是需要耗費系統資源的,也無疑浪費了系統資源。線程池就很好的解決了這個問題。 線程池的工作原理
線程池是系統啟動時預先建立一定數量的線程。線程池在沒有工作要求的時候,建立一定數量的線程放到空閑隊列中。這些線程都是出於睡眠狀態,都是啟動的,不消耗CPU,只是佔用較小空間的記憶體。當請求到來之後,緩衝池給這次請求分配一個閒置線程,把請求傳入該線程中運行,進行處理。當預先建立的線程都處於運行狀態,即預製線程不夠,線程池可以自有建立一定數量的新線程,用於處理更多的請求。當系統比較空間的時候,也可以通過移除一部分處於停用的線程。
這樣做的好處是,一方面,消除了頻繁建立和消亡線程的系統資源開銷,另一方面,面對過量任務的提交能夠平緩的劣化。 程式碼範例
public interface ThreadPool<Job extends Runnable> { // 執行一個Job,這個Job需要實現Runnable void execute(Job job); // 關閉線程池 void shutdown(); // 增加工作者線程 void addWorkers(int num); // 減少工作者線程 void removeWorker(int num); // 得到正在等待執行的任務數量 int getJobSize();}
用戶端可以通過execute(Job)方法將Job提交入線程池執行,而用戶端自身不用等待Job的執行完成。除了execute(Job)方法以外,線程池介面提供了增大/減少工作者線程以及關閉線程池的方法。這裡工作者線程代表著一個重複執行Job的線程,而每個由用戶端提交的Job都將進入到一個工作隊列中等待工作者線程的處理。
package com.thread;import java.util.ArrayList;import java.util.Collections;import java.util.LinkedList;import java.util.List;import java.util.concurrent.atomic.AtomicLong;public class DefaultThreadPool<Job extends Runnable> implements ThreadPool<Job>{ // 線程池最大限制數 private static final int MAX_WORKER_NUMBERS = 10; // 線程池預設的數量 private static final int DEFAULT_WORKER_NUMBERS = 5; // 線程池最小的數量 private static final int MIN_WORKER_NUMBERS = 1; // 這是一個工作列表,將會向裡面插入工作 private final LinkedList<Job> jobs = new LinkedList<Job>(); // 工作者列表 private final List<Worker> workers = Collections.synchronizedList(new ArrayList<Worker>()); // 工作者線程的數量 private int workerNum = DEFAULT_WORKER_NUMBERS; // 線程編號產生 private AtomicLong threadNum = new AtomicLong(); public DefaultThreadPool(){ initializeWokers(DEFAULT_WORKER_NUMBERS); } public DefaultThreadPool(int num){ workerNum = num > MAX_WORKER_NUMBERS ? MAX_WORKER_NUMBERS:num<MIN_WORKER_NUMBERS?MIN_WORKER_NUMBERS:num; initializeWokers(workerNum); } /** * 初始化一定數量的線程 * @param defaultWorkerNumbers */ private void initializeWokers(int defaultWorkerNumbers) { for(int i=0;i<defaultWorkerNumbers;i++){ Worker worker = new Worker(); workers.add(worker); Thread thread = new Thread(worker,"ThreadPool-Worker-"+threadNum.incrementAndGet()); thread.start(); } } @Override public void execute(Job job) { if(job != null){ jobs.addLast(job); jobs.notify(); } } @Override public void shutdown() { for(Worker worker:workers){ worker.shutdown(); } } @Override public void addWorkers(int num) { synchronized (jobs) { // 限制新增的Worker數量不能超過最大值 if(num + this.workerNum > MAX_WORKER_NUMBERS){ num = MAX_WORKER_NUMBERS - this.workerNum; } initializeWokers(num); this.workerNum+=num; } } @Override public void removeWorker(int num) { synchronized (jobs) { if(num > this.workerNum){ throw new IllegalArgumentException("beyond workNum"); } int count = 0; while(count < num){ Worker worker = workers.get(count); if(workers.remove(worker)){ worker.shutdown(); count++; } } this.workerNum -= count; } } @Override public int getJobSize() { return jobs == null?0:jobs.size(); } class Worker implements Runnable{ // 是否在工作 private volatile boolean running = true; @Override public void run() { while(running){ Job job = null; synchronized (jobs) { // 如果工作者列表是空,就wait while(jobs.isEmpty()){ try { jobs.wait(); } catch (InterruptedException e) { // 感知到外部對WorkerThread的中斷,返回 Thread.currentThread().interrupt(); return; } } // 取出一個job job = jobs.removeFirst(); } if(job != null){ try { job.run(); } catch (Exception e) { e.printStackTrace(); } } } } public void shutdown() { running = false; } }}
從線程池的實現可以看到,當用戶端調用execute(Job)方法時,會不斷地向工作清單jobs中添加Job,而每個工作者線程會不斷地從jobs上取出一個Job進行執行,當jobs為空白時,工作者線
程進入等待狀態。添加一個Job後,對工作隊列jobs調用了其notify()方法,而不是notifyAll()方法,因為能夠確定有工作者線程被喚醒,這時使用notify()方法將會比notifyAll()方法獲得更小的開銷(避免將等待隊列中的線程全部移動到阻塞隊列中)。可以看到,線程池的本質就是使用了一個安全執行緒的工作隊列串連工作者線程和用戶端線程,用戶端線程將任務放入工作隊列後便返回,而工作者線程則不斷地從工作隊列上取出工作並執行。當工作隊列為空白時,所有的工作者線程均等待在工作隊列上,當有用戶端提交了一個任務之後會通知任意一個工作者線程,隨著大量的任務被提交,更多的工作者線程會被喚醒。 線程池使用的風險
用線程池構建的應用程式容易遭受任何其它多線程應用程式容易遭受的所有並發風險,諸如同步錯誤和死結,它還容易遭受特定於線程池的少數其它風險,諸如與池有關的死結、資源不足和線程泄漏。 死結
任何多線程應用程式都有死結風險。當一組進程或線程中的每一個都在等待一個只有該組中另一個進程才能引起的事件時,我們就說這組進程或線程 死結了。死結的最簡單情形是:線程 A 持有對象 X 的獨佔鎖,並且在等待對象 Y 的鎖,而線程 B 持有對象 Y 的獨佔鎖,卻在等待對象 X 的鎖。除非有某種方法來打破對鎖的等待(Java 鎖定不支援這種方法),否則死結的線程將永遠等下去。雖然任何多線程程式中都有死結的風險,但線程池卻引入了另一種死結可能,在那種情況下,所有池線程都在執行已阻塞的等待隊列中另一任務的執行結果的任務,但這一任務卻因為沒有未被佔用的線程而不能運行。當線程池被用來實現涉及許多互動對象的類比,被類比的對象可以相互發送查詢,這些查詢接下來作為排隊的任務執行,查詢對象又同步等待著響應時,會發生這種情況。 資源不足
線程池的一個優點在於:相對於其它替代調度機制(有些我們已經討論過)而言,它們通常執行得很好。但只有恰當地調整了線程池大小時才是這樣的。線程消耗包括記憶體和其它系統資源在內的大量資源。除了Thread對象所需的記憶體之外,每個線程都需要兩個可能很大的執行呼叫堆疊。除此以外,JVM 可能會為每個 Java 線程建立一個本機線程,這些本機線程將消耗額外的系統資源。最後,雖然線程之間切換的調度開銷很小,但如果有很多線程,環境切換也可能嚴重地影響程式的效能。
如果線程池太大,那麼被那些線程消耗的資源可能嚴重地影響系統效能。線上程之間進行切換將會浪費時間,而且使用超出比您實際需要的線程可能會引起資源匱乏問題,因為池線程正在消耗一些資源,而這些資源可能會被其它任務更有效地利用。除了線程自身所使用的資源以外,服務要求時所做的工作可能需要其它資源,例如 JDBC 串連、通訊端或檔案。這些也都是有限資源,有太多的並發請求也可能引起失效,例如不能分配 JDBC 串連。 並發錯誤
線程池和其它排隊機制依靠使用 wait() 和 notify() 方法,這兩個方法都難於使用。如果編碼不正確,那麼可能丟失通知,導致線程保持空閑狀態,儘管隊列中有工作要處理。使用這些方法時,必須格外小心;即便是專家也可能在它們上面出錯。而最好使用現有的、已經知道能工作的實現,例如util.concurrent 包。 線程泄露
各種類型的線程池中一個嚴重的風險是線程泄漏,當從池中除去一個線程以執行一項任務,而在任務完成後該線程卻沒有返回池時,會發生這種情況。發生線程泄漏的一種情形出現在任務拋出一個 RuntimeException 或一個 Error 時。如果池類沒有捕捉到它們,那麼線程只會退出而線程池的大小將會永久減少一個。當這種情況發生的次數足夠多時,線程池最終就為空白,而且系統將停止,因為沒有可用的線程來處理任務。
有些任務可能會永遠等待某些資源或來自使用者的輸入,而這些資源又不能保證變得可用,使用者可能也已經回家了,諸如此類的任務會永久停止,而這些停止的任務也會引起和線程泄漏同樣的問題。如果某個線程被這樣一個任務永久地消耗著,那麼它實際上就被從池除去了。對於這樣的任務,應該要麼只給予它們自己的線程,要麼只讓它們等待有限的時間。 請求過載
僅僅是請求就壓垮了伺服器,這種情況是可能的。在這種情形下,我們可能不想將每個到來的請求都排隊到我們的工作隊列,因為排在隊列中等待執行的任務可能會消耗太多的系統資源並引起資源缺乏。在這種情形下決定如何做取決於您自己;在某些情況下,您可以簡單地拋棄請求,依靠更進階別的協議稍後重試請求,您也可以用一個指出伺服器暫時很忙的響應來拒絕請求。 線程池使用準則
1、不要對那些同步等待其它任務結果的任務排隊。這可能會導致上面所描述的那種形式的死結,在那種死結中,所有線程都被一些任務所佔用,這些任務依次等待排隊任務的結果,而這些任務又無法執行,因為所有的線程都很忙。
2、在時間可能很長的操作使用合用的線程時要小心。如果程式必須等待諸如 I/O 完成這樣的某個資源,那麼請指定最長的等待時間,以及隨後是失效還是將任務重新排隊以便稍後執行。這樣做保證了:通過將某個線程釋放給某個可能成功完成的任務,從而將最終取得某些進展
3、理解任務。要有效地調整線程池大小,您需要理解正在排隊的任務以及它們正在做什麼。它們是 CPU 限制的(CPU-bound)嗎。它們是 I/O 限制的(I/O-bound)嗎。您的答案將影響您如何調整應用程式。如果您有不同的任務類,這些類有著截然不同的特徵,那麼為不同任務類設定多個工作隊列可能會有意義,這樣可以相應地調整每個池。 線程池的使用情境
1、多個定時任務(任務之間沒有順序依賴關係)
2、並發測試
3、記錄日誌(前提是SSD,如果HDD只有一個磁頭,寫磁碟是效能瓶頸)
後面的文章我會針對幾種常見的線程池逐一說明