前言
在Java中,我們可以利用多線程來最大化地壓榨CPU多核計算的能力。但是,線程本身是把雙刃劍,我們需要知道它的利弊,才能在實際系統中遊刃有餘地運用。
在進入主題之前,我們先了解一下線程池的基本概念。
線程池,本質上是一種對象池,用於管理線程資源。
在任務執行前,需要從線程池中拿出線程來執行。
在任務執行完成之後,需要把線程放回線程池。
通過線程的這種反覆利用機制,可以有效地避免直接創建線程所帶來的壞處。
我們先來看看線程池帶來了哪些好處。
- 降低資源的消耗。線程本身是一種資源,創建和銷燬線程會有CPU開銷;創建的線程也會佔用一定的內存。
- 提高任務執行的響應速度。任務執行時,可以不必等到線程創建完之後再執行。
- 提高線程的可管理性。線程不能無限制地創建,需要進行統一的分配、調優和監控。
接下來,我們看看不使用線程池有哪些壞處。
- 頻繁的線程創建和銷燬會佔用更多的CPU和內存
- 頻繁的線程創建和銷燬會對GC產生比較大的壓力
- 線程太多,線程切換帶來的開銷將不可忽視
- 線程太少,多核CPU得不到充分利用,是一種浪費
因此,我們有必要對線程池進行比較完整地說明,以便能對線程池進行正確地治理。
線程池實現原理
線程池主要處理流程
通過上圖,我們看到了線程池的主要處理流程。我們的關注點在於,任務提交之後是怎麼執行的。大致如下:
- 判斷核心線程池是否已滿,如果不是,則創建線程執行任務
- 如果核心線程池滿了,判斷隊列是否滿了,如果隊列沒滿,將任務放在隊列中
- 如果隊列滿了,則判斷線程池是否已滿,如果沒滿,創建線程執行任務
- 如果線程池也滿了,則按照拒絕策略對任務進行處理
在jdk裏面,我們可以將處理流程描述得更清楚一點。來看看ThreadPoolExecutor
的處理流程。
ThreadPoolExecutor的處理流程
我們將概念做一下映射。
corePool
-> 核心線程池maximumPool
-> 線程池BlockQueue
-> 隊列RejectedExecutionHandler
-> 拒絕策略
入門級例子
爲了更直觀地理解線程池,我們通過一個例子來宏觀地瞭解一下線程池用法。
public class ThreadPoolTest {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
System.out.println("thread id is: " + Thread.currentThread().getId());
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
}
}
在這個例子中,我們首先創建了一個固定長度爲5的線程池。然後使用循環的方式往線程池中提交了10個任務,每個任務休眠1秒。在任務休眠之前,將任務所在的線程id進行打印輸出。
所以,理論上只會打印5個不同的線程id,且每個線程id會被打印2次。是不是這樣的呢?檢驗真理最好的方式就是運行一下。我們看看執行結果如何。
Executors
Executors
是一個線程池工廠,提供了很多的工廠方法,我們來看看它大概能創建哪些線程池。
// 創建單一線程的線程池
public static ExecutorService newSingleThreadExecutor();
// 創建固定數量的線程池
public static ExecutorService newFixedThreadPool(int nThreads);
// 創建帶緩存的線程池
public static ExecutorService newCachedThreadPool();
// 創建定時調度的線程池
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize);
// 創建流式(fork-join)線程池
public static ExecutorService newWorkStealingPool();
1. 創建單一線程的線程池
顧名思義,這個線程池只有一個線程。若多個任務被提交到此線程池,那麼會被緩存到隊列(隊列長度爲Integer.MAX_VALUE
)。當線程空閒的時候,按照FIFO的方式進行處理。
2. 創建固定數量的線程池
和創建單一線程的線程池
類似,只是這兒可以並行處理任務的線程數更多一些罷了。若多個任務被提交到此線程池,會有下面的處理過程。
- 如果線程的數量未達到指定數量,則創建線程來執行任務
- 如果線程池的數量達到了指定數量,並且有線程是空閒的,則取出空閒線程執行任務
- 如果沒有線程是空閒的,則將任務緩存到隊列(隊列長度爲
Integer.MAX_VALUE
)。當線程空閒的時候,按照FIFO的方式進行處理
3. 創建帶緩存的線程池
這種方式創建的線程池,核心線程池的長度爲0,線程池最大長度爲Integer.MAX_VALUE
。由於本身使用SynchronousQueue
作爲等待隊列的緣故,導致往隊列裏面每插入一個元素,必須等待另一個線程從這個隊列刪除一個元素。
4. 創建定時調度的線程池
和上面3個工廠方法返回的線程池類型有所不同,它返回的是ScheduledThreadPoolExecutor
類型的線程池。平時我們實現定時調度功能的時候,可能更多的是使用第三方類庫,比如:quartz等。但是對於更底層的功能,我們仍然需要了解。
我們寫一個例子來看看如何使用。
public class ThreadPoolTest {
public static void main(String[] args) {
ScheduledExecutorService executor = Executors.newScheduledThreadPool(2);
// 定時調度,每個調度任務會至少等待`period`的時間,
// 如果任務執行的時間超過`period`,則等待的時間爲任務執行的時間
executor.scheduleAtFixedRate(() -> {
try {
Thread.sleep(10000);
System.out.println(System.currentTimeMillis() / 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, 0, 2, TimeUnit.SECONDS);
// 定時調度,第二個任務執行的時間 = 第一個任務執行時間 + `delay`
executor.scheduleWithFixedDelay(() -> {
try {
Thread.sleep(5000);
System.out.println(System.currentTimeMillis() / 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}, 0, 2, TimeUnit.SECONDS);
// 定時調度,延遲`delay`後執行,且只執行一次
executor.schedule(() -> System.out.println("5 秒之後執行 schedule"), 5, TimeUnit.SECONDS);
}
}
scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)
,定時調度,每個調度任務會至少等待period
的時間,如果任務執行的時間超過period
,則等待的時間爲任務執行的時間scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit)
,定時調度,第二個任務執行的時間 = 第一個任務執行時間 +delay
schedule(Runnable command, long delay, TimeUnit unit)
,定時調度,延遲delay
後執行,且只執行一次
手動創建線程池
理論上,我們可以通過Executors
來創建線程池,這種方式非常簡單。但正是因爲簡單,所以限制了線程池的功能。比如:無長度限制的隊列,可能因爲任務堆積導致OOM,這是非常嚴重的bug,應儘可能地避免。怎麼避免?歸根結底,還是需要我們通過更底層的方式來創建線程池。
拋開定時調度的線程池不管,我們看看ThreadPoolExecutor
。它提供了好幾個構造方法,但是最底層的構造方法卻只有一個。那麼,我們就從這個構造方法着手分析。
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler);
這個構造方法有7個參數,我們逐一來進行分析。
corePoolSize
,線程池中的核心線程數maximumPoolSize
,線程池中的最大線程數keepAliveTime
,空閒時間,當線程池數量超過核心線程數時,多餘的空閒線程存活的時間,即:這些線程多久被銷燬。unit
,空閒時間的單位,可以是毫秒、秒、分鐘、小時和天,等等workQueue
,等待隊列,線程池中的線程數超過核心線程數時,任務將放在等待隊列,它是一個BlockingQueue
類型的對象threadFactory
,線程工廠,我們可以使用它來創建一個線程handler
,拒絕策略,當線程池和等待隊列都滿了之後,需要通過該對象的回調函數進行回調處理
這些參數裏面,基本類型的參數都比較簡單,我們不做進一步的分析。我們更關心的是workQueue
、threadFactory
和handler
,接下來我們將進一步分析。
1. 等待隊列-workQueue
等待隊列是BlockingQueue
類型的,理論上只要是它的子類,我們都可以用來作爲等待隊列。
同時,jdk內部自帶一些阻塞隊列,我們來看看大概有哪些。
ArrayBlockingQueue
,隊列是有界的,基於數組實現的阻塞隊列LinkedBlockingQueue
,隊列可以有界,也可以無界。基於鏈表實現的阻塞隊列SynchronousQueue
,不存儲元素的阻塞隊列,每個插入操作必須等到另一個線程調用移除操作,否則插入操作將一直處於阻塞狀態。該隊列也是Executors.newCachedThreadPool()
的默認隊列PriorityBlockingQueue
,帶優先級的無界阻塞隊列
通常情況下,我們需要指定阻塞隊列的上界(比如1024)。另外,如果執行的任務很多,我們可能需要將任務進行分類,然後將不同分類的任務放到不同的線程池中執行。
2. 線程工廠-threadFactory
ThreadFactory
是一個接口,只有一個方法。既然是線程工廠,那麼我們就可以用它生產一個線程對象。來看看這個接口的定義。
public interface ThreadFactory {
/**
* Constructs a new {@code Thread}. Implementations may also initialize
* priority, name, daemon status, {@code ThreadGroup}, etc.
*
* @param r a runnable to be executed by new thread instance
* @return constructed thread, or {@code null} if the request to
* create a thread is rejected
*/
Thread newThread(Runnable r);
}
Executors
的實現使用了默認的線程工廠-DefaultThreadFactory
。它的實現主要用於創建一個線程,線程的名字爲pool-{poolNum}-thread-{threadNum}
。
static class DefaultThreadFactory implements ThreadFactory {
private static final AtomicInteger poolNumber = new AtomicInteger(1);
private final ThreadGroup group;
private final AtomicInteger threadNumber = new AtomicInteger(1);
private final String namePrefix;
DefaultThreadFactory() {
SecurityManager s = System.getSecurityManager();
group = (s != null) ? s.getThreadGroup() :
Thread.currentThread().getThreadGroup();
namePrefix = "pool-" +
poolNumber.getAndIncrement() +
"-thread-";
}
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r,
namePrefix + threadNumber.getAndIncrement(),
0);
if (t.isDaemon())
t.setDaemon(false);
if (t.getPriority() != Thread.NORM_PRIORITY)
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
}
很多時候,我們需要自定義線程名字。我們只需要自己實現ThreadFactory
,用於創建特定場景的線程即可。
3. 拒絕策略-handler
所謂拒絕策略,就是當線程池滿了、隊列也滿了的時候,我們對任務採取的措施。或者丟棄、或者執行、或者其他...
jdk自帶4種拒絕策略,我們來看看。
CallerRunsPolicy
// 在調用者線程執行AbortPolicy
// 直接拋出RejectedExecutionException
異常DiscardPolicy
// 任務直接丟棄,不做任何處理DiscardOldestPolicy
// 丟棄隊列裏最舊的那個任務,再嘗試執行當前任務
這四種策略各有優劣,比較常用的是DiscardPolicy
,但是這種策略有一個弊端就是任務執行的軌跡不會被記錄下來。所以,我們往往需要實現自定義的拒絕策略, 通過實現RejectedExecutionHandler
接口的方式。
提交任務的幾種方式
往線程池中提交任務,主要有兩種方法,execute()
和submit()
。
execute()
用於提交不需要返回結果的任務,我們看一個例子。
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.execute(() -> System.out.println("hello"));
}
submit()
用於提交一個需要返回果的任務。該方法返回一個Future
對象,通過調用這個對象的get()
方法,我們就能獲得返回結果。get()
方法會一直阻塞,直到返回結果返回。另外,我們也可以使用它的重載方法get(long timeout, TimeUnit unit)
,這個方法也會阻塞,但是在超時時間內仍然沒有返回結果時,將拋出異常TimeoutException
。
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Future<Long> future = executor.submit(() -> {
System.out.println("task is executed");
return System.currentTimeMillis();
});
System.out.println("task execute time is: " + future.get());
}
關閉線程池
在線程池使用完成之後,我們需要對線程池中的資源進行釋放操作,這就涉及到關閉功能。我們可以調用線程池對象的shutdown()
和shutdownNow()
方法來關閉線程池。
這兩個方法都是關閉操作,又有什麼不同呢?
shutdown()
會將線程池狀態置爲SHUTDOWN
,不再接受新的任務,同時會等待線程池中已有的任務執行完成再結束。shutdownNow()
會將線程池狀態置爲SHUTDOWN
,對所有線程執行interrupt()
操作,清空隊列,並將隊列中的任務返回回來。
另外,關閉線程池涉及到兩個返回boolean的方法,isShutdown()
和isTerminated
,分別表示是否關閉和是否終止。
如何正確配置線程池的參數
前面我們講到了手動創建線程池涉及到的幾個參數,那麼我們要如何設置這些參數纔算是正確的應用呢?實際上,需要根據任務的特性來分析。
- 任務的性質:CPU密集型、IO密集型和混雜型
- 任務的優先級:高中低
- 任務執行的時間:長中短
- 任務的依賴性:是否依賴數據庫或者其他系統資源
不同的性質的任務,我們採取的配置將有所不同。在《Java併發編程實踐》中有相應的計算公式。
通常來說,如果任務屬於CPU密集型,那麼我們可以將線程池數量設置成CPU的個數,以減少線程切換帶來的開銷。如果任務屬於IO密集型,我們可以將線程池數量設置得更多一些,比如CPU個數*2。
PS:我們可以通過
Runtime.getRuntime().availableProcessors()
來獲取CPU的個數。
線程池監控
如果系統中大量用到了線程池,那麼我們有必要對線程池進行監控。利用監控,我們能在問題出現前提前感知到,也可以根據監控信息來定位可能出現的問題。
那麼我們可以監控哪些信息?又有哪些方法可用於我們的擴展支持呢?
首先,ThreadPoolExecutor
自帶了一些方法。
long getTaskCount()
,獲取已經執行或正在執行的任務數long getCompletedTaskCount()
,獲取已經執行的任務數int getLargestPoolSize()
,獲取線程池曾經創建過的最大線程數,根據這個參數,我們可以知道線程池是否滿過int getPoolSize()
,獲取線程池線程數int getActiveCount()
,獲取活躍線程數(正在執行任務的線程數)
其次,ThreadPoolExecutor
留給我們自行處理的方法有3個,它在ThreadPoolExecutor
中爲空實現(也就是什麼都不做)。
protected void beforeExecute(Thread t, Runnable r)
// 任務執行前被調用protected void afterExecute(Runnable r, Throwable t)
// 任務執行後被調用protected void terminated()
// 線程池結束後被調用
針對這3個方法,我們寫一個例子。
public class ThreadPoolTest {
public static void main(String[] args) {
ExecutorService executor = new ThreadPoolExecutor(1, 1, 1, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1)) {
@Override protected void beforeExecute(Thread t, Runnable r) {
System.out.println("beforeExecute is called");
}
@Override protected void afterExecute(Runnable r, Throwable t) {
System.out.println("afterExecute is called");
}
@Override protected void terminated() {
System.out.println("terminated is called");
}
};
executor.submit(() -> System.out.println("this is a task"));
executor.shutdown();
}
}
輸出結果如下:
beforeExecute is called
this is a task
afterExecute is called
terminated is called
一個特殊的問題
任何代碼在使用的時候都可能遇到問題,線程池也不例外。樓主在現實的系統中就遇到過很奇葩的問題。我們來看一個例子。
public class ThreadPoolTest {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
executor.submit(new DivTask(100, i));
}
}
static class DivTask implements Runnable {
int a, b;
public DivTask(int a, int b) {
this.a = a;
this.b = b;
}
@Override public void run() {
double result = a / b;
System.out.println(result);
}
}
}
該代碼執行的結果如下。
我們循環了5次,理論上應該有5個結果被輸出。可是最終的執行結果卻很讓人很意外--只有4次輸出。我們進一步分析發現,當第一次循環,除數爲0時,理論上應該拋出異常纔對,但是這兒卻沒有,異常被莫名其妙地吞掉了!
這又是爲什麼呢?
我們進一步看看submit()
方法,這個方法是一個非阻塞方法,有一個返回對象,返回的是Future
對象。那麼我們就猜測,會不會是因爲沒有對Future
對象做處理導致的。
我們將代碼微調一下,重新運行,異常信息終於打印出來了。
for (int i = 0; i < 5; i++) {
Future future= executor.submit(new DivTask(100, i));
try {
future.get();
} catch (Exception e) {
e.printStackTrace();
}
}
PS:在使用
submit()
的時候一定要注意它的返回對象Future
,爲了避免任務執行異常被吞掉的問題,我們需要調用Future.get()
方法。另外,使用execute()
將不會出現這種問題。
總結
通過這篇文章,我們已經對Java線程池有了一個比較全面和深入的理解。根據前人的經驗,我們需要注意下面幾點:
- 儘量使用手動的方式創建線程池,避免使用
Executors
工廠類 - 根據場景,合理設置線程池的各個參數,包括線程池數量、隊列、線程工廠和拒絕策略
- 在調線程池
submit()
方法的時候,一定要儘量避免任務執行異常被吞掉的問題