這一篇的內容主要來自於《java並發編程實戰》,有一說一,看這種寫的很專業的書不是很輕松,也沒辦法直接提高多少開發的能力,但是卻能更加夯實基礎,就像玩war3,熟練的基本功並不能讓你快速地與對方拉開差距,但是卻能再每一次團戰中積累優勢。
一、線程池的基礎
1、線程池的相關屬性:
-
corePoolSize(線程池的基本大小):當提交一個任務到線程池時,線程池會創建一個線程來執行任務,即使其他空閑的基本線程能夠執行新任務也會創建線程,等到需要執行的任務數大於線程池基本大小時就不再創建。如果調用了線程池的prestartAllCoreThreads方法,線程池會提前創建並啟動所有基本線程。
-
workQueue任務隊列):用於保存等待執行的任務的阻塞隊列。可以選擇以下幾個阻塞隊列。
- ArrayBlockingQueue:是一個基於數組結構的有界阻塞隊列,此隊列按 FIFO(先進先出)原則對元素進行排序。
- LinkedBlockingQueue:一個基於鏈表結構的阻塞隊列,此隊列按FIFO (先進先出) 排序元素,吞吐量通常要高於ArrayBlockingQueue。靜態工廠方法Executors.newFixedThreadPool()使用了這個隊列
- SynchronousQueue:一個不存儲元素的阻塞隊列。每個插入操作必須等到另一個線程調用移除操作,否則插入操作一直處於阻塞狀態,吞吐量通常要高於LinkedBlockingQueue,靜態工廠方法Executors.newCachedThreadPool使用了這個隊列。
- PriorityBlockingQueue:一個具有優先級的無限阻塞隊列。
-
maximumPoolSize(線程池最大大小):線程池允許創建的最大線程數。如果隊列滿了,並且已創建的線程數小於最大線程數,則線程池會再創建新的線程執行任務。值得注意的是如果使用了無界的任務隊列這個參數就沒什么效果。
-
ThreadFactory:用於設置創建線程的工廠,可以通過線程工廠給每個創建出來的線程做些更有意義的事情,比如設置daemon和優先級等等
-
RejectedExecutionHandler(飽和策略):當隊列和線程池都滿了,說明線程池處於飽和狀態,那么必須采取一種策略處理提交的新任務。這個策略默認情況下是AbortPolicy,表示無法處理新任務時拋出異常。以下是JDK1.5提供的四種策略。
- AbortPolicy:直接拋出異常。
- CallerRunsPolicy:只用調用者所在線程來運行任務。
- DiscardOldestPolicy:丟棄隊列里最近的一個任務,並執行當前任務。
- DiscardPolicy:不處理,丟棄掉。
也可以根據應用場景需要來實現RejectedExecutionHandler接口自定義策略。如記錄日志或持久化不能處理的任務。
- keepAliveTime(線程活動保持時間):線程池的工作線程空閑后,保持存活的時間。所以如果任務很多,並且每個任務執行的時間比較短,可以調大這個時間,提高線程的利用率。
- TimeUnit(線程活動保持時間的單位):可選的單位有天(DAYS),小時(HOURS),分鍾(MINUTES),毫秒(MILLISECONDS),微秒(MICROSECONDS, 千分之一毫秒)和毫微秒(NANOSECONDS, 千分之一微秒)。
2、線程池的執行流程
- 1 如果運行的線程少於corePoolSize,則會添加新的線程,而不進行排隊。
- 2 如果運行的線程等於或多於 corePoolSize,則 Executor 始終首選將請求加入隊列,而不添加新的線程。
- 3 如果無法將請求加入隊列(隊列已滿),則創建新的線程,除非創建此線程超出 maximumPoolSize,如果超過,在這種情況下,新的任務將被拒絕。
3、線程池排隊有三種通用策略
- 同步移交。隊列的默認選項是同步移交,它將任務直接提交給線程而不保持它們。在此,如果不存在可用於立即運行任務的線程,會構造一個新的線程。此策略可以避免在處理可能具有內部依賴性的請求集時出現鎖。同步移交通常要求無界的maximumPoolSizes以避免拒絕新提交的任務。
但是,同步移交不適合管理資源的分配,除非特殊情況不推薦使用
- 無界隊列。使用的是LinkedBlockingQueue類實現,不需要事先制定大小,也是按照“先進先出”算法處理任務。無界隊列很好理解,就是和有界隊列相反,使用無界隊列的線程池,當有新任務提交時,如果線程池里有空閑線程,就分配線程立刻執行任務,否則就把任務放到無界任務隊列中等待,如果線程池中一直沒有空閑線程,但是新的任務又一直不停的提交上來,那么這些任務全部會被掛到等待隊列中,一直到內存全部消耗完。
- 有界隊列。當使用有限的 maximumPoolSizes 時,有界隊列(如 ArrayBlockingQueue)有助於防止資源耗盡,但是可能較難調整和控制。隊列大小和最大池大小可能需要相互折衷:使用大型隊列和小型池可以最大限度地降低CPU使用率、操作系統資源和上下文切換開銷,但是可能導致人工降低吞吐量。使用小型隊列通常要求較大的池大小,CPU 使用率較高,但是可能遇到不可接受的調度開銷,這樣也會降低吞吐量。
我們最后可以這樣理解:
使用無界隊列可能會耗盡系統資源。 |
使用有界隊列可能不能很好的滿足性能,需要調節線程數和隊列大小。 |
線程數也有開銷,所以需要根據不同應用進行調節。 |
二、設置線程池的大小
線程池的大小一直是大家很關心的問題,理想的大小取決於被提交任務的類型以及所部署的系統,代碼中通常不會固定線程池的大小,而通過某種配置,或者Runtime.getRuntime().availableProcessors() 來動態計算。
Runtime.getRuntime().availableProcessors()這個代碼大家可能不算太熟悉,這個方法可以獲取CPU的數目。
- 如果是CPU密集型應用,則線程池大小設置為N+1(或者是N),線程的應用場景:主要是復雜算法
- 如果是IO密集型應用,則線程池大小設置為2N+1(或者是2N),線程的應用場景:主要是:數據庫數據的交互,文件上傳下載,網絡數據傳輸等等。
綜合起來最佳線程數目 = ((線程等待時間+線程CPU時間)/線程CPU時間 +1)* CPU數目。
至於+1的原因,則是當線程偶爾由於缺失故障或者其他原因而暫停時,這個額外的線程也能確保CPU的時鍾周期不會被浪費(剩余價值壓榨的滿滿的)。
當然,CPU並不是唯一影響線程池大小的資源,還應該考慮內存、文件句柄、套接字句柄、數據庫連接等原因。
舉個例子,比如平均每個線程CPU運行時間為0.5s,而線程等待時間(非CPU運行時間,比如IO)為1.5s,CPU核心數為12,那么根據上面這個公式估算得到: ((0.5+1.5)/0.5+1)12=60
除了線程池大小上的顯示設置以外,還可能由於其他資源上的約束而存在一些隱式限制,如應用程序使用一個包含10個連接的JDBC連接池,並且每個任務需要一個數據庫連接,那么線程池就最好只有10個連接,因為當超過10個任務時,新的任務就需要其他任務釋放連接。
三、避免線程池的飢餓死鎖
1、線程的飢餓死鎖
線程池中,如果任務依賴於其他任務,那么就有可能產生死鎖。在單線程的Executor中,如果一個任務將另一個任務提交到同一個Executor,並且等待這個被提交的任務的結果,那么就通常會產生死鎖,這種情況被稱為線程的飢餓死鎖。
對於線程池來說,只要池任務開始了無限期阻塞,例如某個任務的目的是等待一些資源或條件,但是只有另一個池任務的執行才能使那些條件成立。除非能保證線程池足夠大,否則會發生線程飢餓死鎖。
下文清晰地展示了線程飢餓死鎖的示例,隊列為阻塞隊列,因為線程池為是單線程的,當隊列為空時,getHeader 將會一直阻塞等待 putHeader 執行。這就是任務之間相互依賴的飢餓死鎖。
public class ThreadDeadlock {
//創建一個隊列、假設是存放頭文件的地方
private static BlockingQueue root = new ArrayBlockingQueue(10);
public static void main(String[] args) {
//創建一個固定線程的線程池
ExecutorService service = Executors.newSingleThreadExecutor();
service.submit(new getHeader());
service.submit(new putHeader(1));
service.shutdown();
}
static class putHeader implements Callable {
private int val;
public putHeader(int value) {
val = value;
}
@Override
public Object call() throws Exception {
System.out.println("放置頭文件");
//往阻塞隊列增加元素
root.put(1);
return "頭文件";
}
}
static class getHeader implements Callable {
@Override
public Object call() throws Exception {
System.out.println("獲取頭文件");
//取出阻塞隊列的值,如果沒有則會阻塞
int value = (int) root.take();
return "頭文件";
}
}
}
2、運行時間較長的任務
如果任務阻塞的時間過長,那么即使不出現死鎖,線程池的響應性也會變得更糟。執行時間較長的任務不會造成線程池的堵塞,甚至還會增加執行時間較短任務的服務時間。如果線程池中的線程數量遠小於在穩定狀態下執行的任務的數量,那么到最后可能所有的線程都會運行這些執行時間較長的任務,從而影響整體的響應性。
可以通過限定任務等待資源的時間,不要去無限制地等待。在平台類庫的大多數可阻塞方法中,都同時定義了限時版本和無限時版本,列如Thread.join、BlockingQueue.out、CountDownLatch.await以及Selector.select等。如果等待超時,那么可以把任務標識為失敗,然后中止任務或者將任務重新放回隊列。這樣,無論任務的最終結果是否是成功,這種辦法都能保證任務可以順利執行而不會被阻塞住,並將線程釋放出來執行一些能更快完成的任務。
當然了,如果線程池中總是充滿了被阻塞的任務,也說明線程池設計的規模小了。
我是南橘,一名學習時長兩年的程序員,希望這篇文章能幫助到大家。下面是我的微信二維碼,有興趣的朋友可以一起交流學習。
微信公眾號,有興趣的小伙伴來關注一下吧~我最新的內容都會在這里發布