1. 事件背景
最近公司開發了一個新功能:統計有關聯課程的學習信息,數據是每天晚上12點開始統計。由於數據量有點大,所以每次統計當天的任務需要差不多10個小時。運營人員需要第二天就看到數據,但是每天10點後才能看到確實有點不合理。於是組長讓我把代碼統計任務程序時間縮短一下。
2.案例還原演示
下面是我通過簡單代碼大概模擬的統計代碼(當然和真實代碼不是一樣的),主要有2個核心的方法:一個是獲取所有的課程信息,一個是根據課程Id獲取有關聯課程的統計信息(課程單元數量和所有關聯課程學習總人數)。
爲了方便演示獲取所有的課程信息和根據課程Id獲取有關聯課程的統計信息 沒有對接數據庫,而是採用來寫死的數據。
其中 獲取所有的課程信息 中課程信息爲20個,爲了模擬真實效果該方法獲取需要耗時 20秒,根據課程Id獲取有關聯課程的統計信息每調用一次需要2秒。
課程實體類
public class Course {
private Long id;
private String name;
//省略getter and setter
}
課程服務類包含上面講的2個核心方法。
public class CourseService {
/**
* 獲取所有的課程
* @return
*/
public List<Course> findAll(){
List list = new ArrayList();
for (int i = 0; i < 20; i++) {
list.add(new Course((long) i,"course"+i));
}
try {
Thread.sleep(1000*1L);
} catch (InterruptedException e) {
e.printStackTrace();
}
return list;
}
/**
* 獲取有關聯課程的統計信息
*/
public void getClassStatisticsInfo(Course course){
getUnitNum(course.getId());
getLearnNum(course.getId());
}
/**
* 獲取課程的單元數量
*/
private void getUnitNum(Long id) {
try {
Thread.sleep(1000*1L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/**
* 獲取所有關聯課程學習總人數
*/
private void getLearnNum(Long id) {
try {
Thread.sleep(1000*1L);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
普通單線程的處理方式,這個是沒有優化前代碼模擬,首先是獲取所有的課程信息,然後遍歷每個課程信息根據課程ID 獲取有關聯課程的統計信息,任務是一個一個執行的所以比較耗時。
普通單線程的處理方式具體代碼如下:
/**
* 普通單線成任務
*/
private void ordinaryTask() {
long startTime = System.currentTimeMillis();
//獲取數據庫中數據
CourseService courseStatisticsService = new CourseService();
List<Course> list = courseStatisticsService.findAll();
//處理任務
for (Course course : list) {
courseStatisticsService.getClassStatisticsInfo(course);
System.out.println("課程:" + course.getName() + "處理完成!完成時間:"+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
}
long endTime = System.currentTimeMillis();
System.out.println("任務耗時:"+(endTime - startTime));
}
}
測試普通單線程的處理方式執行時間測試類:
public class Application {
public static void main(String[] args) {
Application application = new Application();
application.ordinaryTask();
}
普通單線程的處理方式日誌如下:
課程:course0處理完成!完成時間:2020-04-18 17:54:04
課程:course1處理完成!完成時間:2020-04-18 17:54:06
課程:course2處理完成!完成時間:2020-04-18 17:54:08
課程:course3處理完成!完成時間:2020-04-18 17:54:10
課程:course4處理完成!完成時間:2020-04-18 17:54:12
課程:course5處理完成!完成時間:2020-04-18 17:54:14
課程:course6處理完成!完成時間:2020-04-18 17:54:16
課程:course7處理完成!完成時間:2020-04-18 17:54:18
課程:course8處理完成!完成時間:2020-04-18 17:54:20
課程:course9處理完成!完成時間:2020-04-18 17:54:22
課程:course10處理完成!完成時間:2020-04-18 17:54:24
課程:course11處理完成!完成時間:2020-04-18 17:54:26
課程:course12處理完成!完成時間:2020-04-18 17:54:28
課程:course13處理完成!完成時間:2020-04-18 17:54:30
課程:course14處理完成!完成時間:2020-04-18 17:54:32
課程:course15處理完成!完成時間:2020-04-18 17:54:34
課程:course16處理完成!完成時間:2020-04-18 17:54:36
課程:course17處理完成!完成時間:2020-04-18 17:54:38
課程:course18處理完成!完成時間:2020-04-18 17:54:40
課程:course19處理完成!完成時間:2020-04-18 17:54:42
任務耗時:41225
普通單線程的處理方式執行時間是 41225 毫秒 轉換成秒就是:41.225秒。
3.解決方案
在解決前首先第一步要做的是定位具體那個方法比較耗時並分析原因。分析可以從以下幾個方面入手
1.查詢sql 是否過於耗時
2.是否存在臨時對象的大量使用或者內存泄漏
如果是查詢sql 問題那麼需要通過優化sql 來提高查詢效率,如果是程序問題應該避免臨時對象的大量使用,對於程序中對象已不再使用,但對它的引用還保留着,這些對象就造成了內存泄漏。所以要及時已經清理對象的引用。
處理的第一步通過日誌查看每個方法運行時間,然後查看具體sql 執行發現好多查詢條件字段並沒有索引,所以造成查詢時間過長。在對錶某些高頻率字段添加索引後時間雖然有所提升,但是整體運行時間還是很長。
爲了快速提升程序執行效率,我沒有看程序代碼的問題。而是採用使用多線程的方式解決,就是多個線程同時執行這個統計任務來提升程序的運行時間。
具體處理思路:假設我們有4個需要執行的任務,每個任務執行的時間爲1 秒,如果我們單線程執行需要 4秒,如果有2個線程同時執行的話就可以將執行時間縮短爲2秒。通俗點講就是:4件事情交給一個人做需要4秒,那麼交給2個人同時處理就需要2秒。具體如下圖所示:
處理方式是通過 Executors.newFixedThreadPool(threadNum)來創建一個固定數量的線程池,將任務遍歷通過線程池的線程來執行。
創建一個固定數量爲 5 個的線程池,可以理解成任務可以在同一時間處理5個,比單線程的方式可以縮短5倍的時間。
Executors線程池的處理方式代碼如下:
public void executors(){
long startTime = System.currentTimeMillis();
//獲取數據庫中數據
CourseService courseStatisticsService = new CourseService();
List<Course> list = courseStatisticsService.findAll();
//常見固定數量的線程池
ExecutorService fixedThreadPool = Executors.newFixedThreadPool(5);
//taskTimes 用於記錄執行的次數 當任務執行完畢是統計執行時間
List<String> taskTimes = new ArrayList();
//處理任務
for (Course course : list) {
fixedThreadPool.execute(() -> {
courseStatisticsService.getClassStatisticsInfo(course);
System.out.println("課程:" + course.getName() + "處理完成!完成時間:"+ LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
if(taskTimes.size() == list.size()-1){
Long endTime = System.currentTimeMillis();
System.out.println("任務耗時:"+(endTime - startTime));
}
taskTimes.add("1");
});
}
fixedThreadPool.shutdown();
}
Executors線程池的處理方式日誌如下:
課程:course0處理完成!完成時間:2020-04-18 17:55:42
課程:course4處理完成!完成時間:2020-04-18 17:55:42
課程:course3處理完成!完成時間:2020-04-18 17:55:42
課程:course1處理完成!完成時間:2020-04-18 17:55:42
課程:course2處理完成!完成時間:2020-04-18 17:55:42
課程:course5處理完成!完成時間:2020-04-18 17:55:44
課程:course7處理完成!完成時間:2020-04-18 17:55:44
課程:course6處理完成!完成時間:2020-04-18 17:55:44
課程:course9處理完成!完成時間:2020-04-18 17:55:44
課程:course8處理完成!完成時間:2020-04-18 17:55:44
課程:course10處理完成!完成時間:2020-04-18 17:55:46
課程:course13處理完成!完成時間:2020-04-18 17:55:46
課程:course14處理完成!完成時間:2020-04-18 17:55:46
課程:course12處理完成!完成時間:2020-04-18 17:55:46
課程:course11處理完成!完成時間:2020-04-18 17:55:46
課程:course15處理完成!完成時間:2020-04-18 17:55:48
課程:course19處理完成!完成時間:2020-04-18 17:55:48
課程:course17處理完成!完成時間:2020-04-18 17:55:48
課程:course18處理完成!完成時間:2020-04-18 17:55:48
課程:course16處理完成!完成時間:2020-04-18 17:55:48
任務耗時:9183
Executors 線程池的處理方式執行時間是 9183 毫秒 轉換成秒就是:9.183秒。雖然沒有準確的提升5倍,但是和預期的差不多。
4.解決方案存在問題
翻閱阿里巴巴 Java手冊,其中有有一條明確規定線程池不允許使用 Executors 去創建,因爲FixedThreadPool 可能導致 OOM,具體內容如下:
【強制】線程池不允許使用 Executors 去創建,而是通過 ThreadPoolExecutor 的方式,這
樣的處理方式讓寫的同學更加明確線程池的運行規則,規避資源耗盡的風險。 說明:Executors 返回的線程池對象的弊端如下: 1)
FixedThreadPool 和 SingleThreadPool: 允許的請求隊列長度爲
Integer.MAX_VALUE,可能會堆積大量的請求,從而導致 OOM。 2) CachedThreadPool: 允許的創建線程數量爲
Integer.MAX_VALUE,可能會創建大量的線程,從而導致 OOM。
在使用ThreadPoolExecutor 改造之前先介紹一下 ThreadPoolExecutor 構造方法7個參數:
- corePoolSize:線程池中的核心線程數
- maximumPoolSize:線程池中最大線程數
- keepAliveTime:閒置超時時間
- unit:和 keepAliveTime 配合使用標明超時時間的單位
- workQueue:線程池的任務隊列
- threadFactory:創建新線程的線程工廠
- rejectedExecutionHandler:線程池任務隊列超過最大值之後的拒絕策略
線程池任務隊列超過最大值之後的拒絕策略如下:
- new ThreadPoolExecutor.DiscardPolicy():超出的任務直接丟棄,不進行任何處理。
- new ThreadPoolExecutor.DiscardOldestPolicy():執行最新的任務,丟棄掉未執行的老任務。
- new ThreadPoolExecutor.AbortPolicy():拋出 RejectedExecutionException
異常。 - new ThreadPoolExecutor.CallerRunsPolicy():超出的任務由主線程(調用方)來執行處理。
下面是我通過ThreadPoolExecutor 創建的線程池的代碼:
任務隊列爲15個,線程池任務隊列超過最大值之後的拒絕策略採用 new ThreadPoolExecutor.AbortPolicy()。也就是報異常。
public void threadPoolExecutor() {
long startTime = System.currentTimeMillis();
//獲取數據庫中數據
CourseService courseStatisticsService = new CourseService();
List<Course> list = courseStatisticsService.findAll();
//常見固定數量的線程池 和 Executors.newFixedThreadPool 一樣區別是隊列數量不一樣
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(5, 5,
0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(15)
,Executors.defaultThreadFactory()
, new ThreadPoolExecutor.AbortPolicy());
//taskTimes 用於記錄執行的次數 當任務執行完畢是統計執行時間
List<String> taskTimes = new ArrayList();
for (Course course : list) {
threadPool.execute(() -> {
courseStatisticsService.getClassStatisticsInfo(course);
System.out.println("課程:" + course.getName() + "處理完成!完成時間:" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
if(taskTimes.size() == list.size()-1){
Long endTime = System.currentTimeMillis();
System.out.println("任務耗時:"+(endTime - startTime));
}
taskTimes.add("1");
});
}
threadPool.shutdown();
}
課程:course0處理完成!完成時間:2020-04-18 17:56:33
課程:course3處理完成!完成時間:2020-04-18 17:56:33
課程:course4處理完成!完成時間:2020-04-18 17:56:33
課程:course1處理完成!完成時間:2020-04-18 17:56:33
課程:course2處理完成!完成時間:2020-04-18 17:56:33
課程:course9處理完成!完成時間:2020-04-18 17:56:35
課程:course7處理完成!完成時間:2020-04-18 17:56:35
課程:course6處理完成!完成時間:2020-04-18 17:56:35
課程:course5處理完成!完成時間:2020-04-18 17:56:35
課程:course8處理完成!完成時間:2020-04-18 17:56:35
課程:course12處理完成!完成時間:2020-04-18 17:56:37
課程:course11處理完成!完成時間:2020-04-18 17:56:37
課程:course13處理完成!完成時間:2020-04-18 17:56:37
課程:course14處理完成!完成時間:2020-04-18 17:56:37
課程:course10處理完成!完成時間:2020-04-18 17:56:37
課程:course18處理完成!完成時間:2020-04-18 17:56:39
課程:course19處理完成!完成時間:2020-04-18 17:56:39
課程:course15處理完成!完成時間:2020-04-18 17:56:39
課程:course16處理完成!完成時間:2020-04-18 17:56:39
課程:course17處理完成!完成時間:2020-04-18 17:56:39
任務耗時:9158
通過日誌我們可以發現ThreadPoolExecutor 和 Executors線程池的處理方式耗時時間差不多。這個也和預期一樣。
我將CourseService 中獲取所有課程的數據提升到 21 個,因爲我們採取的是new ThreadPoolExecutor.AbortPolicy()。所以超出的一個任務不會被處理,並報RejectedExecutionException 異常。
具體代碼如下:
/**
* 獲取所有的課程
* @return
*/
public List<Course> findAll(){
List list = new ArrayList();
for (int i = 0; i < 21; i++) {
list.add(new Course((long) i,"course"+i));
}
try {
Thread.sleep(1000*1L);
} catch (InterruptedException e) {
e.printStackTrace();
}
return list;
}
執行統計程序會報了一個 RejectedExecutionException異常,並且任務只能處理20個。具體報錯日誌信息如下:
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task cn.zhuoqianmingyue.statistics.Application$$Lambda$1/1329552164@10f87f48 rejected from java.util.concurrent.ThreadPoolExecutor@b4c966a[Running, pool size = 5, active threads = 5, queued tasks = 15, completed tasks = 0]
at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
at cn.zhuoqianmingyue.statistics.Application.threadPoolExecutor(Application.java:75)
at cn.zhuoqianmingyue.statistics.Application.main(Application.java:15)
課程:course4處理完成!完成時間:2020-04-18 18:22:49
課程:course0處理完成!完成時間:2020-04-18 18:22:49
課程:course1處理完成!完成時間:2020-04-18 18:22:49
課程:course2處理完成!完成時間:2020-04-18 18:22:49
課程:course3處理完成!完成時間:2020-04-18 18:22:49
課程:course7處理完成!完成時間:2020-04-18 18:22:51
課程:course9處理完成!完成時間:2020-04-18 18:22:51
課程:course8處理完成!完成時間:2020-04-18 18:22:51
課程:course5處理完成!完成時間:2020-04-18 18:22:51
課程:course6處理完成!完成時間:2020-04-18 18:22:51
課程:course12處理完成!完成時間:2020-04-18 18:22:53
課程:course10處理完成!完成時間:2020-04-18 18:22:53
課程:course13處理完成!完成時間:2020-04-18 18:22:53
課程:course11處理完成!完成時間:2020-04-18 18:22:53
課程:course14處理完成!完成時間:2020-04-18 18:22:53
課程:course19處理完成!完成時間:2020-04-18 18:22:55
課程:course18處理完成!完成時間:2020-04-18 18:22:55
課程:course16處理完成!完成時間:2020-04-18 18:22:55
課程:course17處理完成!完成時間:2020-04-18 18:22:55
課程:course15處理完成!完成時間:2020-04-18 18:22:55
我通過Executors 方式處理 21 個課程信息並沒有報錯,爲什麼 FixedThreadPool 方式沒有報錯呢?上面我們說到 阿里Java 手冊中說到:
FixedThreadPool 和 SingleThreadPool: 允許的請求隊列長度Integer.MAX_VALUE,而我通過 ThreadPoolExecutor 方式創建的隊列長度爲 15個。
因爲我們的核心線程數爲5 最大線程數也是5 ,隊列爲15,所以可以處理的線程數爲 20,當我們增加到 21 個後任務隊列裏面放不下了,就採取報錯的拒絕策略。
我通過參看FixedThreadPool 驗證了確實和阿里Java 手冊所說一樣它的任務隊列的長度是:Integer.MAX_VALUE
進入newFixedThreadPool 方法如下所示newFixedThreadPool 採用了LinkedBlockingQueue 隊列。
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
在進入 LinkedBlockingQueue 構造方法中我們可以看到其將隊列的數量設置爲 Integer.MAX_VALUE 個長度。
public LinkedBlockingQueue() {
this(Integer.MAX_VALUE);
}
接下來你可能還有疑問,爲什麼任務隊列超了就要執行拒絕策略。線程池的原理是什麼樣的? 請您繼續往下看。
5.問題引發原理深究
接下來我將通過通俗易懂的方式帶你快速帶你瞭解線程池原理。假設一個電影院有 4 個放映室,每個放映室有10個位置,如果同時放映 4 場電影就可以賣 40 張票。同時還有一個備用的放映室(如果人流程大時使用)。
現在外面下雨了,有人想買電影票想在休息室等待看電影同時還能避雨的話怎麼處理呢?
商家通過在搞個等待室,一個等待室可以坐10個人。這樣的話可以一次賣50張票。40個人看電影,10個在等待室等待下一場電影。
如果一次來了60個人來買票的話可以啓動備用放映室,這樣一次可以賣60張票,50個人看電影,10個人等待看下一場。
如果同時來70個人的話,那麼商家只能跟多出的10個人說:不好意思我們現在電影院的位置都坐滿了,等一會您在來吧。
案例中的例子於線程池對應的概念如下:
- 線程池 == 電影院
- 放映室 == 核心線程數
- 備用放映室 + 放映室 == 最大線程數
- 等待室 == 隊列的數量
- 商家跟多餘10個人說等會再來 == 線程池任務隊列超過最大值之後的拒絕策略
到這裏你是不是有種恍然大明白的感覺,如果你想驗證我的內容是否正確,可以具體參讀 Executors 和 ThreadPoolExecutor 源碼。由於篇幅有限我這裏不在過多闡述。
6.小結
通過 Executors 或者 ThreadPoolExecutor 方式來創建線程池來提供任務的執行時間,Executors 相對於 ThreadPoolExecutor 使用方式上簡單了許多,那是因爲 Executors 是在ThreadPoolExecutor基礎之上做了一些封裝。
需要注意的是:使用 Executors 方式可能會堆積大量的請求,從而導致 OOM。 如果說你處理的任務量不大,也可以使用Executors 但是還是比較推薦ThreadPoolExecutor的方式。