多線程如何從理論到實戰

一. 回顧

關於線程相關的一些基礎知識,本篇不再過多闡述,首先我們通過幾個簡單的問題,複習一下線程相關的一些基礎知識。

1.進程和線程分別是什麼?
進程:進程是一個具有一定獨立功能的程序關於某個數據集合的一次運行活動。它是操作系統動態執行的基本單元,在傳統的操作系統中,進程既是基本的分配單元,也是基本的執行單元。
2.進程、線程的例子?
進程:運行的QQ、迅雷、Word等應用進程,進程是系統級別的
線程:QQ多個聊天窗口、迅雷下載多個文件等
3.線程的狀態(生命週期)
線程的狀態枚舉:java.lang.Thread.State 中狀態值:
NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED
線程狀態
4.wait()和sleep()區別
sleep()是Thread類的方法;wait()是Object類的方法
sleep()方法使程序暫停執行指定的時間,讓出cpu給其它線程,但是它的監控狀態依然保持,也就說是不會釋放對象鎖,當指定的時間到了又會自動恢復運行狀態;wait()方法使線程釋放對象鎖,進入此對象的等待鎖定池,只有針對此對象調用notify()方法後,本線程纔會重新進入準備獲取對象鎖的狀態。

  1. 原理不同。sleep()方法是Thread類的靜態方法,是線程用來控制自身流程的,他會使此線程暫停執行一段時間,而把執行機會讓給其他線程,等到計時時間一到,此線程會自動甦醒。例如,當線程執行報時功能時,每一秒鐘打印出一個時間,那麼此時就需要在打印方法前面加一個sleep()方法,以便讓自己每隔一秒執行一次,該過程如同鬧鐘一樣。而wait()方法是object類的方法,用於線程間通信,這個方法會使當前擁有該對象鎖的進程等待,直到其他線程調用notify()方法或者notifyAll()時才醒來,不過開發人員也可以給他指定一個時間,自動醒來。
  2. 對鎖的 處理機制不同。由於sleep()方法的主要作用是讓線程暫停執行一段時間,時間一到則自動恢復,不涉及線程間的通信,因此,調用sleep()方法並不會釋放鎖。而wait()方法則不同,當調用wait()方法後,線程會釋放掉他所佔用的鎖,從而使線程所在對象中的其他synchronized數據可以被其他線程使用。
  3. 使用區域不同。wait()方法必須放在同步控制方法和同步代碼塊中使用,sleep()方法則可以放在任何地方使用。sleep()方法必須捕獲異常,而wait()、notify()、notifyAll()不需要捕獲異常。在sleep的過程中,有可能被其他對象調用他的interrupt(),產生InterruptedException。由於sleep不會釋放鎖標誌,容易導致死鎖問題的發生,因此一般情況下,推薦使用wait()方法。

通過上面幾個問題複習了一下基本概念,如果對這些概念有些模糊,小夥伴可以通過專欄簡單看前幾篇回憶一下。
Java多線程

在學習多線程的時候,我們都知道wait()、notfiy()、notifyAll()一些操作線程的方法,但是工作中我們其實很少直接使用這些方法進行多線的操作。我們使用多線程的時候要注意幾點:
1.在高內聚低耦合的前提下,通過線程操作資源類
競爭資源+操作(對外暴露的調用方法)=資源類
2.操作方法實現
判斷->業務邏輯->通知喚醒
判斷時,防止虛假喚醒,應使用while()循環判斷
3.使用JDK8 lock
synchronized-> wait\notify 替換==> lock->await\signal

在實際工作中更加關心業務邏輯的實現,直接使用這些基礎方法很難寫出高效且安全的多線程代碼,一般都是通過線程池進行多線程實現,那麼有沒有什麼規範可以參考吶?
當然,在《阿里巴巴Java開發手冊》併發處理章節中,對於併發中的規約如下:
阿里巴巴開發手冊
阿里巴巴開發手冊中明確指出線程資源必須通過線程池提供,也就說我們在編碼中應該通過線程池而不是new Thead()的方式。使用線程池的時候也不能通過Executors去創建。ok,進入正題–>線程池

