Java併發與多線程-詳解線程池

Java併發與多線程-詳解線程池

什麼是線程池?

在此,我們參考一下百科的定義:

線程池(英語:thread pool):一種線程使用模式。線程過多會帶來調度開銷,進而影響緩存局部性和整體性能。而線程池維護着多個線程,等待着監督管理者分配可併發執行的任務。這避免了在處理短時間任務時創建與銷燬線程的代價。

線程池的作用?

瞭解線程池作用之前,我們先來看一下,CPU的上下文切換,有多費時間:

拿一臺主頻2.6G的電腦來說,每秒可以執行 2.6*10^9個指令,每個指令只需要 0.38ns,爲了方便理解,我們把這個時間單位,換算成人類世界的1秒
一次 CPU 上下文切換(系統調用)需要大約 1500ns,也就是 1.5us(這個數字採用的是單核 CPU 線程平均時間),換算成人類時間大約是 65分鐘,嗯,也就是一個小時。我們也知道上下文切換是很耗時的行爲,畢竟每次浪費一個小時,也很讓人有罪惡感的。上下文切換更恐怖的事情在於,這段時間裏 CPU沒有做任何有用的計算,只是切換了兩個不同進程的寄存器和內存狀態;而且這個過程還破壞了緩存,讓後續的計算更加耗時。
備註:上下文切換(有時也稱做進程切換或任務切換)是指 CPU 從一個進程或線程切換到另一個進程或線程。

如果想讓程序運行的更快,我們需要減少CPU上下文切換的次數。
所以,需要避免創建不需要的線程,比如任務很少,但是創建了很多線程來處理,這樣會造成大量線程都處於等待狀態。使用線程池,可以有效避免類似的問題。線程池解決的核心問題就是資源管理問題。對使用到的線程進行統一管理,避免創建不必要的線程。

使用線程池,還可以帶來如下好處:

  • 降低資源消耗:通過池化技術重複利用已創建的線程,降低線程創建和銷燬造成的損耗。
  • 提高響應速度:任務到達時,無需等待線程創建即可立即執行。
  • 提高線程的可管理性:線程是稀缺資源,如果無限制創建,不僅會消耗系統資源,還會因爲線程的不合理分佈導致資源調度失衡,降低系統的穩定性。使用線程池可以進行統一的分配、調優和監控。
  • 提供更多更強大的功能:線程池具備可拓展性,允許開發人員向其中增加更多的功能。比如延時定時線程池ScheduledThreadPoolExecutor,就允許任務延期執行或定期執行。
    既然線程池有這麼多好處,那我們如何使用呢?
    首先,要用好線程池,需要知道線程池的生命週期以及線程池的主要參數配置。

線程池生命週期

ThreadPoolExecutor的運行狀態有5種:

  • RUNNING
  • SHUTDOWN
  • STOP
  • TIDYING
  • TERMINATED

源碼定義如下:
java.util.concurrent.ThreadPoolExecutor.ctl
文檔註釋摘錄

The runState provides the main lifecycle control, taking on values:
  RUNNING:  Accept new tasks and process queued tasks
(譯:接受新任務並處理排隊的任務)
SHUTDOWN: Don't accept new tasks, but process queued tasks
(譯:不接受新任務,但處理已排隊的任務)
STOP:     Don't accept new tasks, don't process queued tasks,
            and interrupt in-progress tasks
(譯:不接受新任務,不處理排隊的任務,並中斷正在進行的任務)
TIDYING:  All tasks have terminated, workerCount is zero,
            the thread transitioning to state TIDYING
            will run the terminated() hook method
(譯:所有任務已終止,workerCount爲零,線程轉換到 TIDYING 狀態,會運行terminated()鉤子方法)
TERMINATED: terminated() has completed
(譯:terminated()方法執行完畢)

在這裏插入圖片描述
圖:線程池5種狀態-流程圖

狀態 是否接受新任務 排隊任務的處理 其他
RUNNING 接受新任務 處理排隊的任務 正常運行
SHUTDOWN 不接受新任務 處理排隊的任務 不接受新任務,但處理已排隊的任務
STOP 不接受新任務 不處理排隊的任務 不接受新任務,不處理排隊的任務,並中斷正在進行的任務
TIDYING 不接受新任務 - 所有任務已終止
TERMINATED 不接受新任務 - terminated()方法執行完畢

