Executors使用不當引起的記憶體溢位

bingfeng發表於2020-09-10

線上服務記憶體溢位

這周剛上班突然有一個專案記憶體溢位了,排查了半天終於找到問題所在,在此記錄下,防止後面再次出現類似的情況。

先簡單說下當出現記憶體溢位之後,我是如何排查的,首先通過jstack列印出堆疊資訊,然後通過分析工具對這些檔案進行分析,根據分析結果我們就可以知道大概是由於什麼問題引起的。

關於jstack如何使用,大家可以先看看這篇文章 jstack的使用

問題排查

下面是我列印出來的資訊,大部分都是這個

"http-nio-8761-exec-124" #580 daemon prio=5 os_prio=0 tid=0x00007fbd980c0800 nid=0x249 waiting on condition [0x00007fbcf09c8000]
   java.lang.Thread.State: TIMED_WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x00000000f73a4508> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
        at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078)
        at java.util.concurrent.LinkedBlockingQueue.poll(LinkedBlockingQueue.java:467)
        at org.apache.tomcat.util.threads.TaskQueue.poll(TaskQueue.java:85)
        at org.apache.tomcat.util.threads.TaskQueue.poll(TaskQueue.java:31)
        at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
        at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
        at java.lang.Thread.run(Thread.java:748)

看到了如上資訊之後,大概可以看出是由於執行緒池的使用不當導致的,那麼根據資訊繼續往下看,看到ThreadPoolExecutor那麼就可以知道這肯定是建立了執行緒池,那麼我們就在程式碼裡找,哪裡建立使用了執行緒池,我就找到這麼一段程式碼。

public class ThreadPool {
    private static ExecutorService pool;

    private static long logTime = 0;

    public static ExecutorService getPool() {
        if (pool == null) {
            pool = Executors.newFixedThreadPool(20);
        }
        return pool;
    }
}

乍一看,可能寫的同學是想把這當一個全域性的執行緒池用,所有的業務凡是用到執行緒的都會使用這個類,為了統一管理執行緒,想法沒什麼毛病,但是這樣寫確實有點子毛病。

newFixedThreadPool分析

上面使用了Executors.newFixedThreadPool(20)建立了一個固定的執行緒池,我們先分析下newFixedThreadPool是怎麼樣的一個流程。

一個請求進來之後,如果核心執行緒有空閒執行緒直接使用核心執行緒中的執行緒執行任務,不會新增到阻塞佇列中,如果核心執行緒滿了,新的任務會新增到阻塞佇列,直到佇列加滿再開執行緒,直到maxPoolSize之後再觸發拒絕執行策略

瞭解了流程之後我們再來看newFixedThreadPool的程式碼實現。

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}
public LinkedBlockingQueue() {
    this(Integer.MAX_VALUE);
}
public LinkedBlockingQueue(int capacity) {
    if (capacity <= 0) throw new IllegalArgumentException();
    // 任務阻塞佇列的初始容量
    this.capacity = capacity;
    last = head = new Node<E>(null);
}

定位問題

看到了這裡不知道你是否知道了此次引起記憶體洩漏的原因,其實就是因為阻塞佇列的容量過大

如果不手動的指定阻塞佇列的大小,那麼它預設是Integer.MAX_VALUE,我們的執行緒池只有20個執行緒可以處理任務,其他的請求全部放到阻塞佇列中,那麼當湧入大量的請求之後,阻塞佇列一直增加,你的記憶體配置又非常緊湊的話,那麼是很容易出現記憶體溢位的。

我們的業務是在APP啟動的時候,會使用執行緒池去檢查使用者的一些配置,應用的啟動量還是非常大的而且給的記憶體配置也不是很足,所以執行一段時間後,部分容器就出現了記憶體溢位的情況。

如何正確的建立執行緒池

以前其實沒太在意這種問題,都是使用Executors去建立執行緒,但是這樣確實會存在一些問題,就像這些的記憶體洩漏,所以一般不要使用Executors去建立執行緒,使用ThreadPoolExecutor進行建立,其實Executors底層也是使用ThreadPoolExecutor進行建立的。

使用ThreadPoolExecutor建立需要自己指定核心執行緒數、最大執行緒數、執行緒的空閒時長以及阻塞佇列。

3種阻塞佇列
  • ArrayBlockingQueue:基於陣列的先進先出佇列,有界
  • LinkedBlockingQueue:基於連結串列的先進先出佇列,有界
  • SynchronousQueue:無緩衝的等待佇列,無界

我們使用了有界的佇列,那麼當佇列滿了之後如何處理後面進入的請求,我們可以通過不同的策略進行設定。

4種拒絕策略
  • AbortPolicy:預設,佇列滿了丟任務丟擲異常
  • DiscardPolicy:佇列滿了丟任務不異常
  • DiscardOldestPolicy:將最早進入佇列的任務刪,之後再嘗試加入佇列
  • CallerRunsPolicy:如果新增到執行緒池失敗,那麼主執行緒會自己去執行該任務
在建立之前,先說下我最開始的版本,因為佇列是固定的,最開始我們不知道有拒絕策略,所以在佇列滿了之後再新增的話會出現異常,我就在異常裡面睡眠了1秒,等待其他的執行緒執行完畢獲取空閒連線,但是還是會有部分不能得到執行。

接下來我們來建立一個容錯率比較高的執行緒池。

public class WordTest {

    public static void main(String[] args) throws InterruptedException {

        System.out.println("開始執行");

        // 阻塞佇列容量宣告為100個
        ThreadPoolExecutor executorService = new ThreadPoolExecutor(10, 10,
                0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(100));

        // 設定拒絕策略
        executorService.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 空閒佇列存活時間
        executorService.setKeepAliveTime(20, TimeUnit.SECONDS);

        List<Integer> list = new ArrayList<>(2000);

        try {
            // 模擬200個請求
            for (int i = 0; i < 200; i++) {
                final int num = i;
                executorService.execute(() -> {
                    System.out.println(Thread.currentThread().getName() + "-結果:" + num);
                    list.add(num);
                });
            }
        } finally {
            executorService.shutdown();
            executorService.awaitTermination(10, TimeUnit.SECONDS);
        }
        System.out.println("執行緒執行結束");
    }
}

思路:我宣告瞭100容量的阻塞佇列,模擬了一個200的請求,很顯然肯定有部分請求進入不了佇列,但是我使用了CallerRunsPolicy策略,當佇列滿了之後,使用主執行緒去進行處理,這樣就不會出現有部分請求得不到執行的情況,也不會因為因為阻塞佇列過大導致記憶體溢位的情況。

如果還有什麼更好地寫法歡迎各位指教!

通過測試200個請求全部得到執行,有3個請求由主執行緒進行了處理。

總結

如何更好的建立執行緒池上面已經說過了,關於執行緒池在業務中的使用,其實我們這種全域性的思路是不太好的,因為如果從全域性考慮去建立執行緒池,是很難把控的,因為你無法準確地評估所有的請求加起來會有多大的量,所以最好是每個業務建立獨立的執行緒池進行處理,這樣是很容易評估量化的。

另外建立的時候,最好評估下大概每秒的請求量有多少,然後來合理的初始化執行緒數和佇列大小。

參考文章:<br/>
https://www.cnblogs.com/muxi0...

更多精彩內容請關注微信公眾號:一個程式設計師的成長

相關文章