執行緒池踩坑記 --load飆高的原因

朱清震發表於2018-02-27

去年處理過一個美圖的問題,最近又碰到類似問題了,發現跟美圖那個案例原因是一樣的,在這裡拿出來給大家分享一下。

應該是去年6月底,我們私有化釋出了新版本,然後就拿去給美圖客戶安裝部署了,美圖的美拍應用訪問量較大,新版本部署後問題不斷,後來我接手去處理,在這之已出過不少問題,客戶也不怎麼配合了;

問題現象:美圖客戶的運維說,聽雲應用kafka積壓訊息,backend不工作了,重啟後不久伺服器load值飆高,最高能過萬,美圖方面不讓直接去操作伺服器,懷疑我們是否對自己的應用做過壓測,讓我們自己壓測找問題,尷尬啊;最後好不容易拿到了錯誤日誌,開始分析日誌問題。

在這裡先對load值簡單說明一下,它是linux系統或unix系統下cpu的待處理和正在處理的任務的任務佇列,有兩個原因會導致load飆高, cpu處理不過來或io處理不過來導致等待處理的執行緒數飆升;

日誌裡面各種異常一大堆,肯定是某個地方出了問題導致的連鎖反應,其中有這樣的一些異常:

異常堆疊:


07-07 12:27:11 [pool-9-thread-4] ERROR c.n.n.d.b.p.MobileAppInteractionTraceMessageHandler - failed to write mobileapp interaction trace result to nbfs: unable to create new native thread
java.lang.OutOfMemoryError: unable to create new native thread
        at java.lang.Thread.start0(Native Method)
        at java.lang.Thread.start(Thread.java:714)
        at java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:950)
        at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1368)
        at sun.nio.ch.SimpleAsynchronousFileChannelImpl.implWrite(SimpleAsynchronousFileChannelImpl.java:393)
        at sun.nio.ch.AsynchronousFileChannelImpl.write(AsynchronousFileChannelImpl.java:251)
        at com.networkbench.nbfs.io.NBFSWriter.writeTo(NBFSWriter.java:284)
        at com.networkbench.newlens.datacollector.backend.processor.MobileAppInteractionTraceMessageHandler.receive(MobileAppInteractionTraceMessageHandler.java:131)
        at com.networkbench.newlens.datacollector.backend.processor.MobileAppInteractionTraceMessageHandler.receive(MobileAppInteractionTraceMessageHandler.java:35)
        at com.networkbench.newlens.datacollector.mq.processor.AvroWrappedMessageConsumer$1.run(AvroWrappedMessageConsumer.java:188)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
        at java.lang.Thread.run(Thread.java:745)
07-07 12:27:11 [pool-8-thread-2] ERROR c.n.n.d.b.p.MobileAppErrorTraceMessageHandler - failed to write error trace result to nbfs: unable to create new native thread
java.lang.OutOfMemoryError: unable to create new native thread
        at java.lang.Thread.start0(Native Method)
        at java.lang.Thread.start(Thread.java:714)
        at java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:950)
        at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1368)
        at sun.nio.ch.SimpleAsynchronousFileChannelImpl.implWrite(SimpleAsynchronousFileChannelImpl.java:393)
        at sun.nio.ch.AsynchronousFileChannelImpl.write(AsynchronousFileChannelImpl.java:251)
        at com.networkbench.nbfs.io.NBFSWriter.writeTo(NBFSWriter.java:284)
        at com.networkbench.newlens.datacollector.backend.processor.MobileAppErrorTraceMessageHandler.receive(MobileAppErrorTraceMessageHandler.java:150)
        at com.networkbench.newlens.datacollector.backend.processor.MobileAppErrorTraceMessageHandler.receive(MobileAppErrorTraceMessageHandler.java:38)
        at com.networkbench.newlens.datacollector.mq.processor.AvroWrappedMessageConsumer$1.run(AvroWrappedMessageConsumer.java:188)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)

