1. 程式人生 > >【JVM進階之路】四:直面記憶體溢位和記憶體洩漏

【JVM進階之路】四:直面記憶體溢位和記憶體洩漏

在Java中,和記憶體相關的問題主要有兩種,**記憶體溢位**和**記憶體洩漏**。 - **記憶體溢位(**Out Of Memory**)** :就是申請記憶體時,JVM沒有足夠的記憶體空間。通俗說法就是去蹲坑發現坑位滿了。 - **記憶體洩露 (Memory Leak)**:就是申請了記憶體,但是沒有釋放,導致記憶體空間浪費。通俗說法就是有人佔著茅坑不拉屎。 # 1、記憶體溢位 在JVM的幾個記憶體區域中,除了程式計數器外,其他幾個執行時區域都有發生記憶體溢位(OOM)異常的可能。 ![JDK 1.8記憶體區域](https://gitee.com/sanfene/picgo/raw/master/20210324200121.png) ## 1.1、Java堆溢位 Java堆用於儲存物件例項,我們只要不斷地建立物件,並且保證GC Roots到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼隨著物件數量的增加,總容量觸及最大堆的容量限制後就會產生記憶體溢位異常。 我們來看一個程式碼的例子: ````java /** * VM引數: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError */ public class HeapOOM { static class OOMObject { } public static void main(String[] args) { List list = new ArrayList(); while (true) { list.add(new OOMObject()); } } } ```` 接下來,我們來設定一下程式啟動時的JVM引數。限制記憶體大小為20M,不允許擴充套件,並通過引數-XX:+HeapDumpOnOutOf-MemoryError 讓虛擬機器Dump出記憶體堆轉儲快照。 在Idea中設定JVM啟動引數如下圖: ![Idea設定JVM引數](https://gitee.com/sanfene/picgo/raw/master/20210324201324.png) 執行一下: ![堆記憶體溢位異常](https://gitee.com/sanfene/picgo/raw/master/20210324201444.png) Java堆記憶體的OutOfMemoryError異常是實際應用中最常見的記憶體溢位異常情況。出現Java堆記憶體溢位時,異常堆疊資訊“java.lang.OutOfMemoryError”會跟隨進一步提示“Java heap space”。 Java堆檔案快照檔案dump到了java_pid18728.hprof檔案。 要解決這個記憶體區域的異常,常規的處理方法是首先通過記憶體映像分析工具(如JProfiler、Eclipse Memory Analyzer等)對Dump出來的堆轉儲快照進行分析。 看到記憶體佔用資訊如下: ![Jprofiler 開啟的堆轉儲快照檔案](https://gitee.com/sanfene/picgo/raw/master/20210324204836.png) 然後可以檢視程式碼問題如下: ![Jprofiler檢視程式碼問題](https://gitee.com/sanfene/picgo/raw/master/20210324205329.png) > 常見堆JVM相關引數: > > `-XX:PrintFlagsInitial`: 檢視所有引數的預設初始值`-XX:PrintFlagsFinal`:檢視所有的引數的最終值(可能會存在修改,不再是初始值) > `-Xms`: 初始堆空間記憶體(預設為實體記憶體的1/64) > `-Xmx`: 最大堆空間記憶體(預設為實體記憶體的1/4) > `-Xmn`: 設定新生代大小(初始值及最大值) > `-XX:NewRatio`: 配置新生代與老年代在堆結構的佔比 > `-XX:SurvivorRatio`:設定新生代中Eden和S0/S1空間的比例 > `-XX:MaxTenuringThreshold`:設定新生代垃圾的最大年齡(預設15) > `-XX:+PrintGCDetails`:輸出詳細的GC處理日誌 > 列印`GC`簡要資訊:① `-XX:+PrintGC` ② `-verbose:gc` > `-XX:HandlePromotionFailure`:是否設定空間分配擔保 ## 1.2、虛擬機器棧和本地方法棧溢位 HotSpot虛擬機器中將虛擬機器棧和本地方法棧合二為一,因此對於HotSpot來說,-Xoss引數(設定本地方法棧大小)雖然存在,但實際上是沒有任何效果的,棧容量只能由-Xss引數來設定。關於虛擬機器棧和本地方法棧,有兩種異常: - 如果執行緒請求的棧深度大於虛擬機器所允許的最大深度,將丟擲`StackOverflowError`異常。 - 如果虛擬機器的棧記憶體允許動態擴充套件,當擴充套件棧容量無法申請到足夠的記憶體時,將丟擲 `OutOfMemoryError`異常。 ### 1.2.1、StackOverflowError HotSpot虛擬機器不支援棧的動態擴充套件,在HotSpot虛擬機器中,以下兩種情況都會導致StackOverflowError。 - **棧容量過小** 如下,使用Xss引數減少棧記憶體容量 ````java /** * vm引數:-Xss128k */ public class JavaVMStackSOF { private int stackLength = 1; public void stackLeak() { stackLength++; stackLeak(); } public static void main(String[] args) throws Throwable { JavaVMStackSOF oom = new JavaVMStackSOF(); try { oom.stackLeak(); } catch (Throwable e) { System.out.println("stack length:" + oom.stackLength); throw e; } } } ```` 執行結果: ![棧記憶體溢位](https://gitee.com/sanfene/picgo/raw/master/20210324210756.png) - **棧幀太大** 如下,通過一長串變數,來佔用區域性變量表空間。 ![carbon](https://gitee.com/sanfene/picgo/raw/master/20210324212131.png) 執行結果: ![image-20210324211958180](https://gitee.com/sanfene/picgo/raw/master/20210324211959.png) 無論是由於棧幀太大還是虛擬機器棧容量太小,當新的棧幀記憶體無法分配的時候, HotSpot虛擬機器丟擲的都是StackOverflowError異常。 ### 1.2.2、OutOfMemoryError 雖然不支援動態擴充套件棧,但是通過不斷建立執行緒的方式,也可以在HotSpot上產生記憶體溢位異常。 需要注意,這樣產生的記憶體溢位異常和棧空間是否足夠並不存在任何直接的關係,主要取決於作業系統本身的記憶體使用狀態。因為作業系統給每個程序的記憶體時有限的,執行緒數一多,自然會超過程序的容量。 建立執行緒導致記憶體溢位異常 : ````java /** * vm引數:-Xss2M */ public class JavaVMStackOOM { private void dontStop() { while (true) { } } public void stackLeakByThread() { while (true) { Thread thread = new Thread(new Runnable() { public void run() { dontStop(); } }); thread.start(); } } public static void main(String[] args) throws Throwable { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } } ```` 以上是一段比較有風險的程式碼,可能會導致系統假死,執行結果如下: ![image-20210324213320530](https://gitee.com/sanfene/picgo/raw/master/20210324213321.png) ## 1.3、方法區和執行時常量池溢位 這裡再提一下方法區和執行時常量池的變遷,JDK1.7以後字串常量池移動到了堆中,JDK1.8在直接記憶體中劃出一塊區域**元空間**來實現方區域。 String:intern()是一個本地方法,它的作用是如果字串常量池中已經包含一個等於此String物件的 字串,則返回代表池中這個字串的String物件的引用;否則,會將此String物件包含的字串新增到常量池中,並且返回此String物件的引用。在JDK 6或更早之前的HotSpot虛擬機器中,常量池都是分配在永久代中,永久代本身記憶體不限制可能會出現錯誤: ````java java.lang.OutOfMemoryError: PermGen space ```` ## 1.4、本機直接記憶體溢位 直接記憶體(Direct Memory)的容量大小可通過-XX:MaxDirectMemorySize引數來指定,如果不去指定,則預設與Java堆最大值(由-Xmx指定)一致。 直接通過反射獲取`Unsafe`例項,通過反射向作業系統申請分配記憶體: ````java /** * vm引數:-Xmx20M -XX:MaxDirectMemorySize=10M */ public class DirectMemoryOOM { private static final int _1MB = 1024 * 1024; public static void main(String[] args) throws Exception { Field unsafeField = Unsafe.class.getDeclaredFields()[0]; unsafeField.setAccessible(true); Unsafe unsafe = (Unsafe) unsafeField.get(null); while (true) { unsafe.allocateMemory(_1MB); } } } ```` 執行結果: ![image-20210324215114989](https://gitee.com/sanfene/picgo/raw/master/20210324215116.png) 由直接記憶體導致的記憶體溢位,一個明顯的特徵是在Heap Dump檔案中不會看見有什麼明顯的異常情況。 # 2、記憶體洩漏 記憶體回收,簡單說就是應該被垃圾回收的物件沒有被垃圾回收。 ![記憶體洩漏](https://gitee.com/sanfene/picgo/raw/master/20210324221539.png) 在上圖中:物件 X 引用物件 Y,X 的生命週期比 Y 的生命週期長,Y生命週期結束的時候,垃圾回收器不會回收物件Y。 我們來看幾個記憶體洩漏的例子: - **靜態集合類引起記憶體洩漏** 靜態集合的生命週期和 JVM 一致,所以靜態集合引用的物件不能被釋放。 ````java public class OOM { static List list = new ArrayList(); public void oomTests(){ Object obj = new Object(); list.add(obj); } } ```` - **單例模式**: 和上面的例子原理類似,單例物件在初始化後會以靜態變數的方式在 JVM 的整個生命週期中存在。如果單例物件持有外部的引用,那麼這個外部物件將不能被 GC 回收,導致記憶體洩漏。 - **資料連線、IO、Socket等連線** 建立的連線不再使用時,需要呼叫 **close** 方法關閉連線,只有連線被關閉後,GC 才會回收對應的物件(Connection,Statement,ResultSet,Session)。忘記關閉這些資源會導致持續佔有記憶體,無法被 GC 回收。 ````java 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 { //不關閉連線 } } ```` - **變數不合理的作用域** 一個變數的定義作用域大於其使用範圍,很可能存在記憶體洩漏;或不再使用物件沒有及時將物件設定為 null,很可能導致記憶體洩漏的發生。 ````java public class Simple { Object object; public void method1(){ object = new Object(); //...其他程式碼 //由於作用域原因,method1執行完成之後,object 物件所分配的記憶體不會馬上釋放 object = null; } } ```` - **引用了外部類的非靜態內部類** 非靜態內部類(或匿名類)的初始化總是需要依賴外部類的例項。預設情況下,每個非靜態內部類都包含對其**包含類**的隱式引用,若在程式中使用這個內部類物件,那麼**即使在包含類物件超出範圍之後,也不會被回收**(內部類物件隱式地持有外部類物件的引用,使其成不能被回收)。 - **Hash 值發生改變** 物件Hash值改變,使用HashMap、HashSet等容器中時候,由於物件修改之後的Hah值和儲存進容器時的Hash值不同,會導致無法從容器中**單獨刪除**當前物件,造成記憶體洩露。 - **ThreadLocal** 造成的記憶體洩漏 ThreadLocal 可以實現變數的執行緒隔離,但若使用不當,就可能會引入記憶體洩漏問題。

**參考:** 【1】:周志朋編著《深入理解Java虛擬機器:JVM高階特性與最佳實踐》 【2】:周志朋等翻譯《Java虛擬機器規範》 【3】:封亞飛編著《揭祕Java虛擬機器 JVM設計原理與實現》 【4】:[Java 中的記憶體溢位和記憶體洩露是什麼?我給你舉個有味道的例子 ](https://juejin.cn/post/6844904050127798285) 【5】:[那個小白還沒搞懂記憶體溢位,只能用案例說給他聽了](https://zhuanlan.zhihu.com/p/298056689) 【6】:[Intellij IDEA 整合 JProfiler 效能分析神器](https://learnku.com/articles/47263) 【7】:[JVM系列(二) - JVM記憶體區域詳解](https://zhuanlan.zhihu.com/p/43279292) 【8】[1篇文章搞清楚8種JVM記憶體溢位(OOM)的原因和解決方法](https://blog.csdn.net/Design407/article/details/102992316) 【9】:[十種JVM記憶體溢位的情況,你碰到過幾種?](https://segmentfault.com/a/1190000017226359) 【10】:[**JVM系列(二):JVM 記憶體洩漏與記憶體溢位及問題排查**](http://www.gxitsky.com/article/160334757