面試必殺題:當(dāng)發(fā)生OOM時(shí),進(jìn)程還能處理請(qǐng)求嗎
Java 的優(yōu)勢(shì)有什么
面試官一上來,直接進(jìn)入主題:你覺得在內(nèi)存管理上,Java 有什么優(yōu)勢(shì)?
我:小菜一碟。相比于 C 語言的手動(dòng)釋放內(nèi)存,Java 的優(yōu)勢(shì)在于內(nèi)存的自動(dòng)管理,依賴于垃圾回收機(jī)制,它能自動(dòng)識(shí)別和清理不再使用的內(nèi)存資源,消除了手動(dòng)釋放內(nèi)存的繁瑣過程,大大簡(jiǎn)化了開發(fā)人員的工作量。
什么是 OOM
面試官:那你知道什么是 OOM 嗎?
我:這個(gè)我在線上也碰到過好多次了,Java 的 OOM 通常指的是內(nèi)存溢出(Out of Memory)異常。在 Java 應(yīng)用程序中,每個(gè)對(duì)象都需要在內(nèi)存中分配一定的空間。當(dāng)應(yīng)用程序需要分配更多內(nèi)存空間來創(chuàng)建對(duì)象時(shí),但可分配內(nèi)存卻不足以滿足需求時(shí),就會(huì)拋出 OOM 異常。
什么情況會(huì)產(chǎn)生 OOM
面試官:好小子,線上的事故代碼不會(huì)都是你寫的吧,那你說說有什么情況會(huì)導(dǎo)致 OOM?
我:比如說經(jīng)常發(fā)生的堆內(nèi)存溢出, 在創(chuàng)建對(duì)象時(shí),絕大多數(shù)情況占用的都是 JVM 的堆內(nèi)存,當(dāng)堆內(nèi)存不足以分配時(shí),則會(huì)拋出OOM異常。
java.lang.OutOfMemoryError: Java heap space
堆內(nèi)存溢出的具體場(chǎng)景
面試官:你這個(gè)太抽象了,能不能具體點(diǎn)?
我:emm,常見導(dǎo)致內(nèi)存溢出的情況有這么幾種:
對(duì)象生命周期過長(zhǎng):如果某個(gè)對(duì)象的生命周期過長(zhǎng),而且該對(duì)象占用的內(nèi)存很大,那么在不斷創(chuàng)建新對(duì)象的過程中,堆內(nèi)存會(huì)被耗盡,從而導(dǎo)致內(nèi)存溢出。這種情況一般出現(xiàn)在用集合當(dāng)緩存,卻忽略了緩存的淘汰機(jī)制。
無限遞歸:遞歸調(diào)用中缺少退出條件或遞歸深度過大,會(huì)導(dǎo)致空間耗盡,引發(fā)溢出錯(cuò)誤。往往在測(cè)試環(huán)境就會(huì)發(fā)現(xiàn)該問題,不會(huì)暴露在生產(chǎn)環(huán)境
大數(shù)據(jù)集合:在處理大量數(shù)據(jù)時(shí),如果沒有正確管理內(nèi)存,例如加載過大的文件、查詢結(jié)果集過大等,會(huì)導(dǎo)致內(nèi)存溢出。
JVM配置不當(dāng):如果JVM的內(nèi)存參數(shù)配置不合理,例如堆內(nèi)存設(shè)置過小,無法滿足應(yīng)用程序的內(nèi)存需求,也會(huì)導(dǎo)致內(nèi)存溢出。
下面的這個(gè)例子就是無限循環(huán)導(dǎo)致內(nèi)存溢出。
csharp復(fù)制代碼List<Integer> list = new ArrayList<>(); while (true) { ?? ?list.add(1); }
什么是內(nèi)存泄漏
面試官:你知道在我們的程序里,有可能會(huì)出現(xiàn)內(nèi)存泄漏,你對(duì)它了解嗎?
我:對(duì)的,和內(nèi)存溢出的情況不同,還有一種特殊場(chǎng)景,叫做內(nèi)存泄漏(本質(zhì)上還是內(nèi)存溢出,只不過是錯(cuò)誤的內(nèi)存溢出),指的是程序在運(yùn)行過程中無法釋放不再使用的內(nèi)存,導(dǎo)致內(nèi)存占用不斷增加,最終耗盡系統(tǒng)資源,這種情況就被稱為內(nèi)存泄漏。
這一次,我提前搶答了, 常見導(dǎo)致內(nèi)存泄漏的情況包括:
對(duì)象的引用未被正確釋放:如果在使用完一個(gè)對(duì)象后,忘記將其引用置為 null 或者從數(shù)據(jù)結(jié)構(gòu)中移除,那么該對(duì)象將無法被垃圾回收,導(dǎo)致內(nèi)存泄漏。比如 ThreadLocal。
長(zhǎng)生命周期的對(duì)象持有短生命周期對(duì)象的引用:如果一個(gè)長(zhǎng)生命周期的對(duì)象持有了一個(gè)短生命周期對(duì)象的引用,即使短生命周期對(duì)象不再使用,由于長(zhǎng)生命周期對(duì)象的引用仍然存在,短生命周期對(duì)象也無法被垃圾回收,從而造成內(nèi)存泄漏。
過度使用第三方庫:某些第三方庫可能存在內(nèi)存泄漏或者資源未正確釋放的問題,如果使用不當(dāng)或者沒有適當(dāng)?shù)毓芾磉@些庫,可能會(huì)導(dǎo)致內(nèi)存溢出。
集合類使用不當(dāng):在使用集合類時(shí),如果沒有正確地清理元素,當(dāng)集合不再需要時(shí),集合中的對(duì)象也不會(huì)被釋放,導(dǎo)致內(nèi)存泄漏。
資源未正確釋放:如果程序使用了諸如文件、數(shù)據(jù)庫連接、網(wǎng)絡(luò)連接等資源,在不再需要這些資源時(shí)沒有正確釋放,會(huì)導(dǎo)致資源泄漏,最終導(dǎo)致內(nèi)存泄漏。
下面的這個(gè)例子就是長(zhǎng)生命周期的對(duì)象持有短生命周期對(duì)象的引用, 導(dǎo)致內(nèi)存泄漏。
csharp復(fù)制代碼List<Integer> list2 = new ArrayList<>(); ?@GetMapping("/headOOM2") public String headOOM2() throws InterruptedException { ?? ?while (true) { ?? ? ? ?list2.add(1); ?? ?} }
還有其他情況嗎
面試官:你說的都是堆的內(nèi)存溢出,還有其他情況嗎?
遞歸調(diào)用導(dǎo)致棧溢出
當(dāng)遞歸調(diào)用的層級(jí)過深,??臻g無法容納更多的方法調(diào)用信息時(shí),會(huì)引發(fā) StackOverflowError 異常,這也是一種 OOM 異常。例如,以下示例中的無限遞歸調(diào)用會(huì)導(dǎo)致棧溢出。
typescript復(fù)制代碼public class OOMExample { ?? ?public static void recursiveMethod() { ?? ? ? ?recursiveMethod(); ?? ?} ? ?? ?public static void main(String[] args) { ?? ? ? ?recursiveMethod(); ?? ?} }
元空間(Metaspace)耗盡
元空間是 Java 8 及以后版本中用來存儲(chǔ)類元數(shù)據(jù)的區(qū)域。它取代了早期版本中的永久代(PermGen)。元空間主要用于存儲(chǔ)類的結(jié)構(gòu)信息、方法信息、靜態(tài)變量以及編譯后的代碼等。
當(dāng)程序加載和定義大量類、動(dòng)態(tài)生成類、使用反射頻繁操作類等情況下,可能會(huì)導(dǎo)致元空間耗盡。常見導(dǎo)致元空間耗盡的情況包括:
類加載過多:如果應(yīng)用程序動(dòng)態(tài)加載大量的類或者使用動(dòng)態(tài)生成類的方式,會(huì)導(dǎo)致元空間的使用量增加。如果無法及時(shí)卸載這些類,元空間可能會(huì)耗盡。
字符串常量過多:Java中的字符串常量會(huì)被存儲(chǔ)在元空間中。如果應(yīng)用程序中使用了大量的字符串常量,尤其是較長(zhǎng)的字符串,可能會(huì)導(dǎo)致元空間的耗盡。
頻繁使用反射:反射操作需要大量的元數(shù)據(jù)信息,會(huì)占用較多的元空間。如果應(yīng)用程序頻繁使用反射進(jìn)行類的操作,可能會(huì)導(dǎo)致元空間耗盡。
大量動(dòng)態(tài)代理:動(dòng)態(tài)代理是一種使用反射創(chuàng)建代理對(duì)象的技術(shù)。如果應(yīng)用程序大量使用動(dòng)態(tài)代理,將會(huì)生成大量的代理類,占用較多的元空間。
未正確限制元空間大小:默認(rèn)情況下,元空間的大小是不受限制的,它會(huì)根據(jù)需要?jiǎng)討B(tài)擴(kuò)展。如果沒有正確設(shè)置元空間的大小限制,或者限制過小,可能會(huì)導(dǎo)致元空間耗盡。
下面的這個(gè)例子就是類加載過多導(dǎo)致的內(nèi)存泄漏。
typescript復(fù)制代碼public class OOMExample { ?? ?public static void main(String[] args) { ?? ? ? ?while (true) { ?? ? ? ? ? ?ClassLoader classLoader = new CustomClassLoader(); ?? ? ? ? ? ?classLoader.loadClass("com.example.LargeClass"); ?? ? ? ?} ?? ?} }
終極問題
面試官滿意的點(diǎn)了點(diǎn)頭,小伙子你知道的還挺多,那我再問你一個(gè)問題哈:”當(dāng) Java 線程在處理請(qǐng)求時(shí),拋出了 OOM 異常,整個(gè)進(jìn)程還能處理請(qǐng)求嗎? ”
當(dāng)我正準(zhǔn)備脫口而出的時(shí)候,面試官:“這個(gè)問題考察的內(nèi)容還是挺多的,不是簡(jiǎn)單的是與否的問題。我建議你先整理一下思路?!?/p>
看到面試官的眼神,我就知道這道題有貓膩。思考了一會(huì),我給出了答案?!拔疫€是認(rèn)為OOM 并不會(huì)導(dǎo)致整個(gè)進(jìn)程掛掉”
面試官:你是怎么理解的,OOM 是不是意味著內(nèi)存不夠了。既然內(nèi)存不夠了,進(jìn)程還能處理請(qǐng)求嗎?
我:內(nèi)存不夠了還可以通過垃圾回收釋放內(nèi)存。
面試官:難道 OOM 不就是因?yàn)?GC 后,發(fā)現(xiàn)內(nèi)存不足才會(huì)拋出的異常,這時(shí)候是不是可以理解為 GC 不了了。所以是:內(nèi)存不夠->GC后還不夠-> OOM 這個(gè)流程。
我:此處經(jīng)典國(guó)罵,當(dāng)然我只能在內(nèi)心想想。
這么一套組合拳下來,我徹底懵了。結(jié)果不出意外的掛了,面試官最后送我下樓的時(shí)候,仿佛在和我說:”我也不想這樣,只能怪 HC 太少“
實(shí)戰(zhàn)
回到家,我馬上去進(jìn)行了代碼實(shí)戰(zhàn),用來測(cè)試 OOM。
環(huán)境是:OpenJdk 11 -Xms100m -Xmx100m -XX:+PrintGCDetails
堆內(nèi)存溢出
首先我們創(chuàng)建一個(gè)方法,調(diào)用它,每隔一秒不停的循環(huán)打印控制臺(tái)信息,它的主要作用是模擬其他線程處理請(qǐng)求。
csharp復(fù)制代碼@GetMapping("/writeInfo") public String writeInfo() throws InterruptedException { ?? ?while (true) { ?? ? ? ?Thread.sleep(1000); ?? ? ? ?System.out.println("正在輸出信息"); ?? ?} }
接著再創(chuàng)建一個(gè)死循環(huán)往 List 中放入對(duì)象的方法,它的主要作用是模擬導(dǎo)致OOM的那個(gè)線程。
csharp復(fù)制代碼@GetMapping("/headOOM") public String headOOM() throws InterruptedException { ?? ?List<Integer> list = new ArrayList<>(); ?? ?while (true) { ?? ? ? ?list.add(1); ?? ?} }
最終結(jié)果是
headOOM
拋出了 OOM 異常,但是控制臺(tái)還在不停的打印。【這邊截圖太大了,就不貼出來了】
這就是答案嗎?其實(shí)不是,在第一步中,僅僅是在控制臺(tái)打印出了日志,并沒有創(chuàng)建明確的對(duì)象。將它稍微改動(dòng)下,加一行,每次打印前先創(chuàng)建 10M 的對(duì)象。
csharp復(fù)制代碼public String writeInfo() throws InterruptedException { ?? ?while (true) { ?? ? ? ?Thread.sleep(1000); ?? ? ? ?Byte[] bytes = new Byte[1024 * 1024 * 10]; ?? ? ? ?System.out.println("正在輸出信息"); ?? ?} }
結(jié)果依舊會(huì)繼續(xù)打印。看到這里有些人可能會(huì)說,答案確實(shí)是"還能繼續(xù)執(zhí)行",我只能說你是 Too Young Too Simple 。往下看
堆內(nèi)存泄漏
老規(guī)矩,還是上面的方法
csharp復(fù)制代碼public String writeInfo() throws InterruptedException { ?? ?while (true) { ?? ? ? ?Thread.sleep(1000); ?? ? ? ?Byte[] bytes = new Byte[1024 * 1024 * 10]; ?? ? ? ?System.out.println("正在輸出信息"); ?? ?} }
創(chuàng)建一個(gè)內(nèi)存泄漏的方法,list2 作用域是在類對(duì)象級(jí)別,從而產(chǎn)生內(nèi)存泄漏
csharp復(fù)制代碼List<Integer> list2 = new ArrayList<>(); @GetMapping("/headOOM2") public String headOOM2() throws InterruptedException { ?? ?while (true) { ?? ? ? ?list2.add(1); ?? ?} }
然后繼續(xù)執(zhí)行,結(jié)果首先是
headOOM2
這個(gè)方法對(duì)應(yīng)的線程拋出 OOM。
接著是
WriteInfo
這個(gè)方法對(duì)應(yīng)的線程拋出OOM,所以我猜測(cè)現(xiàn)在整個(gè)進(jìn)程基本都不能處理請(qǐng)求了。
為了印證這個(gè)猜測(cè),再去調(diào)用下
writeInfo
這個(gè)方法,直接拋出 OOM 異常。說明我們的猜測(cè)是對(duì)的。
這時(shí)候你如果把那個(gè) 10M 改成1M,
writeInfo
這個(gè)方法就又能執(zhí)行下去了,不信的話就去試試看吧。
這說明內(nèi)存泄漏的情況,其他線程能否繼續(xù)執(zhí)行下去,取決于這些線程的執(zhí)行邏輯是否會(huì)占用大量?jī)?nèi)存。
不發(fā)生內(nèi)存泄漏的情況下,為什么頻繁創(chuàng)建對(duì)象會(huì)導(dǎo)致OOM,GC 不是會(huì)把對(duì)象給回收嗎
最后再回答下這個(gè)問題:
堆內(nèi)存限制:Java程序的堆內(nèi)存有一定的大小限制,如果頻繁創(chuàng)建對(duì)象并且無法及時(shí)回收,堆空間可能會(huì)被耗盡。雖然垃圾回收器會(huì)盡力回收不再使用的對(duì)象,但如果對(duì)象創(chuàng)建的速度超過垃圾回收器的回收速度,就會(huì)導(dǎo)致堆內(nèi)存不足而發(fā)生 OOM。
垃圾回收的開銷:盡管垃圾回收器會(huì)回收不再使用的對(duì)象,但垃圾回收本身也是需要消耗時(shí)間和計(jì)算資源的。如果頻繁創(chuàng)建大量的臨時(shí)對(duì)象,垃圾回收器需要花費(fèi)更多的時(shí)間來回收這些對(duì)象,導(dǎo)致應(yīng)用程序的執(zhí)行效率下降。
內(nèi)存碎片化:頻繁創(chuàng)建和銷毀對(duì)象會(huì)導(dǎo)致內(nèi)存空間的碎片化。當(dāng)內(nèi)存中存在大量碎片化的空閑內(nèi)存塊時(shí),即使總的空閑內(nèi)存足夠,但可能無法找到連續(xù)的大塊內(nèi)存來分配給新對(duì)象。這種情況下,即使垃圾回收器回收了部分對(duì)象,仍然無法分配足夠的內(nèi)存給新創(chuàng)建的對(duì)象,從而導(dǎo)致OOM。 所以你可以從GC日志上發(fā)現(xiàn),發(fā)生OOM時(shí),你的堆大小沒有到達(dá)你的閾值。
不知道到這,你看懂了沒有。
總結(jié)
首先,我們鋪墊了什么是 OOM,以及 OOM 發(fā)生的場(chǎng)景,包括內(nèi)存溢出、內(nèi)存泄漏,從而得出了這個(gè)問題:當(dāng) Java 線程在處理請(qǐng)求時(shí),拋出了 OOM 異常,整個(gè)進(jìn)程還能處理請(qǐng)求嗎?
接著通過代碼實(shí)戰(zhàn),模擬了內(nèi)存溢出和內(nèi)存泄漏兩個(gè)場(chǎng)景,暫時(shí)性的得出了結(jié)論:
內(nèi)存溢出的情況,當(dāng) GC 的速度跟不上內(nèi)存的分配時(shí),會(huì)發(fā)生 OOM, 從而將那個(gè)線程 Kill 掉,在這種情況下,進(jìn)程一般還能繼續(xù)處理請(qǐng)求。
內(nèi)存泄漏的情況,由于這些內(nèi)存不能被回收掉,會(huì)發(fā)生OOM,從而將那個(gè)線程 Kill 掉,防止繼續(xù)創(chuàng)建不能被回收的對(duì)象,此時(shí)有些不占用內(nèi)存的線程可能將繼續(xù)執(zhí)行,而那些會(huì)占用大量?jī)?nèi)存的線程可能將無法執(zhí)行,最壞的情況可能是進(jìn)程直接掛掉。