二. 線程池

線程池的優勢:

(1)、降低系統資源消耗,通過重用已存在的線程,降低線程創建和銷燬造成的消耗;
(2)、提高系統響應速度,當有任務到達時,通過複用已存在的線程,無需等待新線程的創建便能立即執行;
(3)方便線程併發數的管控。因爲線程若是無限制的創建,可能會導致內存佔用過多而產生OOM,並且會造成cpu過度切換(cpu切換線程是有時間成本的(需要保持當前執行線程的現場,並恢復要執行線程的現場))。
(4)提供更強大的功能,延時定時線程池。

這些都是套話,死記硬背當然是不可能。其實提到池,池化思想是很普遍的,如線程池、數據庫連接池等,這些池化的優點也都是基本相通的.
1.通過資源的重用降低系統的資源消耗;
2.通過提前創建資源提高系統的響應速度;
3.提供更加高效安全的管理方法等;

線程池的使用

可以通過Executors工廠方法創建

方法 特點
newCachedThreadPool() 創建一個可緩存線程池,如果線程池長度超過處理需要,可靈活回收空閒線程,若無可回收,則新建線程;
newFixedThreadPool(int) 創建一個定長線程池,可控制線程最大併發數,超出的線程會在隊列中等待。池中的線程將一直存在,直到它顯式出現shutdown
newSingleThreadPool() 創建一個單線程化的線程池,它只會用唯一的工作線程來執行任務,保證所有任務按照指定順序(FIFO, LIFO, 優先級)執行
newScheduledThreadPool() 創建一個定長線程池,支持定時及週期性任務執行

通過上面四種方法基本可以創建平時所需的線程池,在配合常見如下api,基本可以完成任務了。

方法 說明
execute(Runnable) 執行任務
shutdown() 不在接受新的線程,並且等待之前提交的線程都執行完在關閉
shutdownNow() 直接關閉活躍狀態的所有的線程 , 並返回等待中的線程
getActiveCount() 獲取線程池活動線程數量

線程池api很多,只列舉幾個。通過Executors創建所需線程池,搭配基本api,貌似已經可以完成業務邏輯代碼了,萬事大吉了。
我們當然不能停下思考的腳本,爲什麼開發手冊強制不能使用Executors創建線程池吶?

Executors 返回的線程池對象的弊端如下
1) FixedThreadPool 和 SingleThreadPool:
允許的請求隊列長度爲 Integer.MAX_VALUE,可能會堆積大量的請求,從而導致 OOM
2) CachedThreadPool:
允許的創建線程數量爲 Integer.MAX_VALUE,可能會創建大量的線程,從而導致 OOM

原來這樣會造成OOM。爲什麼吶?我們以CachedThreadPool爲例看一下創建線程池方法的源碼。

    //1.java.util.concurrent.Executors#newCachedThreadPool()
    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
	//2.java.util.concurrent.ThreadPoolExecutor#ThreadPoolExecutor(int, int, long, java.util.concurrent.TimeUnit, java.util.concurrent.BlockingQueue<java.lang.Runnable>)
	public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }

可以看到最終調用的是 java.util.concurrent.ThreadPoolExecutor中有7個參數的構造方法。查看其他方法FixedThreadPool、SingleThreadPool等,同樣發現調用的都是ThreadPoolExecutor中同一個7個參數的構造方法。
在這裏插入圖片描述
在這裏插入圖片描述
只不過有的參數設置的默認值不同而已。

看到這裏,我們如果瞭解了這7個參數,就能知道爲什麼阿里巴巴開發手冊要說會造成OOM了,通過這7個參數,我們也能明白線程池的原理和使用重點了

三. 線程池7大參數

