程序員樓下閒聊:某次jvm崩潰排查

大望路某寫字樓下。

猿A:上家公司的時候,我們組那個項目,每天半夜會跑個大批量數據處理的定時任務,然後程序經常崩潰。
我:哦?那怎麼處理的
猿A:當時的架構有點水,說讓調整“伊甸園”和“from-to”的比例……崩潰和這個就沒關係
我:少年,你成功引起了我的注意。來來來,請你喝飲料,好好聊聊當時的情況。

業務場景

“猿A”是我的同事兼死黨,和他詳聊後大概明白了當時的場景。

據我理解,那個定時任務,會從hive裏拿出超多的數據(據說2億左右),按具體業務做數據整合和處理,最終推送到es(elasticsearch)中。(hive什麼的我沒搞過,但不妨礙要討論的東西)

業務處理部分,使用了線程池FixedThreadPool

模擬解決過程

問題定位

猿A:那時候懷疑是內存OOM導致的jvm崩潰,進而懷疑大量對象GC回收不了,於是打了GC日誌。
我:嗯,沒用hs_err_pid_xxx.log分析嗎?
猿A:當時小,還不會這個技能……

死黨“猿A”當時的解決過程比較粗暴,有了懷疑就直接在啓動參數增加了-XX:+PrintGC。此命令會打印GC日誌,姑且認爲生產環境使用GC是CMS,寫個demo模擬當時的場景。

public class CMSGCLogs {
    //啓動參數:-Xmx10m -Xms10m -Xmn4M -XX:+PrintGC -XX:+UseConcMarkSweepGC
    public static void main(String[] args) throws InterruptedException {
        // 線程數設置爲1,起名`T-1`
        ExecutorService es = Executors.newFixedThreadPool(1, new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                return new Thread(r,"T-1");
            }
        });

        boolean flag = true;
        while (flag){
            es.execute(()->{
                try {
                    byte[] bytes = new byte[1024*1000*1];    //模擬從hive中讀取了大量數據(1M)
                    TimeUnit.MILLISECONDS.sleep(50L);   //模擬寫入es過程
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
        }
        es.shutdown();
    }
}

先背一段線程池的處理過程。

線程池接收請求,小於核心線程數時,會創建線程,直到等於核心線程數。
之後,核心線程數消化不了的請求放入阻塞隊列中。
再後,阻塞隊列也放滿了,再繼續創建線程,直到最大線程數。
最大線程也扛不住依然洶涌的請求,執行拒絕策略。

fixed線程池,作爲線程池家族中的一員,也滿足上述規則。
差別在於它的 核心線程數=最大線程數,然後阻塞隊列(LinkedBlockingQueue)是無界的

# 源碼中fixed線程池創建
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>(),
                                  threadFactory);
}

運行一下,demo執行結果大概長這樣:

[GC (Allocation Failure)  3302K->1174K(9856K), 0.0037023 secs]
[GC (Allocation Failure)  3664K->1541K(9856K), 0.0014799 secs]
[Full GC (Allocation Failure)  1541K->1446K(9856K), 0.0039197 secs]
[Full GC (Allocation Failure)  1446K->1387K(9856K), 0.0037783 secs]
## 線程T-1 OOM
Exception in thread "T-1" java.lang.OutOfMemoryError: Java heap space
    at com.evolution.gc.CMSGCLogs.lambda$main$0(CMSGCLogs.java:44)
    at com.evolution.gc.CMSGCLogs$$Lambda$1/233530418.run(Unknown Source)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:748)

...

[Full GC (Allocation Failure)  9676K->9676K(9856K), 0.0253352 secs]
[Full GC (Allocation Failure)  9676K->9676K(9856K), 0.0190795 secs]
## 線程T-1 OOM
Exception in thread "T-1" java.lang.OutOfMemoryError: Java heap space
Exception in thread "T-1" java.lang.OutOfMemoryError: Java heap space

...

[GC (CMS Initial Mark)  9855K(9856K), 0.0051677 secs]
## 線程main OOM
Exception in thread "main" [Full GC (Allocation Failure)  9855K->9855K(9856K), 0.0211383 secs]
[Full GC (Allocation Failure)  9855K->9855K(9856K), 0.0203374 secs]
[Full GC (Allocation Failure)  9855K->9855K(9856K), 0.0433360 secs]
[GC (CMS Initial Mark)  9855K(9856K), 0.0029999 secs]
[Full GC (Allocation Failure) java.lang.OutOfMemoryError: Java heap space
 9855K->9855K(9856K), 0.0249560 secs]
