案例發生現場
有一天突然收到線上的一個報警:某台機器部署的一個服務突然之間就不可以訪問了。
此時第一反應當然是立馬登錄上機器去看一下日志,因為服務掛掉,很可能是OOM導致的崩潰,當然也可能是其他原因導致的問題。
這個時候在機器的日志中發現了如下的一些信息:
nio handle failed java.lang.OutOfMemoryError: Direct buffer memory
at org.eclipse.jetty.io.nio.xxxx
at org.eclipse.jetty.io.nio.xxxx
at org.eclipse.jetty.io.nio.xxxx
過多的日志信息給省略掉了,因為都是非常雜亂的一些信息,也沒太大意義,大家關注比較核心的一些信息就可以
上述日志中,最主要的就是告訴我們有OOM異常,但是是哪個區域導致的呢?
居然是我們沒見過的一塊區域:Direct buffer memory ,而且在下面我們還看到一大堆的jetty相關的方法調用棧
到此為止,僅僅看到這些日志,我們基本就可以分析出來這次OOM發生的原因了。
初步分析事故發生的原因
先給大家解釋一個東西:Direct buffer memory
這個東西其實就是堆外內存,顧名思義,他是JVM堆內存之外的一塊內存空間,這塊內存空間不是JVM管理的,因此之前我們也沒怎么多講這塊內容,但是你的Java代碼確實是可以在JVM堆之外使用一些內存空間的。
這些空間就叫做Direct buffer memory,如果按英文字面理解,他的意思是直接內存,其實你要這么叫也沒問題,但是從字面可以看出來,這塊內存並不是JVM管理的,而是“直接”被操作系統管理。
正因為這樣,所以其英文名叫做Direct buffer memory,就是直接內存的意思。但是如果我們叫他直接內存,又顯得非常的奇怪,所以通常更好的稱呼就是“堆外內存”。
另外再給大家解釋一個東西:Jetty。這個是什么?
其實你大致可以理解為跟Tomcat一樣的東西,就是Web容器
Jetty本身也是Java寫的,我們如果寫好了一個系統,可以打包后放入Jetty,啟動Jetty即可。
Jetty啟動之后,本身就是一個JVM進程,他會監聽一個端口號,比如說9090
然后你就向Jetty監聽的9090端口發送請求,Jetty會把請求轉交給你用的Spring MVC之類的框架,Spring MVC之類的框架再去調用寫好的Controller之類的代碼。
我們先看下面一個圖,簡單看看Jetty作為一個JVM進程運行我們寫好的系統的一個流程:
首先我們可以明確一點,這次OOM是Jetty這個Web服務器在使用堆外內存的時候導致的
也就是說,基本可以推測出來,Jetty服務器可能在不停的使用堆外內存,然后堆外內存空間不足了,沒法使用更多的堆外內存了,此時就會拋出內存溢出的異常。
至於為什么Jetty要不停的使用堆外內存空間,大家就暫時先別管那么多了,那涉及到Jetty作為一個Web服務器的底層源碼細節。
我們只要知道他肯定會不停的去使用堆外內存,然后用着用着堆外內存不夠了,就內存溢出了。
我們接着看下面的圖,下面的圖里,就體現出來了Jetty不停使用堆外內存的場景。
關於解決OOM問題的底層技術修為的一點建議
講到這里,我們肯定會有一點很疑惑了:Jetty既然是用Java寫的,那么他是如何通過Java代碼去在JVM堆之外申請一塊堆外內存來使用的?然后這個堆外內存空間又是如何釋放掉的呢?
這個東西其實涉及到Java的NIO的底層技術細節,如果大家之前對NIO沒什么了解的話,突然看到這個異常,估計是沒法繼續分析下去了。
因此這里也給大家額外插一句話,其實JVM的性能優化相對還是較為容易一些的,而且基本上整個套路之前也已經給大家都說的很清楚了。
但是如果是解決OOM問題,那么除了一些特別弱智和簡單的,比如有人在代碼里不停的創建對象最后導致內存溢出這種。其他的很多生產環境下的OOM問題,都是有點技術難度的。
大家如果把整個專欄最后兩周的內容都看完了,就會有一個很深的感觸,那就是1000個工程師可能會遇到1000種不同的OOM問題。
可能排查的思路是類似的,或者解決問題的思路是類似的,但是如果你要解決各種OOM問題,是需要對各種技術都有一定的了解,換句話說,需要有較為扎實的技術內功修為。
比如昨天的那個案例,就需要你對Tomcat的一些工作原理有一定的了解,你才能分析清楚那個案例。
同樣,今天的這個案例,就要求你對Java NIO技術的工作原理有一定的了解才能分析清楚。
這些底層技術積累將會在你的線上系統出現問題的時候,迅速幫助你分析和解決問題。
堆外內存是如何申請的,又是如何釋放的?
接着我們繼續來看看,這個堆外內存是如何申請和釋放的?
簡單來說,如果在Java代碼里要申請使用一塊堆外內存空間,是使用DirectByteBuffer這個類,你可以通過這個類構建一個DirectByteBuffer的對象,這個對象本身是在JVM堆內存里的。
但是你在構建這個對象的同時,就會在堆外內存中划出來一塊內存空間跟這個對象關聯起來,我們看看下面的圖,你就對他們倆的關系很清楚了。
因此在分配堆外內存的時候大致就是這個思路,那么堆外內存是如何釋放的呢?
很簡單,當你的DirectByteBuffer對象沒人引用了,成了垃圾對象之后,自然會在某一次young gc或者是full gc的時候把DirectByteBuffer對象回收掉。
只要回收一個DirectByteBuffer對象,就會自然釋放掉他關聯的那塊堆外內存,我們看看下面的圖就知道了。
為什么會出現堆外內存溢出的情況?
那么大家現在應該很清楚了,一般什么情況下會出現堆外內存的溢出?
很簡單,如果你創建了很多的DirectByteBuffer對象,占用了大量的堆外內存,然后這些DirectByteBuffer對象還沒有GC線程來回收掉,那么就不會釋放堆外內存!
久而久之,當堆外內存都被大量的DirectByteBuffer對象關聯使用了,如果你再要使用更多的堆外內存,那么就會報內存溢出了!
那么,什么情況下會出現大量的DirectByteBuffer對象一直存活着,導致大量的堆外內存無法釋放呢?
有一種可能,就是系統承載的是超高並發,復雜壓力很高,瞬時大量請求過來,創建了過多的DirectByteBuffer占用了大量的堆外內存,此時再繼續想要使用堆外內存,就會內存溢出了!
但是這個系統是這種情況嗎?
明顯不是!因為這個系統的負載其實沒有想象中的那么高,不會有瞬時大量的請求過來。
真正的堆外內存溢出原因分析
這個時候你就得思路活躍起來了,我們完全可以去用jstat等工具觀察一下線上系統的實際運行情況,同時根據日志看看一些請求的處理耗時,綜合性的分析一下。
當時我們通過jstat工具分析jvm運行情況,同時分析了過往的gc日志,另外還看了一下系統各個接口的調用耗時之后,分析出了如下的思路。
首先看了一下接口的調用耗時,這個系統並發量不高,但是他每個請求處理較為耗時,平均在每個請求需要一秒多的時間去處理。
然后我們通過jstat發現,隨着系統不停的被調用會一直創建各種對象,包括Jetty本身會不停的創建DirectByteBuffer對象去申請堆外內存空間,接着直到年輕代的Eden區滿了,就會觸發young gc,如下圖所示。
但是往往在進行垃圾回收的一瞬間,可能有的請求還沒處理完畢,此時就會有不少DirectByteBuffer對象處於存活狀態,不能被回收掉,當然之前不少DirectByteBuffer對象對應的請求可能處理完畢了,他們就可以被回收了。
此時肯定會有一些DirectByteBuffer對象以及一些其他的對象是處於存活狀態的,那么就需要轉入Survivor區域中。
但是大家注意了,這個系統當時在上線的時候,內存分配的極為不合理,在當時而言,大概就給了年輕代一兩百MB的空間,老年代反而給了七八百MB的空間,進而導致年輕代中的Survivor區域只有10MB左右的空間。
因此往往在young gc過后,一些存活下來的對象(包括了一些DirectByteBuffer在內)會超過10MB,沒法放入Survivor中,就會直接進入老年代,我們看下圖就表現出了這個過程。
因此上述的過程就這么反復的執行,必然會慢慢的導致一些DirectByteBuffer對象慢慢的進入老年代中,老年代中的DirectByteBuffer對象會越來越多,而且這些DirectByteBuffer都是關聯了很多堆外內存的,如下圖所示。
這些老年代里的DirectByteBuffer其實很多都是可以回收的狀態了,但是因為老年代一直沒塞滿,所以沒觸發full gc,也就自然不會回收老年代里的這些DirectByteBuffer了!當然老年代里這些沒有被回收的DirectByteBuffer就一直關聯占據了大量的堆外內存空間了!
直到最后,當你要繼續使用堆外內存的時候,結果所有堆外內存都被老年代里大量的DirectByteBuffer給占用了,雖然他們可以被回收,但是無奈因為始終沒有觸發老年代的full gc,所以堆外內存也始終無法被回收掉。
最后就會導致內存溢出問題的發生!
難道Java NIO就沒考慮過這個問題嗎?
所以這里我們先不說如何解決這個問題,先說一點,難道Java NIO就從沒考慮過會有上述問題的產生過嗎?
當然不是了,Java NIO是考慮到的!他知道可能很多DirectByteBuffer對象也許沒人用了,但是因為沒有觸發gc就導致他們一直占據着堆外內存。
所以在 Java NIO的源碼中會做如下處理 ,他每次分配新的堆外內存的時候,都會調用System.gc()去提醒JVM去主動執行以下gc去回收掉一些垃圾沒人引用的DirectByteBuffer對象,釋放堆外內存空間。
只要能觸發垃圾回收去回收掉一些沒人引用的DirectByteBuffer,就會釋放一些堆外內存,自然就可以分配更多的對象到堆外內存去了。
但是我們又在JVM中設置了如下參數:
-XX:+DisableExplicitGC
導致這個System.gc()是不生效的,因此就會導致上述的情況。
最終對問題的優化
其實項目問題有兩個,一個是內存設置不合理,導致DirectByteBuffer對象一直慢慢進入老年代,導致堆外內存一直釋放不掉
另外一個是設置了-XX:+DisableExplicitGC導致Java NIO沒法主動提醒去回收掉一些垃圾DIrectByteBuffer對象,同樣導致堆外內存無法釋放。
因此最終對這個項目做的事情就是:
一個是合理分配內存,給年輕代更多內存,讓Survivor區域有更大的空間
另外一個就是放開-XX:+DisableExplicitGC這個限制,讓System.gc()生效。
做完優化之后,DirectByteBuffer一般就不會不斷進入老年代了。只要他停留在年輕代,隨着young gc就會正常回收釋放堆外內存了。
另外一個,只要你放開-XX:+DisableExplicitGC的限制,Java NIO發現堆外內存不足了,自然會通過System.gc()提醒JVM去主動垃圾回收,可以回收掉一些DirectByteBuffer釋放一些堆外內存。