線程池七大參數
1.corePoolSize
線程池中常駐核心線程數;
創建線程池後,當有請求任務來之後,就會安排池中線程去執行請求任務,近似理解爲今日當值線程。
當線程池中的線程數目達到了corePoolSize後,就會把任務放到緩存隊列中;
2.maximumPoolSize
線程池中能容納同時執行的最大線程數,此值大於等於1
3.keepAliveTime
多餘空閒線程的存活時間;當前線程池的數量超過corePoolSize時,當空閒時間達到keepAliveTime值時,多餘空閒線程會被銷燬直到只剩下corePoolSize個線程爲止;
4.unit
keepAliveTime的時間單位

TimeUnit.DAYS;               //天
TimeUnit.HOURS;             //小時
TimeUnit.MINUTES;           //分鐘
TimeUnit.SECONDS;           //秒
TimeUnit.MILLISECONDS;      //毫秒
TimeUnit.MICROSECONDS;      //微妙
TimeUnit.NANOSECONDS;       //納秒

5.workQueue
任務隊列,被提交但尚未被執行的任務存放在workQueue中;
6.threadFactory
表示生成線程池中工作線程的線程工廠,用於創建線程,一般默認即可;
7.handler
拒絕策略,表示線程大於等於線程池的最大線程數時如何拒絕請求執行的任務的策略

拒絕策略

拒絕策略

策略 概述
ThreadPoolExecutor.AbortPolicy 丟棄任務並拋出RejectedExecutionException異常(默認)
ThreadPoolExecutor.DiscardPolicy 丟棄任務,但是不拋出異常
ThreadPoolExecutor.DiscardOldestPolicy 丟棄隊列最前面的任務,然後重新提交被拒絕的任務
ThreadPoolExecutor.CallerRunsPolicy 由調用線程(提交任務的線程)處理該任務,不會拋出異常,不會拋棄任務(常用)

瞭解了7個參數,再回頭來看開發手冊中所說的造成OOM的情況
1.FixedThreadPool 和 SingleThreadPool的workQueuenew LinkedBlockingQueue()請求隊列長度爲 Integer.MAX_VALUE,可能會堆積大量的請求任務,導致 OOM

    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

2.CachedThreadPool:maximumPoolSize
允許的創建線程數量爲 Integer.MAX_VALUE,可能會創建大量的線程,從而導致 OOM
在這裏插入圖片描述
現在串聯一下7個參數的作用,描述一下線程池工作原理,爲實戰做最後的準備。

四. 線程池工作原理

線程池工作原理
1.在創建了線程池後,開始等待請求;
2.當調用execute()方法添加一個請求任務是,線程池會做出如下判斷:

2.1. 如果此時線程池中的數量小於corePoolSize,即使線程池中的線程都處於空閒狀態,也要創建新的線程來處理被添加的任務。
2.2. 如果此時線程池中的數量等於corePoolSize,但是緩衝隊列workQueue未滿,那麼任務被放入緩衝隊列。
2.3. 如果此時線程池中的數量大於等於corePoolSize,緩衝隊列workQueue滿,並且線程池中的數量小於maximumPoolSize,建新的線程來處理被添加的任務。
2.4. 如果此時線程池中的數量大於corePoolSize,緩衝隊列workQueue滿,並且線程池中的數量等於maximumPoolSize,那麼通過handler所指定的策略來處理此任務。
2.5. 當線程池中的線程數量大於 corePoolSize時,如果某線程空閒時間超過keepAliveTime,線程將被終止。這樣,線程池可以動態的調整池中的線程數。

3.當一個線程執行完任務時,會從等待隊列中取下一個任務來執行;
4.當一個線程無任務運行,空閒超過一定時間(keepAliveTime)時,線程會判斷:
如果當前運行的線程數大於corePoolSize,那麼這個線程就被回收,所以線程池的所有任務完成後,它最終會收縮到corePoolSize的大小