[Full GC (Allocation Failure)  9855K->9854K(9856K), 0.0244291 secs]
[GC (CMS Initial Mark)  9854K(9856K), 0.0063567 secs]
[Full GC (Allocation Failure)  9854K->9854K(9856K), 0.0208301 secs]
[Full GC (Allocation Failure)  9854K->9854K(9856K), 0.0346616 secs]
[Full GC (Allocation Failure)  9855K->1147K(9856K), 0.0067034 secs]
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message can't create byte arrau at JPLISAgent.c line: 813

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "T-1"

Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

其實對fixed線程池熟悉的朋友,在看到使用的是這位爺的時候,就能猜出問題是什麼了——Fixed線程池的無界隊列,會使試圖無限存儲任務,直到內存溢出。
從GC日誌來看也確實是這個路數,還很貼心的指出了哪個線程,哪個對象,甚至是哪行異常了(at com.evolution.gc.CMSGCLogs$$Lambda$1/233530418.run(Unknown Source),當然我這裏用了內部類和函數式,所以看着稍微費勁些)。

支線任務1:子線程OOM問題

但有一點和我想象的不一樣,日誌中線程T-1崩潰了多次,程序依然堅挺了一陣,才迎來了線程main的崩潰。

這就奇怪了,堆內存不是公用的嗎?子線程T-1都崩潰了,爲什麼沒帶動整個JVM崩潰?

因此,我用VisualVM觀察了內存情況:
clipboard.png
圖中的斷崖處就是線程T-1 OOM的時候,再求證了相關資料後,得出結論:當某一線程(T-1)OOM的時候,會把該線程佔用的內存釋放,而不影響其它線程(main)!

詳細過程是這樣的:

  1. 線程T-1業務處理較慢(TimeUnit.MILLISECONDS.sleep(50L);),任務又大量的涌過來,致使fixed線程池的阻塞隊列瘋狂儲備待執行任務,內存逐漸吃緊;
  2. 線程T-1每次執行又會佔用1M內存(byte[] bytes = new byte[102410001];),由於內存不足(內存被阻塞隊列佔據了,已不足1M),線程T-1無奈宣告崩潰;
  3. 隨着線程T-1的崩潰,會把資源釋放出來,重新進入循環(下一輪循環,線程T-1繼續崩潰……),直到某個契機把線程main也搞崩潰了,整個JVM才崩潰退出

支線任務2:CMS的STP

我在demo調試時,無意間改大過模擬寫入es的時間,表示耗時更長(如下):

while (flag){
    es.execute(()->{
        try {
            byte[] bytes = new byte[1024*1000*1];
            TimeUnit.HOURS.sleep(50L);    ## 直接把單位從`MILLISECONDS`改成`HOURS`了
        } catch (Exception e) {
            e.printStackTrace();
        }
    });
}

原本以爲這樣會使jvm崩潰的更快——線程T-1 50個小時才能執行完,任務又無盡積壓,jvm理應更快的走向滅亡。
結果卻事與願違!

程序執行的效果變成了這樣:

31.949: [GC (CMS Initial Mark)  9855K(9856K), 0.0033049 secs]
31.966: [GC (CMS Final Remark)  9855K(9856K), 0.0046193 secs]
32.401: [Full GC (Allocation Failure)  9855K->9855K(9856K), 0.0243874 secs]
32.425: [Full GC (Allocation Failure)  9855K->9855K(9856K), 0.0240275 secs]
32.450: [GC (CMS Initial Mark)  9855K(9856K), 0.0033793 secs]
32.466: [GC (CMS Final Remark)  9855K(9856K), 0.0048207 secs]
34.473: [GC (CMS Initial Mark)  9855K(9856K), 0.0033645 secs]
34.485: [GC (CMS Final Remark)  9855K(9856K), 0.0046805 secs]
36.491: [GC (CMS Initial Mark)  9855K(9856K), 0.0032574 secs]
...
109.890: [GC (CMS Initial Mark)  9855K(9856K), 0.0038242 secs]
109.902: [GC (CMS Final Remark)  9855K(9856K), 0.0053191 secs]
111.909: [GC (CMS Initial Mark)  9855K(9856K), 0.0106591 secs]
111.945: [GC (CMS Final Remark)  9855K(9856K), 0.0059029 secs]

我得到了無盡的CMS Initial MarkCMS Final Remark

VisualVM的分析圖長這樣:
clipboard.png

內存將將滿,就是等不到“壓倒駱駝的最後一根稻草”。
仔細思考了一番,想起CMS執行過程中有兩個stop the world(STP)的環節,似乎就是初始標記(CMS Initial Mark)和最終標記(CMS Final Remark)兩兄弟?