分析這個堆疊要用到的知識點:這個異常堆疊涉及到執行緒池程式碼,如果你看過執行緒池的原始碼,那麼分析起來就會比較輕鬆,否則可能不知道到我在說什麼,這裡不會去講執行緒池原始碼,請自己找資料去了解,也可以往後翻我的部落格,看我寫過的一篇原始碼分析java程式設計師必精–從原始碼講解java執行緒池ThreadPoolExecuter的實現原理 , 建議一定要弄懂執行緒池的實現,如果你經常分析執行緒堆疊就會知道,執行緒池用到的地方非常多,沒有幾個應用不使用執行緒池的;

根據開頭的四行執行緒棧分析可以知執行緒池在執行addWorker方法時,無法建立執行緒,丟擲了unable to create new native thread的異常,這個異常有點特殊,它並不是指java堆記憶體溢位了,它說明堆外作業系統的記憶體已經用盡了,java的執行緒在java裡面只是一個Thread物件,這個Thread物件對應著一個作業系統的執行緒,每個執行緒都要分配執行緒棧,執行緒棧佔用的是堆外作業系統記憶體,當作業系統記憶體用盡的時候,再建立執行緒就會丟擲這個異常;

Java裡面有以下幾種操作會佔用堆外的作業系統記憶體:
1. 建立DirectBuffer物件;
2 .map方式開啟檔案,會佔用作業系統的pagecache;
3. 建立執行緒;
我們應用裡面沒有用到mmap的地方,那麼就剩下1、3兩種情況了;
從load飆高,可知一定和3有關係,load值最高能過萬,是什麼原因導致建立了這麼多的執行緒呢?

我們從後往前分析一下執行緒堆疊:

最後兩行說明是執行緒池裡面的執行緒在執行任務,如果熟悉執行緒池原始碼,一看就知道這是執行緒池Worker工作執行緒的run方法中呼叫runWorker執行任務,它的run方法中只有呼叫runWorker這一行程式碼:

    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)

倒數第三行到倒數第六行,一看就是在執行我們自己的業務程式碼:

    at com.networkbench.nbfs.io.NBFSWriter.writeTo(NBFSWriter.java:284)
        at com.networkbench.newlens.datacollector.backend.processor.MobileAppErrorTraceMessageHandler.receive(MobileAppErrorTraceMessageHandler.java:150)
        at com.networkbench.newlens.datacollector.backend.processor.MobileAppErrorTraceMessageHandler.receive(MobileAppErrorTraceMessageHandler.java:38)
        at com.networkbench.newlens.datacollector.mq.processor.AvroWrappedMessageConsumer$1.run(AvroWrappedMessageConsumer.java:188)

最後一行可知,runWorker方法中執行到了task.run方法,task就是AvroWrappedMessageConsumer$1這個類,正在呼叫業務程式碼,從業務程式碼可知是在處理ErrorTrace資料,ErrorTrace就是抓取到的錯誤堆疊資訊,錯誤資訊堆疊就像上面展示的異常堆疊一樣,堆疊層級越多,trace資訊就越大;這個trace資訊會通過我們的NBFS元件寫入到磁碟檔案中去,NBFS元件是我們架構師寫一個jar包,之前從來沒有關注過它是如何實現的;

繼續分析堆疊資訊,接下來的兩行就有意思了:

    at sun.nio.ch.SimpleAsynchronousFileChannelImpl.implWrite(SimpleAsynchronousFileChannelImpl.java:393)
    at sun.nio.ch.AsynchronousFileChannelImpl.write(AsynchronousFileChannelImpl.java:251)

這已經不是我們的業務程式碼了,並且已經到了sun包路徑下的類了,sun包下的類一般都是和不同作業系統的實現有關,sun的jdk沒有開源這個包下的原始碼,但是可以下載openjdk的原始碼來看,大部分程式碼都是一樣的,從程式碼中可以看到是在執行AsynchronousFileChannelImpl.write()方法,這是java AIO中寫檔案的方法;

接下來的四行程式碼又涉及到了執行緒池:

        at java.lang.Thread.start0(Native Method)
        at java.lang.Thread.start(Thread.java:714)
        at java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:950)
        at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1368)

從列印的堆疊可以看出是執行緒池在執行了execute操作後,接著執行addWorker操作建立新的執行緒來執行任務,然後新的執行緒無法建立就oom了,這說明AIO寫檔案的時候用到了執行緒池來建立執行緒;