注意

  • 處理任務判斷的優先級爲 核心線程corePoolSize、任務隊列workQueue、最大線程maximumPoolSize,如果三者都滿了,使用handler處理被拒絕的任務。
  • workQueue使用的是無限隊列時,maximumPoolSize參數就變的無意義了,比如new LinkedBlockingQueue(),或者new ArrayBlockingQueue(Integer.MAX_VALUE);
  • 使用SynchronousQueue隊列時由於該隊列沒有容量的特性,所以不會對任務進行排隊,如果線程池中沒有空閒線程,會立即創建一個新線程來接收這個任務,maximumPoolSize要設置大一點。
  • 線程和最大線程數量相等時keepAliveTime無作用.

五. 實戰

多線程在實際工作中的使用一定要結合業務需求,不要爲了使用多線程而使用多線程。如果目前系統性能正常,能夠滿足系統預期要求,並且可以支撐未來的業務要求,就沒有必要使用。如果系統性能出現了瓶頸,需要使用多線程,那麼高效安全的多線程代碼也不是一撮而就的,需要結合業務實際需求,不斷優化,不斷調試、迭代,並隨着系統業務量的增加,不斷迭代優化。說這些主要是表達,不要爲了技術而技術,需求驅動我覺得比較好,畢竟多線程意味着更多的開發、測試、維護的工作量。
ok,點到爲止,見仁見智吧。
以我前段時間遇到的項目經歷做爲案例分析一下吧。

項目背景

根據系統中預先維護的基礎數據,定時爲系統生成工單並自動派發到工作人員。
1.0:初始時,敏捷迭代,快速開發,考慮初期用戶量不大,沒有使用多線程,配合XxlJob定時調度框架,每天凌晨生成並分派工單。滿足需求
2.0:基礎數據越來越多,用戶量也不多增加。每個工作人員每天應分派4-6個工單,平均5個工單,有的生成工單,但是不符合派單邏輯,分派不到具體人員。預測系統中每日大約需要生成5W個工單,而每個工單的平均有10-20個工作項,每日工作項月50w-60w個。即使優化sql,批量提交、優化代碼,系統在凌晨時壓力也比較大,定時任務的執行時間也是越來越長,有時因爲執行時間過長,造成任務崩潰。不得不進行優化。

解決方案:

分析數據發現可以將任務生成以基礎數據中某個維度劃分開,拆分爲一個個獨立的任務。使用多線程,每個維度任務劃分爲一個線程,互不干擾。這個維度的劃分,需要結合自己項目的實際需求了,一定要慎重選擇,一個好的提交維度切入點,會使多線程代碼更加高效,事務控制更加合理,不會因爲某個線程失敗,導致其他線程異常,甚至垃圾數據,一個好的維度已經成功了一半,如果劃分線程的維度不好,需求輕微一改動,又是一場惡戰。這裏分析得出的維度爲區域,每個區域一個線程。

編碼實戰:

結合Springboot框架,這裏使用ThreadPoolTaskExecutor,spring包下的,是sring爲我們提供的線程池類,內部也是封裝的ThreadPoolExecutor,這裏直接繼承,加入一些日誌使用即可。
MyThreadPoolExecutor.java

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.util.concurrent.ListenableFuture;

import java.util.concurrent.Callable;
import java.util.concurrent.Future;
import java.util.concurrent.ThreadPoolExecutor;

@Slf4j
public class MyThreadPoolExecutor extends ThreadPoolTaskExecutor{
    private void showThreadPoolInfo(String prefix){
        ThreadPoolExecutor threadPoolExecutor = getThreadPoolExecutor();

        if(null==threadPoolExecutor){
            return;
        }
        log.info("{}, {},未完成的任務數量 [{}], 完成任務數 [{}], 線程池中alive的線程數量 [{}], 隊列大小 [{}]",
                this.getThreadNamePrefix(),
                prefix,
                threadPoolExecutor.getTaskCount(),
                threadPoolExecutor.getCompletedTaskCount(),
                threadPoolExecutor.getActiveCount(),
                threadPoolExecutor.getQueue().size());
    }

    @Override
    public void execute(Runnable task) {
        showThreadPoolInfo("Runnable任務");
        super.execute(task);
    }

    @Override
    public void execute(Runnable task, long startTimeout) {
        showThreadPoolInfo("Runnable任務");
        super.execute(task, startTimeout);
    }