那這個現象就能解釋通了:
隨着堆內存的佔滿,CMS收集器開始執行,但兩個標記環節怎麼執行都發現沒有能回收的資源。(1M的bytes不能回收,因爲50小時的休眠後還能用到;阻塞隊列中的任務也不能回收,jvm覺得總會執行這些任務的)
因此,內存吃緊jvm崩潰前夕,CMS就不斷執行初始標記和最終標記,而兩個命令會導致用戶線程(線程T-1)停止,沒機會再給jvm添堵。
於是就這樣微妙的保持了詭異的平衡……

解決方案

以上兩個支線任務是我demo模擬中遇到的魑魅魍魎,猿A同志並沒有搞出這麼多彎彎繞(當然他可能有其它際遇)。
既然已經定位到了問題產生於FixedThreadPool,那麼換掉它就好,自己new ThreadPoolExecutor

  • 參數指定有界隊列
  • 修改拒絕策略爲ThreadPoolExecutor.CallerRunsPolicy

拒絕策略默認是ThreadPoolExecutor.AbortPolicy,表現爲丟棄任務並拋出異常;而ThreadPoolExecutor.CallerRunsPolicy則不勞煩線程池,直接交由原線程執行任務(demo中就是不麻煩線程T-1了,線程main頂上)

Tip:這裏給出全部策略,做個參考 ↓↓↓

ThreadPoolExecutor.AbortPolicy:丟棄任務並拋出RejectedExecutionException異常。
ThreadPoolExecutor.DiscardPolicy:也是丟棄任務,但是不拋出異常。
ThreadPoolExecutor.DiscardOldestPolicy:丟棄隊列最前面的任務,然後重新嘗試執行任務(重複此過程)
ThreadPoolExecutor.CallerRunsPolicy:由調用線程處理該任務

修改之後的demo代碼:

public class CMSGCLogs {
    //-Xmx10m -Xms10m -Xmn4M -XX:+PrintGC -XX:+UseConcMarkSweepGC
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue list = new ArrayBlockingQueue(20);    //修改1:有界隊列
        ExecutorService es = new ThreadPoolExecutor(1, 1,
                0, TimeUnit.MILLISECONDS, list, new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        return new Thread(r,"T-1");
                    }
                },
                new ThreadPoolExecutor.CallerRunsPolicy());   //修改2:更換拒絕策略
        boolean flag = true;
        while (flag){
            es.execute(()->{
                try {
                    byte[] bytes = new byte[1024*1000*1];
                    TimeUnit.MILLISECONDS.sleep(50L);
                    System.out.println(String.format("curThread=%s,run task!",Thread.currentThread())); //打印執行線程
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
        }
        es.shutdown();
    }
}

看下執行效果:

53.422: [GC (Allocation Failure)  6615K->3550K(9856K), 0.0006514 secs]
curThread=Thread[main,5,main],run task!
curThread=Thread[main,5,main],run task!
curThread=Thread[T-1,5,main],run task!
53.473: [GC (Allocation Failure)  6615K->3550K(9856K), 0.0003791 secs]
curThread=Thread[main,5,main],run task!
curThread=Thread[T-1,5,main],run task!
curThread=Thread[main,5,main],run task!
curThread=Thread[T-1,5,main],run task!
53.573: [GC (Allocation Failure)  6615K->3550K(9856K), 0.0003733 secs]
curThread=Thread[main,5,main],run task!
curThread=Thread[T-1,5,main],run task!
53.623: [GC (Allocation Failure)  6615K->3550K(9856K), 0.0009536 secs]
curThread=Thread[main,5,main],run task!
curThread=Thread[T-1,5,main],run task!
curThread=Thread[main,5,main],run task!
curThread=Thread[T-1,5,main],run task!
53.724: [GC (Allocation Failure)  6615K->3550K(9856K), 0.0004394 secs]

我們發現程序已經能夠平穩的運行了,再觀察此時的jvm堆內存,一切正常:
clipboard.png

新生代各區比例的作用

問題解決了,回顧下架構師當時的提議:

猿A:當時的架構有點水,說讓調整“伊甸園”和“from-to”的比例……崩潰和這個就沒關係

“架構”說的是設定-XX:NewRadio參數來調整新生代各模塊比例。
clipboard.png

這個比例默認爲8:1:1,我們來看下調整這個參數產生的影響。

  • 極端一些,把Edian調的很大

clipboard.png

新生代GC時,對象是否進入老年代主要取決於兩個因素——對象年齡和對象大小
如果對象大小超出了某界限,對象就直接進入老年代。如此,會給老年代的GC會增加更多壓力,有可能產生更多的STP。

  • 反向極端,把Edian調的很小

clipboard.png

因爲s1s2兩個區域總有1個是空的,這樣調整會浪費更多的內存,並更頻繁的觸發的新生代GC。



我們感性的理解了這個比例的作用,那麼架構師的提議和猿A遇到的問題有關嗎?答案是沒有,所以那個架構是真的水……
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章