本系列筆記主要基於《深入理解Java虛擬機:JVM高級特性與最佳實踐 第2版》,是這本書的讀書筆記。 在 Java 虛擬機記憶體區域中,除了程式計數器外,其他幾個記憶體區域都可能會發生OutOfMemoryError,這次通過一些代碼來驗證虛擬機各個記憶體區域存儲的內容。 在實際工作中遇到記憶體溢出異常時, ...
本系列筆記主要基於《深入理解Java虛擬機:JVM高級特性與最佳實踐 第2版》,是這本書的讀書筆記。
在 Java 虛擬機記憶體區域中,除了程式計數器外,其他幾個記憶體區域都可能會發生OutOfMemoryError,這次通過一些代碼來驗證虛擬機各個記憶體區域存儲的內容。
在實際工作中遇到記憶體溢出異常時,需要做到能根據異常信息快速判斷是哪個記憶體區域的溢出,知道什麼樣的代碼會導致這些區域記憶體溢出,並且知道出現記憶體溢出後如何處理。
Java堆溢出
Java 堆用於存儲對象實例,只要不斷的擴展對象,並且保證 GC Roots 到對象有可達路徑來避免垃圾回收,那麼對象數量到達堆的最大容量後就會發生記憶體溢出異常。
模擬堆記憶體溢出
以下代碼會把堆大小限制在20M且不可擴展(將最小參數-Xms
和最大參數-Xmx
設為相同就會避免自動擴展),通過參數-XX:+HeapDumpOnOutOfMemoryError
可以讓虛擬機在發生記憶體溢出時Dump出記憶體快照用來分析。
參數 | 說明 |
---|---|
-XX:+HeapDumpOnOutOfMemoryError | 記憶體溢出時自動導出記憶體快照 |
-XX:HeapDumpPath=E:/dumps/ | 導出記憶體快照時保存的路徑 |
/**
* Java堆記憶體溢出異常
* VM args: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
* -Xms和-Xmx設為相同值避免堆記憶體自動擴展,
* -XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機在發生OOM時Dump出記憶體快照
* Run With JDK 1.8
* */
public class HeapOOM {
static class OOMObject{
}
public static void main(String[] args){
List<OOMObject> list = new ArrayList<>();
while(true){
list.add(new OOMObject());
}
}
}
運行結果:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid1344.hprof ...
Heap dump file created [29068691 bytes in 0.108 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:261)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
at java.util.ArrayList.add(ArrayList.java:458)
at test.oom.HeapOOM.main(HeapOOM.java:21)
可以從異常信息中看到,OOM異常發生在“main”線程,發生的記憶體區域是“Java heap space”。
通過IntelliJ IDEA運行的話,可以點擊Edit Configurations
配置VM參數,生成的堆Dump快照文件為hprof尾碼,存放在Working directory
配置對應的目錄下,如下圖:
堆記憶體溢出分析
要分析 Java 堆的記憶體溢出,首先通過快照分析工具(如Java VisualVM)對 Dump 出來的的快照進行分析,確認記憶體中的對象是否是必要的。如果是不必要的而沒有垃圾回收掉,則發生的是記憶體泄漏(Memory Leak);如果都是必要的,則是記憶體溢出(Memory Overflow)。
如果是記憶體泄漏,通過工具進一步查看對象實例到 GC Roots 的引用鏈,找到泄露對象是通過什麼路徑與 GC Roots 相關聯導致垃圾收集器無法回收它們。根據泄露對象的類型信息和到 GC Roots 的引用鏈,就可以定位到泄露代碼的位置。
如果是記憶體溢出,也就是說這些對象還都必須存活,那麼就檢查堆記憶體的大小參數(-Xms與-Xmx)與物理記憶體比較還是否可以調大,再從代碼上檢查是否存在某些對象生命周期過長、持有狀態時間過長的情況,嘗試減少程式運行期的記憶體消耗。
打開 JDK 自帶的分析工具 Java VisualVM(bin目錄下的jvisualvm.exe),點擊文件->裝入
選擇堆快照java_pid1344.hprof
文件,打開後顯示的是概述信息,這裡會顯示快照的一些基本信息、環境屬性以及線程信息。
然後點擊類
,打開後如下圖:
從上圖可以看到,數量最多的且占用記憶體最大的對象是OOMObject類型的實例,OOMObject類型共有實例810,326
個,大小總共12,965,216
個位元組(byte),而這些對象都是在while迴圈中new出來加入到List中的,都是應該存活的對象,也就是說發生的OOM是記憶體溢出而不是記憶體泄漏。
然後在OOMObject
的記錄上右鍵點擊在實例試圖中顯示
,則會打開實例視圖,見下圖:
可以看到其中一個OOMObject
對象的引用鏈,它被一個Object[]
數組中的元素引用,我們都知道ArrayList
是基於數組實現的,而這個Object[]
數組對象就是一個 GC Root,它的記憶體地址是578296
。
虛擬機棧和本地方法棧溢出
在記憶體區域那篇文章講到過,HotSpot虛擬機把本地方法棧和虛擬機棧合二為一了,棧容量由-Xss
參數設置。關於虛擬機棧和本地方法棧,虛擬機規範規定了兩種異常:
- 如果線程請求的棧深度大於虛擬機所允許的最大深度,將拋出StackOverflowError異常。
- 如果虛擬機在擴展棧時無法申請到足夠的記憶體空間,則拋出OutOfMemoryError異常。
這裡把異常分為了兩種,看似嚴謹實際上有相互重疊的地方,當棧空間無法繼續分配時,到底是記憶體太小,還是已使用的棧空間太大,本質上只是對同一個問題的不同描述而已。
有兩種方法會拋出StackOverflowError異常,一種是通過-Xss
參數減小棧記憶體容量;一種是定義大量局部變數,從而增大此方法幀中的局部變數表的長度。以下代碼是第一種:
/**
* Java棧記憶體溢出異常
* 通過減小棧記憶體容量拋出StackOverflowError
* VM args: -Xss128K
* Run With JDK 1.8
* */
public class StackOOM {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
StackOOM oom = new StackOOM();
try {
oom.stackLeak();
}catch(Throwable e){
System.out.println("stack length: " + oom.stackLength);
throw e;
}
}
}
運行結果:
stack length: 998
Exception in thread "main" java.lang.StackOverflowError
at com.cellei.outofmemory.StackOOM.stackLeak(StackOOM.java:15)
at com.cellei.outofmemory.StackOOM.stackLeak(StackOOM.java:16)
at com.cellei.outofmemory.StackOOM.stackLeak(StackOOM.java:16)
...
at com.cellei.outofmemory.StackOOM.main(StackOOM.java:22)
實驗結果表明,不論是減小棧容量大小還是增加棧幀大小,當記憶體無法分配時虛擬機拋出的都是StackOverflowError異常。
如果不限於單線程,不斷的建立線程的情況下倒是會拋出OutOfMemoryError異常,但跟棧空間是否足夠大沒有直接關係,而且棧是線程私有的記憶體區域。這種情況下,每個線程的棧分配的記憶體越大,就越容易產生記憶體溢出異常。
虛擬機提供了參數來控制堆記憶體和方法區的最大容量,物理記憶體減去堆記憶體最大值,再減去方法區的最大值,程式計數器消耗記憶體很小忽略不計,剩下的就被虛擬機棧和本地方法棧瓜分了。所以每個線程分配到的棧容量越大,則可以建立的線程數量越少,建立線程時就越容易把剩下的記憶體耗盡。如果建立過多導致了記憶體溢出,在不能減少線程數的情況下,就只能通過減少最大堆和減少棧容量來換取更多的線程。
方法區和運行時常量池溢出
在JDK1.6及之前,運行時常量池是方法區的一部分,且方法區還使用永久代實現,那時候可以在限制永久代大小的情況下,迴圈調用String.intern()
方法造成運行時常量池溢出而導致方法區溢出。使用參數-XX:PermSize
和-XX:MaxPermSize
來限制永久代也就是方法區的大小。String.intern()
方法是一個Native方法,作用是:如果字元串常量池中已經包含一個等於此String對象的字元串,則返回代表常量池中這個字元串的String對象;否則,將此String對象包含的字元串添加到常量池中。
在JDK1.7的時候常量池挪到了堆記憶體中,到了JDK1.8就乾脆取消了永久代,取而代之的是元空間(MetaSpace),且元空間是位於本地記憶體而不是虛擬機記憶體。
以下代碼,在JDK1.6及之前的版本中會產生記憶體溢出:
/**
* 要求運行在 JDK1.6 或以前
* 導致常量池溢出從而產生永久代溢出
* VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
* Run With JDK 1.6
*/
public class ConstantPoolOverflowTest
{
public static void main(String[] args)
{
List<String> list = new ArrayList<String>();
int i = 0;
while (true)
{
list.add(String.valueOf(i++).intern());
}
}
}
運行結果:
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
at java.lang.String.intern(Native Method)
...
可見運行結果提示了PermGen space
,表明是那個版本的永久代也就是方法區溢出。
既然JDK1.7及之後常量池挪到了 Java 堆中,在那之後的版本如何產生方法區溢出呢?既然方法區用於存放類的相關信息,基本思路就是在運行時產生大量的類去填充方法區,直到溢出。可以使用 JDK 的動態代理,也可以使用第三方庫比如 CGLib 實現。
以下代碼使用CGLib庫,在運行時不斷的產生類導致方法區溢出。由於JDK1.8的方法區改為了使用元空間實現,所以可以使用參數-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
限制方法區大小。
/**
* 限制元空間大小後
* 使用CGLib運行時產生類,導致元空間也就是方法區溢出
* VM Args:-XX:MetaspaceSize=8M -XX:MaxMetaspaceSize=28M
* Run With JDK 1.8
*/
public class MethodAreaOOM {
static class OOMObject{
}
public static void main(String[] args){
while(true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object o, Method method, Object[] objects,
MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o, objects);
}
});
enhancer.create();
}
}
}
運行結果:
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:345)
at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)
at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:114)
at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:291)
at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)
at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:305)
at com.cellei.oom.MethodAreaOOM.main(MethodAreaOOM.java:29)
可見異常信息提示Metaspace
,就是說元空間(方法區)記憶體溢出了。方法區溢出也是一種比較常見的溢出,一個類要被垃圾收集器回收,判定條件是比較苛刻的。在經常動態產生大量 Class 的應用中,要特別註意類的回收情況。
本機記憶體直接溢出
DirectMemory容量可以通過參數-XX:MaxDirectMemorySize
指定,如果不指定,則預設與 Java 堆最大值(-Xmx)一樣。通過反射獲取Unsafe
實例進行記憶體分配,allocateMemory()
方法會真正申請分配記憶體。
/**
* 不斷的申請記憶體,導致本機記憶體溢出
* VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
* Run With JDK 1.8
* */
public class DirectMemoryOOM {
private static final int _1M = 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(_1M);
}
}
}
運行結果:
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at com.cellei.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:20)
由DirectMemory導致的記憶體溢出,有一個特點就是Heap Dump文件中不會看到明顯異常,如果Dump文件非常小,又直接間接使用了NIO,則有可能是這方面的原因。
本文代碼的 Github Repo 地址:https://github.com/cellei/JVM-Practice