如果你讀過,ThreadPoolExecutor原始碼就會知道addWorker方法在execute方法中有3個地方會呼叫到,而出異常的時候執行緒池的狀態一定是RUNNING狀態 ,所以在RUNNING狀態下,有以下三種情況會去呼叫AddWorker:

  1. 執行緒池中執行緒數小於corePoolSize,這時候會直接執行addWorker方法建立一個新的執行緒執行任務(如果建立執行緒池的時候設定的corePoolSize為0,那麼這一步的addWorker不會有機會執行);

  2. . 執行緒池中執行緒數大於等於corePoolSize,任務就會進入到佇列裡面,如果這時候執行緒池中執行緒數為0,那麼就會執行addWorker方法建立一個新的工作執行緒,去任務佇列裡取任務執行(執行到這一步,一種情況是執行緒池的corePoolSize設定為0,於是就可以跳過步驟1,直接執行到了這一步,還有一種情況是設定了allowCoreThreadTimeOut為true,執行到步驟一的時候,所有執行緒都存活,執行到步驟2的時候全部執行緒都超時了,但是出現這種情況的機率比中彩票還低);

  3. 執行緒池中的執行緒數大於等於corePoolSize,任務入隊失敗,說明任務佇列已經滿了,則會呼叫addWorker方法去建立一個新的執行緒,只要執行緒池中執行緒數不大於maximumPoolSize,就會建立成功;


從異常堆疊看,執行緒池中的執行緒數不大於maximumPoolSize,因為已經執行到了addWorker方法的Thread.start()執行緒的方法了,說明是可以執行到建立執行緒物件這一步的,只是在啟動執行緒的時候,因為無法申請到執行緒棧記憶體,導致了oom;

根據系統執行情況,當時kafka資料積壓,io操作頻繁,所以執行緒池一定是全速運轉,執行緒池中執行緒數量不太可能小於corePoolSize;

所以addWorker只可能是執行第二步和第三步,只能分析到這裡了,已經沒有什麼思路了;

但是如果你還知道執行緒池執行緒池相關的更多知識,你就能分析到問題可能發生的原因:


這裡就又用到執行緒池實現的知識了,先想一下java內建的幾種執行緒池的坑:
1. Executors.newFixedThreadPool(10);
固定大小的執行緒池,它的實現

new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());

初始化一個指定執行緒數的執行緒池,其中corePoolSize == maximumPoolSize,使用LinkedBlockingQuene作為阻塞佇列,超時時間為0,當執行緒池沒有可執行任務時,也不會釋放執行緒。
因為佇列LinkedBlockingQueue大小為預設的Integer.MAX_VALUE,可以無限的往裡面新增任務,直到記憶體溢位;

2.Executors.newCachedThreadPool();

快取執行緒池,它的實現:

new ThreadPoolExecutor(0,Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());

初始化一個可以快取執行緒的執行緒池,預設超時時間60s,執行緒池的最小執行緒數時0,但是最大執行緒數為Integer.MAX_VALUE,即2147483647,內部使用SynchronousQueue作為阻塞佇列;

因為執行緒池的最大值了Integer.MAX_VALUE,會導致無限建立執行緒;所以,使用該執行緒池時,一定要注意控制併發的任務數,如果短時有大量任務要執行,就會建立大量的執行緒,導致嚴重的效能問題(執行緒上下文切換帶來的開銷),執行緒建立佔用堆外記憶體,如果任務物件也不小,它就會使堆外記憶體和堆內記憶體其中的一個先耗盡,導致oom;

3.Executors.newSingleThreadExecutor()
單執行緒執行緒池,它的實現

new FinalizableDelegatedExecutorService(
    new ThreadPoolExecutor(1, 1,0L, 
                        TimeUnit.MILLISECONDS,
                        new LinkedBlockingQueue<Runnable>()
                        )
);

同newFixedThreadPool執行緒池一樣,佇列用的是LinkedBlockingQueue,佇列大小為預設的Integer.MAX_VALUE,可以無限的往裡面新增任務,直到記憶體溢位;


從現象來看很有可能是使用了第二種執行緒池建立的方式:Executors.newCachedThreadPool();

