Java內(nèi)存溢出(OOM)排查優(yōu)化指南
前言
OutOfMemoryError
,也就是臭名昭著的 OOM(內(nèi)存溢出),相信很多球友都遇到過(guò),相對(duì)于常見(jiàn)的業(yè)務(wù)異常,如數(shù)組越界、空指針等,OOM 問(wèn)題
更難難定位和解決。
這篇內(nèi)容就以之前碰到的一次線上內(nèi)存溢出的定位、解決問(wèn)題的方式展開(kāi);希望能對(duì)碰到類似問(wèn)題的球友帶來(lái)思路和幫助。
主要從表現(xiàn)-->排查-->定位-->解決
四個(gè)步驟來(lái)分析和解決問(wèn)題。
內(nèi)存溢出和內(nèi)存泄露
在 Java 中,和內(nèi)存相關(guān)的問(wèn)題主要有兩種,內(nèi)存溢出和內(nèi)存泄漏。
- 內(nèi)存溢出(
Out Of Memory
):就是申請(qǐng)內(nèi)存時(shí),JVM 沒(méi)有足夠的內(nèi)存空間。通俗說(shuō)法就是去蹲坑發(fā)現(xiàn)坑位滿了。 - 內(nèi)存泄露(
Memory Leak
):就是申請(qǐng)了內(nèi)存,但是沒(méi)有釋放,導(dǎo)致內(nèi)存空間浪費(fèi)。通俗說(shuō)法就是有人占著茅坑不拉屎。
內(nèi)存溢出
在 JVM 的內(nèi)存區(qū)域中,除了程序計(jì)數(shù)器,其他的內(nèi)存區(qū)域都有可能發(fā)生內(nèi)存溢出。
大家都知道,Java 堆中存儲(chǔ)的都是對(duì)象,或者叫對(duì)象實(shí)例,那只要我們不斷地創(chuàng)建對(duì)象,并且保證 GC Roots 到對(duì)象之間有可達(dá)路徑來(lái)避免垃圾回收機(jī)制清除這些對(duì)象,那么就一定會(huì)產(chǎn)生內(nèi)存溢出。
比如說(shuō)運(yùn)行下面這段代碼:
public class OOM { public static void main(String[] args) { List<Object> list = new ArrayList<>(); while (true) { list.add(new Object()); } } }
運(yùn)行程序的時(shí)候記得設(shè)置一下 VM 參數(shù):-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
,限制堆內(nèi)存大小為 20M,并且不允許擴(kuò)展,并且當(dāng)發(fā)生 OOM 時(shí) dump 出當(dāng)前內(nèi)存的快照。
運(yùn)行結(jié)果如下:
內(nèi)存泄露
內(nèi)存泄露是指程序中己動(dòng)態(tài)分配的堆內(nèi)存由于某種原因程序未釋放或無(wú)法釋放,造成系統(tǒng)內(nèi)存的浪費(fèi),導(dǎo)致程序運(yùn)行速度減慢甚至系統(tǒng)崩潰等嚴(yán)重后果。
簡(jiǎn)單來(lái)說(shuō),就是應(yīng)該被垃圾回收的對(duì)象沒(méi)有回收掉,導(dǎo)致占用的內(nèi)存越來(lái)越多,最終導(dǎo)致內(nèi)存溢出。
在上圖中:對(duì)象 X 引用對(duì)象 Y,X 的生命周期比 Y 的生命周期長(zhǎng),Y 生命周期結(jié)束的時(shí)候,垃圾回收器不會(huì)回收對(duì)象 Y。
來(lái)看下面的例子:
public class MemoryLeak { public static void main(String[] args) { try{ Connection conn =null; Class.forName("com.mysql.jdbc.Driver"); conn =DriverManager.getConnection("url","",""); Statement stmt =conn.createStatement(); ResultSet rs =stmt.executeQuery("...."); } catch(Exception e){//異常日志 } finally { // 1.關(guān)閉結(jié)果集 Statement // 2.關(guān)閉聲明的對(duì)象 ResultSet // 3.關(guān)閉連接 Connection } } }
創(chuàng)建的連接不再使用時(shí),需要調(diào)用 close 方法關(guān)閉連接,只有連接被關(guān)閉后,GC 才會(huì)回收對(duì)應(yīng)的對(duì)象(Connection,Statement,ResultSet,Session)。忘記關(guān)閉這些資源會(huì)導(dǎo)致持續(xù)占有內(nèi)存,無(wú)法被 GC 回收。
這樣就會(huì)導(dǎo)致內(nèi)存泄露,最終導(dǎo)致內(nèi)存溢出。
換句話說(shuō),內(nèi)存泄露不是內(nèi)存溢出,但會(huì)加快內(nèi)存溢出的發(fā)生。
內(nèi)存溢出后的表象
之前生產(chǎn)環(huán)境爆出的內(nèi)存溢出問(wèn)題會(huì)隨著業(yè)務(wù)量的增長(zhǎng),出現(xiàn)的頻次也越來(lái)越高。
應(yīng)用程序的業(yè)務(wù)邏輯非常簡(jiǎn)單,就是從 Kafka 中將數(shù)據(jù)消費(fèi)下來(lái),然后批量的做持久化操作。
OOM 現(xiàn)象則是隨著 Kafka 的消息越多,出現(xiàn)異常的頻次就越快。由于當(dāng)時(shí)還有其他工作所以只能讓運(yùn)維做重啟,并且監(jiān)控好堆內(nèi)存以及 GC 情況。
不得不說(shuō),重啟大法真的好,能解決大量的問(wèn)題,但不是長(zhǎng)久之計(jì)。
內(nèi)存泄露的排查
于是我們想根據(jù)運(yùn)維之前收集到的內(nèi)存數(shù)據(jù)、GC 日志嘗試判斷哪里出現(xiàn)了問(wèn)題。
結(jié)果發(fā)現(xiàn)老年代的內(nèi)存使用就算是發(fā)生 GC 也一直居高不下,而且隨著時(shí)間推移也越來(lái)越高。
結(jié)合 jstat 的日志發(fā)現(xiàn)就算是發(fā)生了 FGC,老年代也回收不了,內(nèi)存已經(jīng)到頂。
甚至有幾臺(tái)應(yīng)用 FGC 達(dá)到了上百次,時(shí)間也高的可怕。
這說(shuō)明應(yīng)用的內(nèi)存使用肯定是有問(wèn)題的,有許多賴皮對(duì)象始終回收不掉。
內(nèi)存泄露的定位
由于生產(chǎn)上的內(nèi)存 dump 文件非常大,達(dá)到了幾十 G。也和我們生產(chǎn)環(huán)境配置的內(nèi)存太大有關(guān)。
所以導(dǎo)致想使用 MAT 分析需要花費(fèi)大量時(shí)間。
MAT 是 Eclipse 的一個(gè)插件,也可以單獨(dú)使用,可以用來(lái)分析 Java 的堆內(nèi)存,找出內(nèi)存泄露的原因。
因此我們就想是否可以在本地復(fù)現(xiàn),這樣就好定位的多。
為了盡快的復(fù)現(xiàn)問(wèn)題,我將本地應(yīng)用最大堆內(nèi)存設(shè)置為 150M。然后在消費(fèi) Kafka 那里 Mock 了一個(gè) while 循環(huán)一直不斷的生成數(shù)據(jù)。
同時(shí)當(dāng)應(yīng)用啟動(dòng)之后利用 VisualVM 連上應(yīng)用實(shí)時(shí)監(jiān)控內(nèi)存、GC 的使用情況。
結(jié)果跑了 10 幾分鐘內(nèi)存使用并沒(méi)有什么問(wèn)題。根據(jù)圖中可以看出,每一次 GC 內(nèi)存都能有效的回收,所以并沒(méi)有復(fù)現(xiàn)問(wèn)題。
沒(méi)法復(fù)現(xiàn)問(wèn)題就很難定位。于是我們就采用了一種古老的方法——review 代碼,發(fā)現(xiàn)生產(chǎn)的邏輯和我們用 while 循環(huán) Mock 的數(shù)據(jù)還不太一樣。
果然 review 代碼是保障程序性能的第一道防線,誠(chéng)不欺我。大家在寫(xiě)完代碼的時(shí)候,盡量也要團(tuán)隊(duì) review 一次。
后來(lái)查看生產(chǎn)日志發(fā)現(xiàn)每次從 Kafka 中取出的都是幾百條數(shù)據(jù),而我們 Mock 時(shí)每次只能產(chǎn)生一條。
為了盡可能的模擬生產(chǎn)情況便在服務(wù)器上跑了一個(gè)生產(chǎn)者程序,一直源源不斷的向 Kafka 中發(fā)送數(shù)據(jù)。
果然不出意外只跑了一分多鐘內(nèi)存就頂不住了,觀察下圖發(fā)現(xiàn) GC 的頻次非常高,但是內(nèi)存的回收卻是相形見(jiàn)拙。
同時(shí)后臺(tái)也開(kāi)始打印內(nèi)存溢出了,這樣便復(fù)現(xiàn)出了問(wèn)題。
內(nèi)存泄露的解決
從目前的表現(xiàn)來(lái)看,就是內(nèi)存中有許多對(duì)象一直存在強(qiáng)引用關(guān)系導(dǎo)致得不到回收。
于是便想看看到底是什么對(duì)象占用了這么多的內(nèi)存,利用 VisualVM 的 HeapDump 功能,就可以立即 dump 出當(dāng)前應(yīng)用的內(nèi)存情況。
結(jié)果發(fā)現(xiàn) com.lmax.disruptor.RingBuffer
類型的對(duì)象占用了將近 50% 的內(nèi)存。
看到這個(gè)包自然就想到了 Disruptor
環(huán)形隊(duì)列了。
Disruptor 是一個(gè)高性能的異步處理框架,它的核心思想是:通過(guò)無(wú)鎖的方式來(lái)實(shí)現(xiàn)高性能的并發(fā)處理,其性能是高于 JDK 的 BlockingQueue 的。
再次 review 代碼發(fā)現(xiàn):從 Kafka 里取出的 700 條數(shù)據(jù)是直接往 Disruptor 里丟的。
這里也就能說(shuō)明為什么第一次模擬數(shù)據(jù)沒(méi)復(fù)現(xiàn)問(wèn)題了。
模擬的時(shí)候是一個(gè)對(duì)象放進(jìn)隊(duì)列里,而生產(chǎn)的情況是 700 條數(shù)據(jù)放進(jìn)隊(duì)列里。這個(gè)數(shù)據(jù)量就是 700 倍的差距啊。
而 Disruptor 作為一個(gè)環(huán)形隊(duì)列,在對(duì)象沒(méi)有被覆蓋之前是一直存在的。
我也做了一個(gè)實(shí)驗(yàn),證明確實(shí)如此。
我設(shè)置隊(duì)列大小為 8 ,從 0~9 往里面寫(xiě) 10 條數(shù)據(jù),當(dāng)寫(xiě)到 8 的時(shí)候就會(huì)把之前 0 的位置覆蓋掉,后面的以此類推(類似于 HashMap 的取模定位)。
所以在生產(chǎn)環(huán)境上,假設(shè)我們的隊(duì)列大小是 1024,那么隨著系統(tǒng)的運(yùn)行最終會(huì)導(dǎo)致 1024 個(gè)位置上裝滿了對(duì)象,而且每個(gè)位置都是 700 個(gè)!
于是查看了生產(chǎn)環(huán)境上 Disruptor 的 RingBuffer 配置,結(jié)果是:1024*1024
。
這個(gè)數(shù)量級(jí)就非常嚇人了。
為了驗(yàn)證是否是這個(gè)問(wèn)題,我在本地將該值設(shè)為 2 ,一個(gè)最小值試試。
同樣的 128M 內(nèi)存,也是通過(guò) Kafka 一直源源不斷的取出數(shù)據(jù)。通過(guò)監(jiān)控如下:
跑了 20 幾分鐘系統(tǒng)一切正常,每當(dāng)一次 GC 都能回收大部分內(nèi)存,最終呈現(xiàn)鋸齒狀。
這樣問(wèn)題就找到了,不過(guò)生產(chǎn)上這個(gè)值具體設(shè)置多少還得根據(jù)業(yè)務(wù)情況測(cè)試才能知道,但原有的 1024*1024 是絕對(duì)不能再使用了。
小結(jié)
雖然到了最后也就改了一行代碼(還沒(méi)改,直接修改配置),但這個(gè)排查過(guò)程我覺(jué)得是很有意義的。
也會(huì)讓大部分覺(jué)得 JVM 這樣的黑盒難以下手的球友有一個(gè)直觀感受。
同時(shí)也得感嘆 Disruptor 東西雖好,也不能亂用哦!
以上就是Java內(nèi)存溢出(OOM)排查優(yōu)化指南的詳細(xì)內(nèi)容,更多關(guān)于Java內(nèi)存溢出OOM的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
springboot數(shù)據(jù)庫(kù)操作圖文教程
本文以圖文并茂的形式給大家介紹了springboot數(shù)據(jù)庫(kù)操作,感興趣的朋友一起看看吧2017-07-07Spring Boot集成MyBatis訪問(wèn)數(shù)據(jù)庫(kù)的方法
這篇文章主要介紹了Spring Boot集成MyBatis訪問(wèn)數(shù)據(jù)庫(kù)的方法,需要的朋友可以參考下2017-04-04如何使用Code128字體將文本轉(zhuǎn)換為code128條形碼
這篇文章主要介紹了如何使用Code128字體將文本轉(zhuǎn)換為code128條形碼 ,非常不錯(cuò),具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2019-04-04@Valid 無(wú)法校驗(yàn)List<E>的問(wèn)題
這篇文章主要介紹了@Valid 無(wú)法校驗(yàn)List<E>的問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-10-10App登陸java后臺(tái)處理和用戶權(quán)限驗(yàn)證
這篇文章主要為大家詳細(xì)介紹了App登陸java后臺(tái)處理和用戶權(quán)限驗(yàn)證,感興趣的朋友可以參考一下2016-06-06關(guān)于在Springboot中集成unihttp后應(yīng)用無(wú)法啟動(dòng)的解決辦法
本文主要介紹了在SpringBoot項(xiàng)目中集成UniHttp框架時(shí)遇到的無(wú)法啟動(dòng)問(wèn)題,并提供了解決方法,作者通過(guò)詳細(xì)記錄和分析問(wèn)題,希望為其他開(kāi)發(fā)者提供有價(jià)值的參考和借鑒,感興趣的朋友跟隨小編一起看看吧2025-03-03解決IDEA service層跳轉(zhuǎn)實(shí)現(xiàn)類的快捷圖標(biāo)消失問(wèn)題
這篇文章主要介紹了解決IDEA service層跳轉(zhuǎn)實(shí)現(xiàn)類的快捷圖標(biāo)消失問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2021-02-02java針對(duì)于時(shí)間轉(zhuǎn)換的DateUtils工具類
這篇文章主要為大家詳細(xì)介紹了java針對(duì)于時(shí)間轉(zhuǎn)換的DateUtils工具類,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-12-12maven為MANIFEST.MF文件添加內(nèi)容的方法
這篇文章主要介紹了maven為MANIFEST.MF文件添加內(nèi)容的方法,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-12-12