詳解線程池參數

在這裏插入圖片描述
圖:線程池參數列表.png

  • 第一個參數設置核心線程數。默認情況下核心線程會一直存活。
  • 第二個參數設置最大線程數。決定線程池最多可以創建的多少線程。
  • 第三個參數和第四個參數用來設置線程空閒時間,和空閒時間的單位,當線程閒置超過空閒時間就會被銷燬。可以通過 allowCoreThreadTimeOut 方法來允許核心線程被回收。
  • 第五個參數設置緩衝隊列,上圖中左下方的三個隊列是設置線程池時常使用的緩衝隊列。其中 ArrayBlockingQueue 是一個有界隊列,就是指隊列有最大容量限制。LinkedBlockingQueue 是無界隊列,就是隊列不限制容量。最後一個是 SynchronousQueue,是一個同步隊列,內部沒有緩衝區。
  • 第六個參數設置線程池工廠方法,線程工廠用來創建新線程,可以用來對線程的一些屬性進行定製,例如線程的 group、線程名、優先級等。一般使用默認工廠類即可。
  • 第七個參數設置線程池滿時的拒絕策略。如上圖右下方所示有四種策略,Abort 策略在線程池滿後,提交新任務時會拋出 RejectedExecutionException,這個也是默認的拒絕策略。Discard 策略會在提交失敗時對任務直接進行丟棄。CallerRuns 策略會在提交失敗時,由提交任務的線程直接執行提交的任務。DiscardOldest 策略會丟棄最早提交的任務。
    接着,我們需要了解一下線程池的執行流程:

線程池執行流程

在這裏插入圖片描述
圖:線程池任務執行流程.png

  1. 向線程池提交任務時,會首先判斷線程池中的線程數是否大於設置的核心線程數,如果不大於,就創建一個核心線程來執行任務。
  2. 如果大於核心線程數,就會判斷緩衝隊列是否滿了,如果沒有滿,則放入隊列,等待線程空閒時執行任務。
  3. 如果隊列已經滿了,則判斷是否達到了線程池設置的最大線程數,如果沒有達到,就創建新線程來執行任務。
  4. 如果已經達到了最大線程數,則執行指定的拒絕策略。
    這裏需要注意隊列的判斷與最大線程數判斷的順序,不要搞反。

瞭解了理論,接下來,我們通過一個實戰,來實地觀察一下線程池任務的執行流程。

實戰源碼

  • 程序目的:觀察ThreadPoolExecutor的執行流程
    包含如下組成部分:
  • 一個監聽線程池狀態的類:MyMonitorThread,每1秒輸出一次線程池狀態。
  • 一個RejectedExecutionHandlerImpl,用來執行拒絕策略,會打印那些任務被拒絕了。
  • 一個工作線程定義:WorkerThread 會模擬2秒的任務執行耗時。
  • 主程序:WorkerPoolApplication 用來執行程序

MyMonitorThread.java

import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.stream.Collectors;

import lombok.extern.slf4j.Slf4j;

/**
 * <pre>
 * 監控線程池狀態(每隔N秒,輸出一次線程池的狀態)
 * 分別會打印如下信息:
 * </pre>
 * created at 2019-05-29 14:12
 * @author lerry
 */
@Slf4j
public class MyMonitorThread implements Runnable {

	/**
	 * 持有被監控的線程池對象
	 */
	private ThreadPoolExecutor threadPoolExecutor;

	/**
	 * 每隔多久執行一次
	 */
	private int delay;

	/**
	 * 如果爲false、則關閉監聽
	 */
	private boolean isRun = true;

	public MyMonitorThread(ThreadPoolExecutor threadPoolExecutor, int delay) {
		this.threadPoolExecutor = threadPoolExecutor;
		this.delay = delay;
	}

	public void shutDown() {
		this.isRun = false;
	}

	@Override
	public void run() {
		while (isRun) {
			// 獲取等待隊列
			BlockingQueue<Runnable> queue = this.threadPoolExecutor.getQueue();
			// 等待隊列轉爲僅存儲 指令名稱 的List
			List<String> queueList = queue.stream().map(r -> {
				if (r instanceof WorkerThread) {
					return ((WorkerThread) r).getCommand();
				}
				else {
					return "";
				}
			}).collect(Collectors.toList());
			// 日誌記錄線程池狀態
			/*
			 * poolSize:池中的當前線程數
			 * corePoolSize: 核心線程數
			 * Active:當前主動執行任務的近似線程數量
			 * Completed:已完成執行的任務的總數(由於任務和線程的狀態在計算過程中可能會動態變化,因此返回的值僅是一個近似值,而在連續的調用中不會降低。)
            * TaskCount:計劃執行的任務數
			 * queue:緩衝隊列
			 * isShutdown:如果此執行程序已關閉,則返回true
			 * isTerminated:觀察線程池是否終結
			 */
			log.info("poolSize/corePoolSize [{}/{}] Active: {}, Completed: {}, Task: {}, queue:{},isShutdown: {}, isTerminated: {}",
					this.threadPoolExecutor.getPoolSize(),
					this.threadPoolExecutor.getCorePoolSize(),
					this.threadPoolExecutor.getActiveCount(),
					this.threadPoolExecutor.getCompletedTaskCount(),
					this.threadPoolExecutor.getTaskCount(),
					queueList,
					this.threadPoolExecutor.isShutdown(),
					this.threadPoolExecutor.isTerminated());
			// 間隔N秒輸出一次線程池狀態
			try {
				Thread.sleep(delay * 1000);
			}
			catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
	}
}

RejectedExecutionHandlerImpl.java

import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;

import lombok.extern.slf4j.Slf4j;

/**
 * 還可以創建自己的 RejectedExecutionHandler 實現來處理沒有放在工作隊列裏的任務。
 * rejected:拒絕的
 * created at 2019-05-29 14:09
 * @author lerry
 */
@Slf4j
public class RejectedExecutionHandlerImpl implements RejectedExecutionHandler {
	@Override
	public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
		WorkerThread workerThread = null;
		if (r instanceof WorkerThread) {
			workerThread = (WorkerThread) r;
		}
		log.info("[{}] is rejected(被拒絕/駁回)", workerThread.getCommand());
	}
}

WorkerThread.java

/**
 * 工作線程
 * created at 2019-05-29 11:25
 * @author lerry
 */
@Slf4j
public class WorkerThread implements Runnable {

	/**
	 * 執行的任務編號
	 */
	private String command;

	/**
	 * 模擬工作線程的執行耗時(單位:秒)
	 */
	private int executeDuration;

	public WorkerThread(String command) {
		this.command = command;
	}

	public WorkerThread(String command, int executeDuration) {
		this.command = command;
		this.executeDuration = executeDuration;
	}

	@Override
	public void run() {
		log.info("{}  Start. Command = {}", Thread.currentThread().getName(), command);
		processCommand();
		log.info("{}  End. Command = {}", Thread.currentThread().getName(), command);
	}

	/**
	 * 模擬任務執行耗時
	 */
	private void processCommand() {
		try {
			Thread.sleep(executeDuration * 1000);
		}
		catch (InterruptedException e) {
			e.printStackTrace();
		}
	}

	public String getCommand() {
		return command;
	}
}

WorkerPoolApplication.java

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

import lombok.extern.slf4j.Slf4j;

/**
 * <pre>
 * 程序目的:觀察ThreadPoolExecutor的執行流程
 * 包含:一個監聽線程池狀態的類:MyMonitorThread,每1秒輸出一次線程池狀態。
 * 一個RejectedExecutionHandlerImpl,用來執行拒絕策略,會打印那些任務被拒絕了。
 * 一個工作線程定義:WorkerThread 會模擬2秒的執行時間。
 * 主程序:WorkerPoolApplication 用來執行程序
 * 請注意:在初始化 ThreadPoolExecutor 時,核心線程數大小設爲2、最大線程數設爲5、緩衝隊列大小設爲2。
 * 所以,一共提交10個任務的情況下,前2個任務,線程池會創建核心線程、並執行該任務;
 * 第3——4個任務,會進入等待隊列,隊列滿了之後,
 * 第5——7個任務,會繼續創建線程、執行該任務;
 * 最後的第8、9、10個任務,會執行拒絕策略,交由RejectedExecutionHandlerImpl 處理,被rejected。
 * </pre>
 * created at 2019-05-29 14:16
 * @author lerry
 */
@Slf4j
public class WorkerPoolApplication {

	public static void main(String[] args) throws InterruptedException {
		RejectedExecutionHandlerImpl rejectedExecutionHandler = new RejectedExecutionHandlerImpl();

		ThreadFactory threadFactory = Executors.defaultThreadFactory();

		// 線程池
		/*
		 *  corePoolSize:核心線程數 默認情況下核心線程會一直存活
		 *	maximumPoolSize:最大線程數 決定線程池最多可以創建的多少線程
		 *	keepAliveTime、unit:線程空閒時間,和空閒時間的單位 當線程閒置超過空閒時間就會被銷燬
		 *	workQueue:緩衝隊列
		 *  threadFactory:設置線程池工廠方法,線程工廠用來創建新線程,可以用來對線程的一些屬性進行定製,例如線程的 group、線程名、優先級等
		 *  RejectedExecutionHandler: 設置線程池滿時的拒絕策略
		 */
		ThreadPoolExecutor executorPool = new ThreadPoolExecutor(
				2,
				5,
				10,
				TimeUnit.SECONDS,
				new ArrayBlockingQueue<Runnable>(2),
				threadFactory,
				rejectedExecutionHandler
		);

		// 啓動監控線程
		MyMonitorThread monitor = new MyMonitorThread(executorPool, 1);
		new Thread(monitor).start();

		for (int i = 1; i <= 10; i++) {
			executorPool.execute(new WorkerThread("cmd" + i, 2));
		}

		log.info("關閉線程池");
		executorPool.shutdown();

		for (; ; ) {
			// 線程池終止後,關閉監控線程
			if (executorPool.isTerminated()) {
				// 等待監視器打印線程池 isTerminated: true 的狀態
				Thread.sleep(1_000);
				monitor.shutDown();
				break;
			}
		}// end for
	}
}

執行結果

2020-07-05 11:00:10.287 [pool-1-thread-3] INFO  WorkerThread - pool-1-thread-3  Start. Command = cmd5
2020-07-05 11:00:10.287 [pool-1-thread-4] INFO  WorkerThread - pool-1-thread-4  Start. Command = cmd6
2020-07-05 11:00:10.287 [pool-1-thread-5] INFO  WorkerThread - pool-1-thread-5  Start. Command = cmd7
2020-07-05 11:00:10.287 [pool-1-thread-2] INFO  WorkerThread - pool-1-thread-2  Start. Command = cmd2
2020-07-05 11:00:10.287 [pool-1-thread-1] INFO  WorkerThread - pool-1-thread-1  Start. Command = cmd1
2020-07-05 11:00:10.287 [main    ] INFO  RejectedExecutionHandlerImpl - [cmd8] is rejected(被拒絕/駁回)
2020-07-05 11:00:10.294 [main    ] INFO  RejectedExecutionHandlerImpl - [cmd9] is rejected(被拒絕/駁回)
2020-07-05 11:00:10.294 [main    ] INFO  RejectedExecutionHandlerImpl - [cmd10] is rejected(被拒絕/駁回)
2020-07-05 11:00:10.294 [main    ] INFO  WorkerPoolApplication - 關閉線程池
2020-07-05 11:00:10.359 [Thread-0] INFO  MyMonitorThread - poolSize/corePoolSize [5/2] Active: 5, Completed: 0, Task: 7, queue:[cmd3, cmd4],isShutdown: true, isTerminated: false
2020-07-05 11:00:11.364 [Thread-0] INFO  MyMonitorThread - poolSize/corePoolSize [5/2] Active: 5, Completed: 0, Task: 7, queue:[cmd3, cmd4],isShutdown: true, isTerminated: false
2020-07-05 11:00:12.294 [pool-1-thread-5] INFO  WorkerThread - pool-1-thread-5  End. Command = cmd7
2020-07-05 11:00:12.294 [pool-1-thread-4] INFO  WorkerThread - pool-1-thread-4  End. Command = cmd6
2020-07-05 11:00:12.294 [pool-1-thread-5] INFO  WorkerThread - pool-1-thread-5  Start. Command = cmd3
2020-07-05 11:00:12.294 [pool-1-thread-2] INFO  WorkerThread - pool-1-thread-2  End. Command = cmd2
2020-07-05 11:00:12.294 [pool-1-thread-3] INFO  WorkerThread - pool-1-thread-3  End. Command = cmd5
2020-07-05 11:00:12.294 [pool-1-thread-1] INFO  WorkerThread - pool-1-thread-1  End. Command = cmd1
2020-07-05 11:00:12.294 [pool-1-thread-4] INFO  WorkerThread - pool-1-thread-4  Start. Command = cmd4
2020-07-05 11:00:12.366 [Thread-0] INFO  MyMonitorThread - poolSize/corePoolSize [2/2] Active: 2, Completed: 5, Task: 7, queue:[],isShutdown: true, isTerminated: false
2020-07-05 11:00:13.370 [Thread-0] INFO  MyMonitorThread - poolSize/corePoolSize [2/2] Active: 2, Completed: 5, Task: 7, queue:[],isShutdown: true, isTerminated: false
2020-07-05 11:00:14.297 [pool-1-thread-4] INFO  WorkerThread - pool-1-thread-4  End. Command = cmd4
2020-07-05 11:00:14.297 [pool-1-thread-5] INFO  WorkerThread - pool-1-thread-5  End. Command = cmd3
2020-07-05 11:00:14.374 [Thread-0] INFO  MyMonitorThread - poolSize/corePoolSize [0/2] Active: 0, Completed: 7, Task: 7, queue:[],isShutdown: true, isTerminated: true

執行結果解讀

首先,工作線程5、6、7和2、1被創建並啓動,一共10個任務,但是線程池最大線程數設置的是5,等待隊列大小設置的是2,剩下的三個線程(8、9、10),被執行拒絕策略。
這時,我們手動調用shutdown(),嘗試關閉線程池。因爲還有線程未執行完,等待隊列中也有任務,所以線程池會等待事情全部處理好後,再關閉。
這時,通過監控日誌,可以發現:

poolSize/corePoolSize [5/2] Active: 5, Completed: 0, Task: 7, queue:[cmd3, cmd4],isShutdown: true, isTerminated: false

當前池中線程數爲5、計劃執行的線程數爲7,3、4號工作線程在隊列中。
接着,7和6執行完畢,隊列中的3號任務開始執行,2、5、1也相繼執行完畢,隊列中的4號任務開始執行。這時再次觀察線程池狀態:

poolSize/corePoolSize [2/2] Active: 2, Completed: 5, Task: 7, queue:[],isShutdown: true, isTerminated: false

可以看到,當前池中線程數爲2、已完成執行的任務的總數爲5,計劃執行的線程數爲7,隊列爲空。
繼續,4號和3號線程執行完畢,最後查看線程池狀態:

poolSize/corePoolSize [0/2] Active: 0, Completed: 7, Task: 7, queue:[],isShutdown: true, isTerminated: true

可以看到,當前池中線程數爲0、已完成執行的任務的總數爲7,計劃執行的線程數爲7,隊列爲空。線程池關閉。
我們發現、超過線程池核心線程數的、小於最大線程數的這部分線程,優先於等待隊列中的任務執行。

目錄結構

在這裏插入圖片描述
圖:本文目錄結構

參考資料

線程池_百度百科
讓 CPU 告訴你硬盤和網絡到底有多慢 | Cizixs Write Here
多線程上下文切換 - 五月的倉頡 - 博客園
Java線程池實現原理及其在美團業務中的實踐 - 美團技術團隊

環境說明

  • java -version
java version "1.8.0_251"
Java(TM) SE Runtime Environment (build 1.8.0_251-b08)
Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, mixed mode)

  • OS:macOS High Sierra 10.13.4
  • 日誌:logback
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章