案例實戰(三)Jetty 服務器的 NIO 機制是如何導致堆外內存溢出的


案例發生現場

有一天突然收到線上的一個報警:某台機器部署的一個服務突然之間就不可以訪問了。

此時第一反應當然是立馬登錄上機器去看一下日志,因為服務掛掉,很可能是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釋放一些堆外內存。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM