大望路某寫字樓下。 猿A:上家公司的時候,咱們組那個項目,天天半夜會跑個大批量數據處理的定時任務,而後程序常常崩潰。 我:哦?那怎麼處理的 猿A:當時的架構有點水,說讓調整「伊甸園」和「from-to」的比例……崩潰和這個就不要緊 我:少年,你成功引發了個人注意。來來來,請你喝飲料,好好聊聊當時的狀況。
「猿A」是個人同事兼死黨,和他詳聊後大概明白了當時的場景。java
據我理解,那個定時任務,會從hive
裏拿出超多的數據(聽說2億左右),按具體業務作數據整合和處理,最終推送到es(elasticsearch)中。(hive什麼的我沒搞過,但不妨礙要討論的東西)架構
業務處理部分,使用了線程池FixedThreadPool
。jvm
猿A:那時候懷疑是內存OOM致使的jvm崩潰,進而懷疑大量對象GC回收不了,因而打了GC日誌。 我:嗯,沒用hs_err_pid_xxx.log分析嗎? 猿A:當時小,還不會這個技能……
死黨「猿A」當時的解決過程比較粗暴,有了懷疑就直接在啓動參數增長了-XX:+PrintGC
。此命令會打印GC日誌,姑且認爲生產環境使用GC是CMS,寫個demo模擬當時的場景。elasticsearch
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(); } }
先背一段線程池的處理過程。ide
線程池接收請求,小於核心線程數時,會建立線程,直到等於核心線程數。 以後,核心線程數消化不了的請求放入阻塞隊列中。 再後,阻塞隊列也放滿了,再繼續建立線程,直到最大線程數。 最大線程也扛不住依然洶涌的請求,執行拒絕策略。
fixed線程池,做爲線程池家族中的一員,也知足上述規則。
差異在於它的 核心線程數=最大線程數,而後阻塞隊列(LinkedBlockingQueue)是無界的。函數
# 源碼中fixed線程池建立 public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory); }
運行一下,demo執行結果大概長這樣:ui
[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)
,固然我這裏用了內部類和函數式,因此看着稍微費勁些)。spa
但有一點和我想象的不同,日誌中線程T-1
崩潰了屢次,程序依然堅挺了一陣,才迎來了線程main的崩潰。線程
這就奇怪了,堆內存不是公用的嗎?子線程T-1都崩潰了,爲何沒帶動整個JVM崩潰?3d
所以,我用VisualVM觀察了內存狀況:
圖中的斷崖處就是線程T-1 OOM的時候,再求證了相關資料後,得出結論:當某一線程(T-1)OOM的時候,會把該線程佔用的內存釋放,而不影響其它線程(main)!
詳細過程是這樣的:
線程T-1
業務處理較慢(TimeUnit.MILLISECONDS.sleep(50L);),任務又大量的涌過來,導致fixed線程池
的阻塞隊列瘋狂儲備待執行任務,內存逐漸吃緊;線程T-1
每次執行又會佔用1M內存(byte[] bytes = new byte[102410001];),因爲內存不足(內存被阻塞隊列佔據了,已不足1M),線程T-1無奈宣告崩潰;線程T-1
的崩潰,會把資源釋放出來,從新進入循環(下一輪循環,線程T-1繼續崩潰……),直到某個契機把線程main也搞崩潰了,整個JVM才崩潰退出我在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 Mark
和CMS Final Remark
。
VisualVM的分析圖長這樣:
內存將將滿,就是等不到「壓倒駱駝的最後一根稻草」。
仔細思考了一番,想起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.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堆內存,一切正常:
問題解決了,回顧下架構師當時的提議:
猿A:當時的架構有點水,說讓調整「伊甸園」和「from-to」的比例……崩潰和這個就不要緊
「架構」說的是設定-XX:NewRadio
參數來調整新生代各模塊比例。
這個比例默認爲8:1:1,咱們來看下調整這個參數產生的影響。
Edian
調的很大 新生代GC時,對象是否進入老年代主要取決於兩個因素——對象年齡和對象大小。
若是對象大小超出了某界限,對象就直接進入老年代。如此,會給老年代的GC會增長更多壓力,有可能產生更多的STP。
Edian
調的很小 由於s1
和s2
兩個區域總有1個是空的,這樣調整會浪費更多的內存,並更頻繁的觸發的新生代GC。