java執行緒池實踐

farsun發表於2021-09-09

執行緒池大家都很熟悉,無論是平時的業務開發還是框架中介軟體都會用到,大部分都是基於JDK執行緒池ThreadPoolExecutor做的封裝,

都會牽涉到這幾個核心引數的設定:核心執行緒數,等待(任務)佇列,最大執行緒數,拒絕策略等。

但如果執行緒池設定不當就會引起一系列問題, 下面就說下我最近碰到的問題。

案件還原

比如你有一個專案中有個介面部分功能使用了執行緒池,這個功能會去呼叫多個第三方介面,都有一定的耗時,為了不影響主流程的效能,不增加整體響應時間,所以放線上程池裡和主執行緒並行執行,等執行緒池裡的任務執行完通過future.get的方式獲取執行緒池裡的執行緒執行結果,然後合併到主流程的結果裡返回,大致流程如下:

image

執行緒池引數為:

  • coresize:50
  • max:200
  • queuesize:1
  • keepalivetime:60s
  • 拒絕策略為reject

假設每次請求提交5個task到執行緒池,平均每個task是耗時50ms

沒過一會就收到了執行緒池滿了走了拒絕策略的報錯

結合你對執行緒池的瞭解,先思考下為什麼

執行緒池的工作流程如下:
image

image

根據這個我們來列一個時間線

1. 專案剛啟動 第1次請求(每次5個task提交到執行緒池),建立5個核心執行緒

2. 第2次請求 繼續建立5個(共10個核心執行緒了)

3. 直到第10次 核心執行緒數會達滿50個

4. 核心執行緒處理完之後核心執行緒會幹嘛呢

根據 jdk1.8的執行緒池的原始碼:
執行緒池的執行緒處理處理了交給它的task之後,它會去getTask()

原始碼如下:

private Runnable getTask() {
        boolean timedOut = false; // Did the last poll() time out?

        for (;;) {
            int c = ctl.get();
            int rs = runStateOf(c);

            // Check if queue empty only if necessary.
            if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
                decrementWorkerCount();
                return null;
            }

            int wc = workerCountOf(c);

            // Are workers subject to culling?
            boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

            if ((wc > maximumPoolSize || (timed && timedOut))
                && (wc > 1 || workQueue.isEmpty())) {
                if (compareAndDecrementWorkerCount(c))
                    return null;
                continue;
            }

            try {
            //注意這段
                Runnable r = timed ?
                    workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                    workQueue.take();
                if (r != null)
                    return r;
                timedOut = true;
            } catch (InterruptedException retry) {
                timedOut = false;
            }
        }
    }

請注意上面程式碼中的bool型別的timed的賦值邏輯,
由於allowCoreThreadTimeOut預設為false,也就是說:
只要建立的執行緒數量超過了核心執行緒數,那麼幹完手上活後的執行緒(不管是核心執行緒,還是超過佇列後新開的執行緒)就會走進

//執行緒狀態為 timedwaiting
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) 

由於我們上面步驟裡面還沒有超過coresize所以會走進

//執行緒狀態為 waiting
workQueue.take() 

所以答案是:上面步驟幹活的核心執行緒處理完之後核心執行緒會進入waiting狀態,
只要佇列一有活就會被喚醒去幹活。

5. 到第11次的時候

好傢伙,到這步驟的時候 ,核心執行緒數已滿,那麼就往佇列裡面塞,但是設定的queuesize=1,
每次有5個task,那就是說往佇列裡面塞1個,剩下4個(別較真我懂你意思)要建立新的max執行緒了。

結果:

  • 核心執行緒數:50
  • 佇列:1
  • max執行緒:4個

因為50個核心執行緒在waiting中,所以佇列只要一add,就會立馬被消費,假設消費的這個核心執行緒名字是小A。

這裡要細品一下:

這裡已經匯流排程數大於核心執行緒數了,那麼getTask()裡面