結合這個執行緒池的特性和上面分析的addWorker呼叫的兩種可能性,那麼就可以推斷出addWorker方法一定執行的是步驟3的addWorker方法;
為什麼這麼肯定呢?因為這個執行緒池的corePoolSize大小為0,所以步驟1的addWorker方法一定不會執行到,步驟二對於這個執行緒池更是不可能執行到了,因為這個執行緒池用的佇列是SynchronousQueue,對於生產執行緒,除非佇列裡面已經存在消費執行緒在等待,可以直接匹配,否則入隊永遠返回的是false,就直接跳到步驟3的addWorker方法,如果佇列裡面已經存在消費者執行緒可以匹配,那麼執行緒池中的執行緒數就不會是0,所以步驟二的addWorker方法也是不可能執行到的;

我們去看一下linux openjdk1.8原始碼來驗證一下(注意一定是linux的,不能是window的,前面提到了,涉及到sun包下的程式碼不同作業系統是不一樣的),根據異常堆疊給出的資訊,從linux openjdk1.8的原始碼包裡面找到sun.nio.ch.SimpleAsynchronousFileChannelImpl的implWrite方法的393行(這裡是394行比異常執行緒棧指示的行號多了一行):

 @Override
    <A> Future<Integer> implWrite(final ByteBuffer src,
                                  final long position,
                                  final A attachment,
                                  final CompletionHandler<Integer,? super A> handler){
        if (position < 0)
            throw new IllegalArgumentException("Negative position");
        if (!writing)
            throw new NonWritableChannelException();

        // complete immediately if channel is closed or no bytes remaining
        if (!isOpen() || (src.remaining() == 0)) {
            Throwable exc = (isOpen()) ? null : new ClosedChannelException();
            if (handler == null)
                return CompletedFuture.withResult(0, exc);
            Invoker.invokeIndirectly(handler, attachment, 0, exc, executor);
            return null;
        }

        final PendingFuture<Integer,A> result = (handler == null) ?
            new PendingFuture<Integer,A>(this) : null;

    //建立Runnable任務
        Runnable task = new Runnable() {
            public void run() {
                int n = 0;
                Throwable exc = null;

                int ti = threads.add();
                try {
                    begin();
                    do {
            //通過IOUtil.write方法將資料src寫入到fdObj指向的檔案
                        n = IOUtil.write(fdObj, src, position, nd);
                    } while ((n == IOStatus.INTERRUPTED) && isOpen());
                    if (n < 0 && !isOpen())
                        throw new AsynchronousCloseException();
                } catch (IOException x) {
                    if (!isOpen())
                        x = new AsynchronousCloseException();
                    exc = x;
                } finally {
                    end();
                    threads.remove(ti);
                }
                if (handler == null) {
                    result.setResult(n, exc);
                } else {
                    Invoker.invokeUnchecked(handler, attachment, n, exc);
                }
            }
        };
    //這裡就是執行緒堆疊指示的393行,通過執行緒池執行任務;
        executor.execute(task);
        return result;
    }
}

我們就去看看這個執行緒池是怎麼建立的,這裡的execute物件是SimpleAsynchronousFileChannelImpl類的父類AsynchronousFileChannelImpl的一個成員變數,它會在SimpleAsynchronousFileChannelImpl的構造方法中傳入進來:

   SimpleAsynchronousFileChannelImpl(FileDescriptor fdObj,
                                      boolean reading,
                                      boolean writing,
                                      ExecutorService executor)
    {
        super(fdObj, reading, writing, executor);
    }

然後呼叫父類的構造方法,賦值給成員變數;

 protected AsynchronousFileChannelImpl(FileDescriptor fdObj,
                                          boolean reading,
                                          boolean writing,
                                          ExecutorService executor)
    {
        this.fdObj = fdObj;
        this.reading = reading;
        this.writing = writing;
        this.executor = executor;//賦值給成員變數
    }

如果細心點你就會發現,SimpleAsynchronousFileChannelImpl的構造方法沒有public修飾符,我們無法在不是同一個包裡面的類裡直接new它;

