百度二面:一個線程OOM了,其它線程還能運(yùn)行嗎?
由于面試官僅提到OOM,但 Java 的OOM又分很多類型:
- 堆溢出(“java.lang.OutOfMemoryError: Java heap space”)
- 永久代溢出(“java.lang.OutOfMemoryError:Permgen space”)
- 不能創(chuàng)建線程(“java.lang.OutOfMemoryError:Unable to create new native thread”)
- 通過代碼驗(yàn)證《Java虛擬機(jī)規(guī)范》中描述的各個運(yùn)行時區(qū)域儲存的內(nèi)容
- 在工作中遇到實(shí)際的內(nèi)存溢出異常時,能根據(jù)異常的提示信息迅速得知是哪個區(qū)域的內(nèi)存溢出,知道怎樣的代碼可能會導(dǎo)致這些區(qū)域內(nèi)存溢出,以及出現(xiàn)這些異常后該如何處理。
Java堆用于儲存對象實(shí)例,只要不斷地創(chuàng)建對象,并且保證GC Roots到對象之間有可達(dá)路徑來避免GC機(jī)制清除這些對象,則隨對象數(shù)量增加,總?cè)萘坑|及最大堆的容量限制后就會產(chǎn)生內(nèi)存溢出異常。限制Java堆的大小20MB,不可擴(kuò)展
-XX: HeapDumpOnOutOf-MemoryError
可以讓虛擬機(jī)在出現(xiàn)內(nèi)存溢出異常的時候Dump出當(dāng)前的內(nèi)存堆轉(zhuǎn)儲快照。案例1
Java堆內(nèi)存的OOM是實(shí)際應(yīng)用中最常見的內(nèi)存溢出異常場景。出現(xiàn)Java堆內(nèi)存溢出時,異常堆棧信息“java.lang.OutOfMemoryError”會跟隨進(jìn)一步提示“Java heap space”。那既然發(fā)生了,如何解決這個內(nèi)存區(qū)域的異常呢?
一般先通過內(nèi)存映像分析工具(如jprofile)對Dump出來的堆轉(zhuǎn)儲快照進(jìn)行分析。
第一步首先確認(rèn)內(nèi)存中導(dǎo)致OOM的對象是否是必要的,即先分清楚:
- 內(nèi)存泄漏(Memory Leak)
- 內(nèi)存溢出(Memory Overflow)
若是內(nèi)存泄漏,可查看泄漏對象到GC Roots的引用鏈,找到泄漏對象是通過怎樣的引用路徑、與哪些GC Roots相關(guān)聯(lián),才導(dǎo)致垃圾收集器無法回收它們。根據(jù)泄漏對象的類型信息以及它到GC Roots引用鏈的信息,一般可以比較準(zhǔn)確地定位到這些對象創(chuàng)建的位置,進(jìn)而找出產(chǎn)生內(nèi)存泄漏的代碼的具體位置。若不是內(nèi)存泄漏,即就是內(nèi)存中的對象確實(shí)都必須存活,則應(yīng):
- 檢查JVM堆參數(shù)(-Xmx與-Xms)的設(shè)置,與機(jī)器內(nèi)存對比,看是否還有向上調(diào)整的空間
- 再檢查代碼是否存在某些對象生命周期過長、持有狀態(tài)時間過長、存儲結(jié)構(gòu)設(shè)計不合理等情況,盡量減少程序運(yùn) 行期的內(nèi)存消耗
案例 2
JVM啟動參數(shù)設(shè)置:
-Xms5m -Xmx10m -XX: HeapDumpOnOutOfMemoryError
JVM堆空間的變化
堆的使用大小,突然抖動!說明當(dāng)一個線程拋OOM后,它所占據(jù)的內(nèi)存資源會全部被釋放掉,而不會影響其他線程的正常運(yùn)行!
所以一個線程溢出后,進(jìn)程里的其他線程還能照常運(yùn)行。
發(fā)生OOM的線程一般情況下會死亡,也就是會被終結(jié)掉,該線程持有的對象占用的heap都會被gc了,釋放內(nèi)存。因?yàn)榘l(fā)生OOM之前要進(jìn)行g(shù)c,就算其他線程能夠正常工作,也會因?yàn)轭l繁gc產(chǎn)生較大的影響。
2 虛擬機(jī)棧/本地方法棧溢出
由于HotSpot JVM并不區(qū)分虛擬機(jī)棧和本地方法棧,因此HotSpot的
-Xoss
參數(shù)(設(shè)置本地方法棧的大小)雖然存在,但無任何效果,棧容量只能由-Xss
參數(shù)設(shè)定。關(guān)于虛擬機(jī)棧和本地方法棧,《Java虛擬機(jī)規(guī)范》描述如下異常:- 若線程請求的棧深度大于虛擬機(jī)所允許的最大深度,將拋出StackOverflowError異常
- 若虛擬機(jī)的棧內(nèi)存允許動態(tài)擴(kuò)展,當(dāng)擴(kuò)展棧容量無法申請到足夠的內(nèi)存時,將拋出 OutOfMemoryError異常
如何驗(yàn)證呢?
做倆實(shí)驗(yàn),先在單線程操作,嘗試下面兩種行為是否能讓HotSpot OOM:使用-Xss
減少棧內(nèi)存容量
示例結(jié)果
拋StackOverflowError異常,異常出現(xiàn)時輸出的堆棧深度相應(yīng)縮小。
不同版本的Java虛擬機(jī)和不同的操作系統(tǒng),棧容量最小值可能會有所限制,這主要取決于操作系統(tǒng)內(nèi)存分頁大小。譬如上述方法中的參數(shù)-Xss160k可以正常用于62位macOS系統(tǒng)下的JDK 8,但若用于64位Windows系統(tǒng)下的JDK 11,則會提示棧容量最小不能低于180K,而在Linux下這個值則可能是228K,如果低于這個最小限制,HotSpot虛擬器啟動時會給出如下提示:
The stack size specified is too small, Specify at
定義大量局部變量,增大此方法幀中本地變量表的長度
示例:結(jié)果:
所以無論是由于棧幀太或虛擬機(jī)棧容量太小,當(dāng)新的棧幀內(nèi)存無法分配時, HotSpot 都拋SOF。可若在允許動態(tài)擴(kuò)展棧容量大小的虛擬機(jī)上,相同代碼則會導(dǎo)致不同情況。若測試時不限于單線程,而是不斷新建線程,在HotSpot上也會產(chǎn)生OOM。但這樣產(chǎn)生OOM和棧空間是否足夠不存在直接的關(guān)系,主要取決于os本身內(nèi)存使用狀態(tài)。甚至說這種情況下,給每個線程的棧分配的內(nèi)存越大,反而越容易產(chǎn)生OOM。
不難理解,os分配給每個進(jìn)程的內(nèi)存有限制,比如32位Windows的單個進(jìn)程最大內(nèi)存限制為2G。HotSpot提供參數(shù)可以控制Java堆和方法區(qū)這兩部分的內(nèi)存的最大值,那剩余的內(nèi)存即為2G(os限制)減去最大堆容量,再減去最大方法區(qū)容量,由于程序計數(shù)器消耗內(nèi)存很小,可忽略,若把直接內(nèi)存和虛擬機(jī)進(jìn)程本身耗費(fèi)的內(nèi)存也去掉,剩下的內(nèi)存就由虛擬機(jī)棧和本地方法棧來分配了。因此為每個線程分配到的棧內(nèi)存越大,可以建立的線程數(shù)量越少,建立線程時就越容易把剩下的內(nèi)存耗盡:示例:
結(jié)果:
Exception in thread "main" java.lang.OutOfMemoryError:
unable to create native thread
出現(xiàn)SOF時,會有明確錯誤堆??晒┓治?,相對容易定位問題。如果使用HotSpot虛擬機(jī)默認(rèn)參數(shù),棧深度在大多數(shù)情況下(因?yàn)槊總€方法壓入棧的幀大小并不是一樣的)到達(dá)1000~2000沒有問題,對于正常的方法調(diào)用(包括不能做尾遞歸優(yōu)化的遞歸調(diào)用),這個深度應(yīng)該完全夠用。但如果是建立過多線程導(dǎo)致的內(nèi)存溢出,在不能減少線程數(shù)量或者更換64位虛擬機(jī)的情況下,就只能通過減少最大堆和減少棧容量換取更多的線程。這種通過“減少內(nèi)存”手段解決內(nèi)存溢出的方式,如果沒有這方面處理經(jīng)驗(yàn),一般比較難以想到。也是由于這種問題較為隱蔽,從 JDK 7起,以上提示信息中“unable to create native thread”后面,虛擬機(jī)會特別注明原因可能是“possibly
#define OS_NATIVE_THREAD_CREATION_FAILED_MSG
"unable to create native thread:
possibly out of memory or process/resource limits reached"
3 方法區(qū)和運(yùn)行時常量池溢出運(yùn)行時常量池是方法區(qū)的一部分,所以這兩個區(qū)域的溢出測試可以放到一起。HotSpot從JDK 7開始逐步“去永久代”,在JDK 8中完全使用元空間代替永久代。那么方法區(qū)使用“永久代”還是“元空間”來實(shí)現(xiàn),對程序有何影響呢?
String::intern()
一個本地方法:若字符串常量池中已經(jīng)包含一個等于此String對象的字符串,則返回代表池中這個字符串的String對象的引用;否則,會將此String對象包含的字符串添加到常量池,并且返回此String對象的引用。在JDK6或之前HotSpot虛擬機(jī),常量池都是分配在永久代,可以通過如下兩個參數(shù):
限制永久代的大小,即可間接限制其中常量池的容量,實(shí)例結(jié)果:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java: 18)
可見,運(yùn)行時常量池溢出時,在OutOfMemoryError異常后面跟隨的提示信息是“PermGen space”,說明運(yùn)行時常量池的確是屬于方法區(qū)(即JDK 6的HotSpot虛擬機(jī)中的永久代)的 一部分。而使用JDK 7或更高版本的JDK來運(yùn)行這段程序并不會得到相同的結(jié)果,無論是在JDK 7中繼續(xù)使 用-XX:MaxPermSize參數(shù)或者在JDK 8及以上版本使用-XX:MaxMeta-spaceSize參數(shù)把方法區(qū)容量同樣限制在6MB,也都不會重現(xiàn)JDK 6中的溢出異常,循環(huán)將一直進(jìn)行下去,永不停歇。
這種變化是因?yàn)樽訨DK 7起,原本存放在永久代的字符串常量池被移至Java堆,所以在JDK 7及以上版 本,限制方法區(qū)的容量對該測試用例來說是毫無意義。這時候使用-Xmx參數(shù)限制最大堆到6MB就能看到以下兩種運(yùn)行結(jié)果之一,具體取決于哪里的對象分配時產(chǎn)生了溢出:
// OOM異常一:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.lang.Integer.toString(Integer.java:440)
at java.base/java.lang.String.valueOf(String.java:3058)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)
// OOM異常二:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at java.base/java.util.HashMap.resize(HashMap.java:699)
at java.base/java.util.HashMap.putVal(HashMap.java:658)
at java.base/java.util.HashMap.put(HashMap.java:607)
at java.base/java.util.HashSet.add(HashSet.java:220)
at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java from InputFile-Object:14)
字符串常量池的實(shí)現(xiàn)位置還有很多趣事:
JDK 6中運(yùn)行,結(jié)果是兩個false
JDK 7中運(yùn)行,一個true和一個false
因?yàn)镴DK6的intern()會把首次遇到的字符串實(shí)例復(fù)制到永久代的字符串常量池中,返回的也是永久代里這個字符串實(shí)例的引用,而由StringBuilder創(chuàng)建的字符串對象實(shí)例在 Java 堆,所以不可能是同一個引用,結(jié)果將返回false。JDK 7及以后的intern()無需再拷貝字符串的實(shí)例到永久代,字符串常量池已移到Java堆,只需在常量池里記錄一下首次出現(xiàn)的實(shí)例引用,因此intern()返回的引用和由StringBuilder創(chuàng)建的那個字符串實(shí)例是同一個。str2比較返回false,這是因?yàn)椤癹ava”這個字符串在執(zhí)行String-Builder.toString()之前就已經(jīng)出現(xiàn)過了,字符串常量池中已經(jīng)有它的引用,不符合intern()方法要求“首次遇到”的原則,而“計算機(jī)軟件”這個字符串則是首次 出現(xiàn)的,因此結(jié)果返回true!對于方法區(qū)的測試,基本的思路是運(yùn)行時產(chǎn)生大量類去填滿方法區(qū),直到溢出。雖然直接使用Java SE API也可動態(tài)產(chǎn)生類(如反射時的 GeneratedConstructorAccessor和動態(tài)代理),但操作麻煩。
借助了CGLib直接操作字節(jié)碼運(yùn)行時生成大量動態(tài)類。當(dāng)前的很多主流框架,如Spring、Hibernate對類進(jìn)行增強(qiáng)時,都會使用到 CGLib字節(jié)碼增強(qiáng),當(dāng)增強(qiáng)的類越多,就需要越大的方法區(qū)以保證動態(tài)生成的新類型可以載入內(nèi)存。
很多運(yùn)行于JVM的動態(tài)語言(例如Groovy)通常都會持續(xù)創(chuàng)建新類型來支撐語言的動態(tài)性,隨著這類動態(tài)語言的流行,與如下代碼相似的溢出場景也越來越容易遇到在JDK 7中的運(yùn)行結(jié)果:
Caused by: java.lang.OutOfMemoryError: PermGen space
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClassCond(ClassLoader.java:632)
at java.lang.ClassLoader.defineClass(ClassLoader.java:616)
JDK8及以后:可以使用
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
設(shè)置元空間初始大小以及最大可分配大小。
1.如果不指定元空間的大小,默認(rèn)情況下,元空間最大的大小是系統(tǒng)內(nèi)存的大小,元空間一直擴(kuò)大,虛擬機(jī)可能會消耗完所有的可用系統(tǒng)內(nèi)存。
2.如果元空間內(nèi)存不夠用,就會報OOM。
3.默認(rèn)情況下,對應(yīng)一個64位的服務(wù)端JVM來說,其默認(rèn)的-XX:MetaspaceSize值為21MB,這就是初始的高水位線,一旦元空間的大小觸及這個高水位線,就會觸發(fā)Full GC并會卸載沒有用的類,然后高水位線的值將會被重置。
4.從第3點(diǎn)可以知道,如果初始化的高水位線設(shè)置過低,會頻繁的觸發(fā)Full GC,高水位線會被多次調(diào)整。所以為了避免頻繁GC以及調(diào)整高水位線,建議將-XX:MetaspaceSize設(shè)置為較高的值,而-XX:MaxMetaspaceSize不進(jìn)行設(shè)置。JDK8 運(yùn)行結(jié)果:
一個類如果要被gc,要達(dá)成的條件比較苛刻。在經(jīng)常運(yùn)行時生成大量動態(tài)類的場景,就應(yīng)該特別關(guān)注這些類的回收狀況。
這類場景除了之前提到的程序使用了CGLib字節(jié)碼增強(qiáng)和動態(tài)語言外,常見的還有:JDK8后,永久代完全廢棄,而使用元空間作為其替代者。在默認(rèn)設(shè)置下,前面列舉的那些正常的動態(tài)創(chuàng)建新類型的測試用例已經(jīng)很難再迫使虛擬機(jī)產(chǎn)生方法區(qū)OOM。
為了讓使用者有預(yù)防實(shí)際應(yīng)用里出現(xiàn)類似于如上代碼那樣的破壞性操作,HotSpot還是提供了一些參數(shù)作為元空間的防御措施: 4 本機(jī)直接內(nèi)存溢出
直接內(nèi)存(Direct Memory)的容量大小可通過
-XX:MaxDirectMemorySize
指定,若不指定,則默認(rèn)與Java堆最大值(-Xmx
)一致。這里越過DirectByteBuffer類,直接通過反射獲取Unsafe實(shí)例進(jìn)行內(nèi)存分配。Unsafe類的getUnsafe()指定只有引導(dǎo)類加載器才會返回實(shí)例,體現(xiàn)了設(shè)計者希望只有虛擬機(jī)標(biāo)準(zhǔn)類庫里面的類才能使用Unsafe,JDK10時才將Unsafe的部分功能通過VarHandle開放給外部。
因?yàn)殡m然使用DirectByteBuffer分配內(nèi)存也會拋OOM,但它拋異常時并未真正向os申請分配內(nèi)存,而是通過計算得知內(nèi)存無法分配,就在代碼里手動拋了OOM,真正申請分配內(nèi)存的方法是Unsafe::allocateMemory()使用unsafe分配本機(jī)內(nèi)存:
結(jié)果:
由直接內(nèi)存導(dǎo)致的內(nèi)存溢出,一個明顯的特征是在Heap Dump文件中不會看見有什么明顯異常,若發(fā)現(xiàn)內(nèi)存溢出之后產(chǎn)生的Dump文件很小,而程序中又直接或間接使用了 DirectMemory(比如使用NIO),則該考慮直接內(nèi)存了。