// timed=true
 boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

那麼小A幹完活就會走進

//執行緒狀態為 timedwaiting
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) 

此處核心執行緒小A就會變成timedwaiting的狀態(keepalive設定的是60s)

6. 到第12次的時候

繼續往佇列塞1個,建立4個max執行緒,max執行緒已經有8個了

這裡 又會有一個新的核心執行緒小B ,會變成timedwaiting狀態了

max執行緒們幹完手上的活後,也會去呼叫getTask() 也會進入timedwaiting狀態

因為queuesize=1,狼多肉少

7. 繼續下去,那麼最終會變成

max滿了,執行緒們都在timedwaiting(keepalive設定的是60s)

新的提交就會走拒絕策略了

image

問題總結

其實核心與非核心對於執行緒池來說都是一樣的,只要一旦執行緒數超過了核心執行緒數,那麼執行緒就會走進timewaiting

把queuesize調大就好了?

這裡又有一個新的注意點:
上面舉例的是I/O密集型業務,queuesize不是越大越好的,
因為:

執行緒池新建立的執行緒會優先處理新請求進來的任務,而不是去處理佇列裡的任務,佇列裡的任務只能等核心執行緒數忙完了才能被執行,這樣可能造成佇列裡的任務長時間等待,導致佇列積壓,尤其是I/O密集場景

慎用CallRunnerPolicy這個拒絕策略

一定得理解這個策略會帶來什麼影響,
先看下這個拒絕策略的原始碼
image

如果你提交執行緒池的任務即時失敗也沒有關係的話,用這個拒絕策略是致命的,
因為一旦超過執行緒池的負載後開始吞噬tomcat執行緒。

用future.get的方式慎用DiscardPolicy這個拒絕策略

如果需要得到執行緒池裡的執行緒執行結果,使用future的方式,拒絕策略不建議使用DiscardPolicy,這種丟棄策略雖然不執行子執行緒的任務,

但是還是會返回future物件(其實在這種情況下我們已經不需要執行緒池返回的結果了),然後後續程式碼即使判斷了future!=null也沒用,

這樣的話還是會走到future.get()方法,如果get方法沒有設定超時時間會導致一直阻塞下去

類似下面的虛擬碼:

// 如果執行緒池已滿,新的請求會被直接執行拒絕策略,此時如果拒絕策略設定的是DiscardPolicy丟棄任務,
// 則還是會返回future物件, 這樣的話後續流程還是可能會走到get獲取結果的邏輯
Future<String> future = executor.submit(() -> {
    // 業務邏輯,比如呼叫第三方介面等操作
    return result;
});
 
// 主流程呼叫邏輯
if(future != null) // 如果拒絕策略是DiscardPolicy還是會走到下面程式碼
  future.get(超時時間); // 呼叫方阻塞等待結果返回,直到超時

推薦解決方案

1. 用動態執行緒池,可以動態修改coresize,maxsize,queuesize,keepalivetime

  • 對執行緒池的核心指標進行埋點監控,可以通過繼承 ThreadPoolExecutor 然後Override掉beforeExecute,afterExecute,shutdown,shutdownNow方法,進行埋點記錄到es
  • 可以埋點的資料有:
    包括執行緒池執行狀態、核心執行緒數、最大執行緒數、任務等待數、已完成任務數、執行緒池異常關閉等資訊
名稱 含義
core_pool_size 定義的核心執行緒總數
max_pool_size 定義的maxpoolsize
keep_alive_time 定義的keepalivetime
current_pool_size 當前執行緒池匯流排程數
queue_wait_size 當前佇列中等待處理的個數
active_count 當前run狀態的執行緒數
completed_count 當前執行緒池中的每個執行緒處理的task數的疊加值
task_count 等於completed_count加上queue_wait_size
shutdown 當前執行緒池的狀態是否關閉
useRate 當前執行緒池利用率:((active_count * 1.0 / max_pool_size) * 100)