如果要獲取它的例項物件,SimpleAsynchronousFileChannelImpl類有一個public static的open方法,通過這個方法可以建立SimpleAsynchronousFileChannelImpl物件,並且在這裡發現執行緒池建立相關的程式碼邏輯:

 public static AsynchronousFileChannel open(FileDescriptor fdo,
                                               boolean reading,
                                               boolean writing,
                                               ThreadPool pool)
    {
        // Executor is either default or based on pool parameters
        ExecutorService executor = (pool == null) ?
            DefaultExecutorHolder.defaultExecutor : pool.executor();//如果沒有傳入執行緒池,那麼就使用預設的DefaultExecutorHolder.defaultExecutor;
        return new SimpleAsynchronousFileChannelImpl(fdo, reading, writing, executor);//呼叫構造方法建立物件例項
    }

如果我們沒有給它指定執行緒池的話,那麼它會使用DefaultExecutorHolder.defaultExecutor預設的執行緒池;

DefaultExecutorHolder是SimpleAsynchronousFileChannelImpl類裡面有一個內部類:

    // lazy initialization of default thread pool for file I/O
    private static class DefaultExecutorHolder {
        static final ExecutorService defaultExecutor =
            ThreadPool.createDefault().executor();//呼叫了ThreadPool這個類createDefault()方法建立線預設的執行緒池
    }

又找到ThreadPool.createDefault()方法:

 static ThreadPool createDefault() {
        // default the number of fixed threads to the hardware core count
        int initialSize = getDefaultThreadPoolInitialSize();
        if (initialSize < 0)
            initialSize = Runtime.getRuntime().availableProcessors();
        // default to thread factory that creates daemon threads
        ThreadFactory threadFactory = getDefaultThreadPoolThreadFactory();
        if (threadFactory == null)
            threadFactory = defaultThreadFactory;

        //看這一行,正是使用了Executors.newCachedThreadPool(threadFactory)方法來建立的執行緒池
        // create thread pool
        ExecutorService executor = Executors.newCachedThreadPool(threadFactory);
        return new ThreadPool(executor, false, initialSize);
    }

這就解釋通了為什麼,作業系統oom,無法建立更多執行緒,load值飆高;

然後我就去找我們NBFS元件裡,進一步驗證:

//這裡是開啟channel的程式碼
public class LocalAsynchronousFileChannelManager extends AbstractFileChannelManager implements FileChannelManager{
    @Override
    public Channel createFileChannel(final String fileName) throws IOException {
        if (fileName == null) {
            throw new IllegalArgumentException("fileName not specified: " + fileName);
        }
        final Path path = Paths.get(fileName, new String[0]);
        final File dir = path.getParent().toFile();
        if (!dir.exists()) {
            dir.mkdirs();
        }
        //open的時候沒有指定執行緒池:
        return AsynchronousFileChannel.open(path, StandardOpenOption.WRITE, StandardOpenOption.CREATE);
    }
}

果然,沒有指定執行緒池,要解決這個問題,只要指定一個合理的最大執行緒數量的執行緒池即可;

最後理了一下問題發生的原因:因為客戶trace量比較多,當訊息積壓的時候,會不斷的往磁碟寫資料,客戶是伺服器是使用nfs掛載的磁碟,所以讀寫能力會受到網路狀況的影響,當網路繁忙,或網路狀況不好的時候,寫入資料的速度已經超過了網路或磁碟io的能力,導致寫執行緒被阻塞,而新的任務又不斷往執行緒池裡面放,每放一個任務,執行緒池就要建立一個新的執行緒,最後的現象就是load值不斷飆高,直到最後導致oom;

導致系統oom的原因還有另外一個,因為是使用AIO,往檔案裡面寫資料的時候,它會自動將我們傳入的資料由HeapByteBuffer轉換為DirectByteBuffer;前面也講到DirectByteBuffer也會佔用堆外記憶體,這部分的原始碼我就不分析了,感興趣的可以自己去看下,所以是資料的DirectByteBuffer與執行緒棧一起耗盡了堆外記憶體;

另外通過分析原始碼,還發現原來linux下java寫檔案的AIO之所以不會被阻塞,其實是使用執行緒池模擬的啊;

相關文章