    @Override
    public Future<?> submit(Runnable task) {
        showThreadPoolInfo("1. do submit");
        return super.submit(task);
    }

    @Override
    public <T> Future<T> submit(Callable<T> task) {
        showThreadPoolInfo("2. do submit");
        return super.submit(task);
    }

    @Override
    public ListenableFuture<?> submitListenable(Runnable task) {
        showThreadPoolInfo("1. do submitListenable");
        return super.submitListenable(task);
    }

    @Override
    public <T> ListenableFuture<T> submitListenable(Callable<T> task) {
        showThreadPoolInfo("2. do submitListenable");
        return super.submitListenable(task);
    }
}

配置及使用:

1.通過SpringBoot @Configuration 配置

@Configuration
@EnableAsync
public class ExecutorConfig {

    @Bean("threadPool")
    public ThreadPoolTaskExecutor asyncServiceExecutor() {
        ThreadPoolTaskExecutor executor = new VisiableThreadPoolTaskExecutorHelper();
        //配置核心線程數-示例大小,按需配置
        executor.setCorePoolSize(5);
        //配置最大線程數-示例大小,按需配置
        executor.setMaxPoolSize(5);
        //配置空閒線程存活時間
        executor.setKeepAliveSeconds(100);
        //配置隊列大小-示例大小,按需配置
        executor.setQueueCapacity(1000);
        //配置線程池中的線程的名稱前綴
        executor.setThreadNamePrefix("demo-線程池執行");
        // 配置拒絕策略:當pool已經達到max size的時候,如何處理新任務
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        //執行初始化
        executor.initialize();
        return executor;
    }
}

當然,也可以通過xml配置,不在演示xml配置

2.使用:在需要使用的時候自動注入的方式注入線程池即可

@Resource(name="threadPool")
ThreadPoolTaskExecutor taskExecutor;
// 或者可以直接@Autowried
@AutoWired
ThreadPoolTaskExecutor taskExecutor

我使用的是第一種配置bean的方式。

實踐效果:

通過調度框架執行的時間來看,運行時間縮短了x倍,產品經理露出大姨夫的微笑(* ̄︶ ̄)~

線程池參數設置

隊列大小、拒絕策略按照系統業務需求設置即可。

如何合理設置線程池的核心線程數、最大線程數?

這個也是要根據線程池執行的任務的實際情況進行分析後進行設置。

  • 如果是CPU密集型任務應配置儘可能小的線程,如配置CPU個數+1的線程數;
//獲取CPU核數
int availableProcessors = Runtime.getRuntime().availableProcessors();
  • IO密集型任務應配置儘可能多的線程,因爲IO操作不佔用CPU,不要讓CPU閒下來,應加大線程數量,如配置兩倍CPU個數+1;
  • 混合型的任務,如果可以拆分,拆分成IO密集型和CPU密集型分別處理,前提是兩者運行的時間是差不多的,如果處理時間相差很大,則沒必要拆分了。

六.結語

合理、適度、適時、正確的使用線程池,提高系統性能。如果系統業務量還在增長,多線程優化效果不明顯;出現任務間相互依賴,某個任務依賴上一步任務的執行結果等複雜情況…
繼續演化的話,可以將任務進行更細粒度的拆分,一個任務拆分爲多個步驟執行。可以考慮使用SpringBatch批處理框架

Spring Batch是一個輕量級,全面的批處理框架,旨在開發對企業系統日常運營至關重要的強大批處理應用程序。 Spring Batch構建了人們期望的Spring Framework特性(生產力,基於POJO的開發方法和一般易用性),同時使開發人員可以在必要時輕鬆訪問和利用更高級的企業服務。 spring batch是spring提供的一個數據處理框架。企業域中的許多應用程序需要批量處理才能在關鍵任務環境中執行業務操作。

注意Springbatch只是批處理框架,不具備調度功能,可以搭配Quartz等完成定時調度功能。

溜了溜了~,眼疼吖在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章