基於以上資料,我們可以實時監控和排查定位問題

參考程式碼:

/**
 * 自定義執行緒池<p>
 * 1.監控執行緒池狀態及異常關閉等情況<p>
 * 2.監控執行緒池執行時的各項指標, 比如:任務執行時間、任務等待數、已完成任務數、任務異常資訊、核心執行緒數、最大執行緒數等<p>
 * author: maoyingxu
 */
public class ThreadPoolExt extends ThreadPoolExecutor{
 
    private TimeUnit timeUnit;
 
    public ThreadPoolExt(int corePoolSize,
                         int maximumPoolSize,
                         long keepAliveTime,
                         TimeUnit unit,
                         BlockingQueue<Runnable> workQueue,
                         ThreadFactory threadFactory,
                         RejectedExecutionHandler handler) {
        super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);
        this.timeUnit = unit;
    } 
 
    @Override
    protected void beforeExecute(Thread t, Runnable r) {
        monitor("ThreadPool monitor data:"); // 監控執行緒池執行時的各項指標
    }
 
    @Override
    protected void afterExecute(Runnable r, Throwable ex) {
        // 記錄執行緒池執行任務的時間
        ELKLogUtils.addAppendedValue(StoredLogTag.RUNNING_DETAIL, MessageFormat.format("ThreadPool task executeTime:{0}", executeTime));
        if (ex != null) { // 監控執行緒池中的執行緒執行是否異常
            LogUtils.warn("unknown exception caught in ThreadPool afterExecute:", ex);
        }
    }
 
    @Override
    public void shutdown() {
        monitor("ThreadPool will be shutdown:"); // 執行緒池將要關閉事件,此方法會等待執行緒池中正在執行的任務和佇列中等待的任務執行完畢再關閉
        super.shutdown();
    }
 
    @Override
    public List<Runnable> shutdownNow() {
        monitor("ThreadPool going to immediately be shutdown:"); // 執行緒池立即關閉事件,此方法會立即關閉執行緒池,但是會返回佇列中等待的任務
 
        // 記錄被丟棄的任務, 目前只記錄日誌, 後續可根據業務場景做進一步處理
        List<Runnable> dropTasks = null;
        try {
            dropTasks = super.shutdownNow();
            ELKLogUtils.addAppendedValue(StoredLogTag.RUNNING_DETAIL, MessageFormat.format("{0}ThreadPool discard task count:{1}{2}",
                    System.lineSeparator(), dropTasks!=null ? dropTasks.size() : 0, System.lineSeparator()));
        } catch (Exception e) {
            LogUtils.addClogException("ThreadPool shutdownNow error", e);
        }
        return dropTasks;
    }
 
    /**
     * 監控執行緒池執行時的各項指標, 比如:任務等待數、任務異常資訊、已完成任務數、核心執行緒數、最大執行緒數等
     * @param title
     */
    private void monitor(String title){
        try {
            // 執行緒池監控資訊記錄, 這裡需要注意寫ES的時機,尤其是多個子執行緒的日誌合併到主流程的記錄方式
            String threadPoolMonitor = MessageFormat.format(
                    "{0}{1}core pool size:{2}, current pool size:{3}, queue wait size:{4}, active count:{5}, completed task count:{6}, " +
                            "task count:{7}, largest pool size:{8}, max pool size:{9}, keep alive time:{10}, is shutdown:{11}, is terminated:{12}, " +
                            "thread name:{13}{14}",
                    System.lineSeparator(), title, this.getCorePoolSize(), this.getPoolSize(),
                    this.getQueue().size(), this.getActiveCount(), this.getCompletedTaskCount(), this.getTaskCount(), this.getLargestPoolSize(),
                    this.getMaximumPoolSize(), this.getKeepAliveTime(timeUnit != null ? timeUnit : TimeUnit.SECONDS), this.isShutdown(),
                    this.isTerminated(), Thread.currentThread().getName(), System.lineSeparator());
            ELKLogUtils.addAppendedValue(StoredLogTag.RUNNING_DETAIL, threadPoolMonitor);
            LogUtils.info(title, threadPoolMonitor);
 
            ELKLogUtils.addFieldValue(APPIndexedLogTag.THREAD_POOL_USE_RATE, useRate); // ES埋點執行緒池使用率, useRate = (getActiveCount()/getMaximumPoolSize())*100
            Cat.logEvent(key, String.valueOf(useRate)); // 報警設定
        } catch (Exception e) {
            LogUtils.addClogException("ThreadPool monitor error", e);
        }
    }
 
}

