JVM記憶體管理:記憶體區域和記憶體洩漏
VM執行時資料區域
JVM執行Java程式的過程中,會使用到各種資料區域,這些區域有各自的用途、建立和銷燬時間。根據《Java虛擬機器規範(第二版)》(下文稱VM Spec)的規定,JVM包括下列幾個執行時資料區域:
1.程式計數器(Program Counter Register):
每一個Java執行緒都有一個程式計數器來用於儲存程式執行到當前方法的哪一個指令,對於非Native方法,這個區域記錄的是正在執行的VM原語的地址,如果正在執行的是Natvie方法,這個區域則為空(undefined)。此記憶體區域是唯一一個在VM Spec中沒有規定任何OutOfMemoryError情況的區域。
2.Java虛擬機器棧(Java Virtual Machine Stacks)
與程式計數器一樣,VM棧的生命週期也是與執行緒相同。VM棧描述的是Java方法呼叫的記憶體模型:每個方法被執行的時候,都會同時建立一個幀(Frame)用於儲存本地變量表、操作棧、動態連結、方法出入口等資訊。每一個方法的呼叫至完成,就意味著一個幀在VM棧中的入棧至出棧的過程。在後文中,我們將著重討論VM棧中本地變量表部分。
經常有人把Java記憶體簡單的區分為堆記憶體(Heap)和棧記憶體(Stack),實際中的區域遠比這種觀點複雜,這樣劃分只是說明與變數定義密切相關的記憶體區域是這兩塊。其中所指的“堆”後面會專門描述,而所指的“棧”就是VM棧中各個幀的本地變量表部分。本地變量表存放了編譯期可知的各種標量型別(boolean、byte、char、short、int、float、long、double)、物件引用(不是物件本身,僅僅是一個引用指標)、方法返回地址等。其中long和double會佔用2個本地變數空間(32bit),其餘佔用1個。本地變量表在進入方法時進行分配,當進入一個方法時,這個方法需要在幀中分配多大的本地變數是一件完全確定的事情,在方法執行期間不改變本地變量表的大小。
在VM Spec中對這個區域規定了2中異常狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;如果VM棧可以動態擴充套件(VM Spec中允許固定長度的VM棧),當擴充套件時無法申請到足夠記憶體則丟擲OutOfMemoryError異常。
3.本地方法棧(Native Method Stacks)
本地方法棧與VM棧所發揮作用是類似的,只不過VM棧為虛擬機器執行VM原語服務,而本地方法棧是為虛擬機器使用到的Native方法服務。它的實現的語言、方式與結構並沒有強制規定,甚至有的虛擬機器(譬如Sun Hotspot虛擬機器)直接就把本地方法棧和VM棧合二為一。和VM棧一樣,這個區域也會丟擲StackOverflowError和OutOfMemoryError異常。
4.Java堆(Java Heap)
對於絕大多數應用來說,Java堆是虛擬機器管理最大的一塊記憶體。Java堆是被所有執行緒共享的,在虛擬機器啟動時建立。Java堆的唯一目的就是存放物件例項,絕大部分的物件例項都在這裡分配。這一點在VM Spec中的描述是:所有的例項以及陣列都在堆上分配(原文:The heap is the runtime data area from which memory for all class instances and arrays is allocated),但是在逃逸分析和標量替換優化技術出現後,VM Spec的描述就顯得並不那麼準確了。
Java堆內還有更細緻的劃分:新生代、老年代,再細緻一點的:eden、from survivor、to survivor,甚至更細粒度的本地執行緒分配緩衝(TLAB)等,無論對Java堆如何劃分,目的都是為了更好的回收記憶體,或者更快的分配記憶體,在本章中我們僅僅針對記憶體區域的作用進行討論,Java堆中的上述各個區域的細節,可參見本文第二章《JVM記憶體管理:深入垃圾收集器與記憶體分配策略》。
根據VM Spec的要求,Java堆可以處於物理上不連續的記憶體空間,它邏輯上是連續的即可,就像我們的磁碟空間一樣。實現時可以選擇實現成固定大小的,也可以是可擴充套件的,不過當前所有商業的虛擬機器都是按照可擴充套件來實現的(通過-Xmx和-Xms控制)。如果在堆中無法分配記憶體,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError異常。
5.方法區(Method Area)
叫“方法區”可能認識它的人還不太多,如果叫永久代(Permanent Generation)它的粉絲也許就多了。它還有個別名叫做Non-Heap(非堆),但是VM Spec上則描述方法區為堆的一個邏輯部分(原文:the method area is logically part of the heap),這個名字的問題還真容易令人產生誤解,我們在這裡就不糾結了。
方法區中存放了每個Class的結構資訊,包括常量池、欄位描述、方法描述等等。VM Space描述中對這個區域的限制非常寬鬆,除了和Java堆一樣不需要連續的記憶體,也可以選擇固定大小或者可擴充套件外,甚至可以選擇不實現垃圾收集。相對來說,垃圾收集行為在這個區域是相對比較少發生的,但並不是某些描述那樣永久代不會發生GC(至少對當前主流的商業JVM實現來說是如此),這裡的GC主要是對常量池的回收和對類的解除安裝,雖然回收的“成績”一般也比較差強人意,尤其是類解除安裝,條件相當苛刻。
6.執行時常量池(Runtime Constant Pool)
Class檔案中除了有類的版本、欄位、方法、介面等描述等資訊外,還有一項資訊是常量表(constant_pool table),用於存放編譯期已可知的常量,這部分內容將在類載入後進入方法區(永久代)存放。但是Java語言並不要求常量一定只有編譯期預置入Class的常量表的內容才能進入方法區常量池,執行期間也可將新內容放入常量池(最典型的String.intern()方法)。
執行時常量池是方法區的一部分,自然受到方法區記憶體的限制,當常量池無法在申請到記憶體時會丟擲OutOfMemoryError異常。
7.本機直接記憶體(Direct Memory)
直接記憶體並不是虛擬機器執行時資料區的一部分,它根本就是本機記憶體而不是VM直接管理的區域。但是這部分記憶體也會導致OutOfMemoryError異常出現,因此我們放到這裡一起描述。
在JDK1.4中新加入了NIO類,引入一種基於渠道與緩衝區的I/O方式,它可以通過本機Native函式庫直接分配本機記憶體,然後通過一個儲存在Java堆裡面的DirectByteBuffer物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在Java對和本機堆中來回複製資料。
顯然本機直接記憶體的分配不會受到Java堆大小的限制,但是即然是記憶體那肯定還是要受到本機實體記憶體(包括SWAP區或者Windows虛擬記憶體)的限制的,一般伺服器管理員配置JVM引數時,會根據實際記憶體設定-Xmx等引數資訊,但經常忽略掉直接記憶體,使得各個記憶體區域總和大於實體記憶體限制(包括物理的和作業系統級的限制),而導致動態擴充套件時出現OutOfMemoryError異常。
實戰OutOfMemoryError
上述區域中,除了程式計數器,其他在VM Spec中都描述了產生OutOfMemoryError(下稱OOM)的情形,那我們就實戰模擬一下,通過幾段簡單的程式碼,令對應的區域產生OOM異常以便加深認識,同時初步介紹一些與記憶體相關的虛擬機器引數。下文的程式碼都是基於Sun Hotspot虛擬機器1.6版的實現,對於不同公司的不同版本的虛擬機器,引數與程式執行結果可能結果會有所差別。
Java堆
Java堆存放的是物件例項,因此只要不斷建立物件,並且保證GC Roots到物件之間有可達路徑即可產生OOM異常。測試中限制Java堆大小為20M,不可擴充套件,通過引數-XX:+HeapDumpOnOutOfMemoryError讓虛擬機器在出現OOM異常的時候Dump出記憶體映像以便分析。(關於Dump映像檔案分析方面的內容,可參見本文第三章《JVM記憶體管理:深入JVM記憶體異常分析與調優》。)
清單1:Java堆OOM測試
/** * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError * @author zzm */ public class HeapOOM { static class OOMObject { } public static void main(String[] args) { List<OOMObject> list = new ArrayList<OOMObject>(); while (true) { list.add(new OOMObject()); } } } |
執行結果:
java.lang.OutOfMemoryError: Java heap space Dumping heap to java_pid3404.hprof ... Heap dump file created [22045981 bytes in 0.663 secs] |
VM棧和本地方法棧
Hotspot虛擬機器並不區分VM棧和本地方法棧,因此-Xoss引數實際上是無效的,棧容量只由-Xss引數設定。關於VM棧和本地方法棧在VM Spec描述了兩種異常:StackOverflowError與OutOfMemoryError,當棧空間無法繼續分配分配時,到底是記憶體太小還是棧太大其實某種意義上是對同一件事情的兩種描述而已,在筆者的實驗中,對於單執行緒應用嘗試下面3種方法均無法讓虛擬機器產生OOM,全部嘗試結果都是獲得SOF異常。
1.使用-Xss引數削減棧記憶體容量。結果:丟擲SOF異常時的堆疊深度相應縮小。
2.定義大量的本地變數,增大此方法對應幀的長度。結果:丟擲SOF異常時的堆疊深度相應縮小。
3.建立幾個定義很多本地變數的複雜物件,開啟逃逸分析和標量替換選項,使得JIT編譯器允許物件拆分後在棧中分配。結果:實際效果同第二點。
清單2:VM棧和本地方法棧OOM測試(僅作為第1點測試程式)
/** * VM Args:-Xss128k * @author zzm */ 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; } } } |
執行結果:
stack length:2402 Exception in thread "main" java.lang.StackOverflowError at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:20) at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21) at org.fenixsoft.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:21) |
如果在多執行緒環境下,不斷建立執行緒倒是可以產生OOM異常,但是基本上這個異常和VM棧空間夠不夠關係沒有直接關係,甚至是給每個執行緒的VM棧分配的記憶體越多反而越容易產生這個OOM異常。
原因其實很好理解,作業系統分配給每個程序的記憶體是有限制的,譬如32位Windows限制為2G,Java堆和方法區的大小JVM有引數可以限制最大值,那剩餘的記憶體為2G(作業系統限制)-Xmx(最大堆)-MaxPermSize(最大方法區),程式計數器消耗記憶體很小,可以忽略掉,那虛擬機器程序本身耗費的記憶體不計算的話,剩下的記憶體就供每一個執行緒的VM棧和本地方法棧瓜分了,那自然每個執行緒中VM棧分配記憶體越多,就越容易把剩下的記憶體耗盡。
清單3:建立執行緒導致OOM異常
/** * VM Args:-Xss2M (這時候不妨設大些) * @author zzm */ public class JavaVMStackOOM { private void dontStop() { while (true) { } } public void stackLeakByThread() { while (true) { Thread thread = new Thread(new Runnable() { @Override public void run() { dontStop(); } }); thread.start(); } } public static void main(String[] args) throws Throwable { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } } |
特別提示一下,如果讀者要執行上面這段程式碼,記得要存檔當前工作,上述程式碼執行時有很大令作業系統卡死的風險。
執行結果:
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread |
執行時常量池
要在常量池裡新增內容,最簡單的就是使用String.intern()這個Native方法。由於常量池分配在方法區內,我們只需要通過-XX:PermSize和-XX:MaxPermSize限制方法區大小即可限制常量池容量。實現程式碼如下:
清單4:執行時常量池導致的OOM異常
/** * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M * @author zzm */ public class RuntimeConstantPoolOOM { public static void main(String[] args) { // 使用List保持著常量池引用,壓制Full GC回收常量池行為 List<String> list = new ArrayList<String>(); // 10M的PermSize在integer範圍內足夠產生OOM了 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) at org.fenixsoft.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18) |
方法區
上文講過,方法區用於存放Class相關資訊,所以這個區域的測試我們藉助CGLib直接操作位元組碼動態生成大量的Class,值得注意的是,這裡我們這個例子中模擬的場景其實經常會在實際應用中出現:當前很多主流框架,如Spring、Hibernate對類進行增強時,都會使用到CGLib這類位元組碼技術,當增強的類越多,就需要越大的方法區用於保證動態生成的Class可以載入入記憶體。
清單5:藉助CGLib使得方法區出現OOM異常
/** * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M * @author zzm */ public class JavaMethodAreaOOM { 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 obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { return proxy.invokeSuper(obj, args); } }); enhancer.create(); } } static class OOMObject { } } |
執行結果:
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) ... 8 more |
本機直接記憶體
DirectMemory容量可通過-XX:MaxDirectMemorySize指定,不指定的話預設與Java堆(-Xmx指定)一樣,下文程式碼越過了DirectByteBuffer,直接通過反射獲取Unsafe例項進行記憶體分配(Unsafe類的getUnsafe()方法限制了只有引導類載入器才會返回例項,也就是基本上只有rt.jar裡面的類的才能使用),因為DirectByteBuffer也會拋OOM異常,但丟擲異常時實際上並沒有真正向作業系統申請分配記憶體,而是通過計算得知無法分配既會丟擲,真正申請分配的方法是unsafe.allocateMemory()。
/** * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M * @author zzm */ 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); } } } |
執行結果:
Exception in thread "main" java.lang.OutOfMemoryError at sun.misc.Unsafe.allocateMemory(Native Method) at org.fenixsoft.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:20) |
總結
到此為止,我們弄清楚虛擬機器裡面的記憶體是如何劃分的,哪部分割槽域,什麼樣的程式碼、操作可能導致OOM異常。雖然Java有垃圾收集機制,但OOM仍然離我們並不遙遠,本章內容我們只是知道各個區域OOM異常出現的原因,下一章我們將看看Java垃圾收集機制為了避免OOM異常出現,做出了什麼樣的努力。