2. 重寫執行緒池拒絕策略, 拒絕策略主要參考了 Dubbo的執行緒池拒絕策略

public class AbortPolicyWithReport extends ThreadPoolExecutor.AbortPolicy {
 
    // 省略部分程式碼
 
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        String msg = String.format("Thread pool is EXHAUSTED!" +
                " Thread Name: %s, Pool Size: %d (active: %d, core: %d, max: %d, largest: %d), Task: %d (completed: "
                + "%d)," +
                " Executor status:(isShutdown:%s, isTerminated:%s, isTerminating:%s), in %s://%s:%d!",
            threadName, e.getPoolSize(), e.getActiveCount(), e.getCorePoolSize(), e.getMaximumPoolSize(),
            e.getLargestPoolSize(),
            e.getTaskCount(), e.getCompletedTaskCount(), e.isShutdown(), e.isTerminated(), e.isTerminating(),
            url.getProtocol(), url.getIp(), url.getPort());
        logger.warn(msg); // 記錄最大負載情況下執行緒池的核心執行緒數,活躍數,最大執行緒數等引數
        dumpJStack(); // 記錄執行緒堆疊資訊包括鎖爭用資訊
        throw new RejectedExecutionException(msg);
    }
 
    private void dumpJStack() {
        long now = System.currentTimeMillis();
 
        //dump every 10 minutes 每隔10分鐘記錄一次
        if (now - lastPrintTime < TEN_MINUTES_MILLS) {
            return;
        }
 
        if (!guard.tryAcquire()) { // 加鎖訪問
            return;
        }
 
        ExecutorService pool = Executors.newSingleThreadExecutor(); // 這裡單獨開啟一個新的執行緒去執行(阿里的Java開發規範不允許直接呼叫Executors.newSingleThreadExecutor, 估計dubbo那時候還沒出開發規範...)
        pool.execute(() -> {
            String dumpPath = url.getParameter(DUMP_DIRECTORY, System.getProperty("user.home"));
 
            SimpleDateFormat sdf;
 
            String os = System.getProperty(OS_NAME_KEY).toLowerCase();
 
            // window system don't support ":" in file name
            if (os.contains(OS_WIN_PREFIX)) {
                sdf = new SimpleDateFormat(WIN_DATETIME_FORMAT);
            } else {
                sdf = new SimpleDateFormat(DEFAULT_DATETIME_FORMAT);
            }
 
            String dateStr = sdf.format(new Date());
            //try-with-resources
            try (FileOutputStream jStackStream = new FileOutputStream(
                new File(dumpPath, "Dubbo_JStack.log" + "." + dateStr))) {
                JVMUtil.jstack(jStackStream);
            } catch (Throwable t) {
                logger.error("dump jStack error", t);
            } finally {
                guard.release();
            }
            lastPrintTime = System.currentTimeMillis();
        });
        //must shutdown thread pool ,if not will lead to OOM
        pool.shutdown();
 
    }
 
}

以上理解如果有誤,歡迎大佬指正。

參考資料:

  • Dubbo執行緒池拒絕策略: org.apache.dubbo.common.threadpool.support.AbortPolicyWithReport.java
  • 《Java併發程式設計實戰》

相關文章