Java與C++之間有一堵由內存動態分配和垃圾收集技術所圍成的高牆,牆外面的人想進去,牆裏面的人卻想出來。
概述:
對於從事C、C++程序開發的開發人員來說,在內存管理領域,他們即是擁有最高權力的皇帝又是執行最基礎工作的勞動人民——擁有每一個對象的“所有權”,又擔負着每一個對象生命開始到終結的維護責任。
對於Java程序員來說,不需要在爲每一個new操作去寫配對的delete/free,不容易出現內容泄漏和內存溢出錯誤,看起來由JVM管理內存一切都很美好。不過,也正是因爲Java程序員把內存控制的權力交給了JVM,一旦出現泄漏和溢出,如果不瞭解JVM是怎樣使用內存的,那排查錯誤將會是一件非常困難的事情。
VM運行時數據區域
JVM執行Java程序的過程中,會使用到各種數據區域,這些區域有各自的用途、創建和銷燬時間。根據《Java虛擬機規範(第二版)》(下文稱VM Spec)的規定,JVM包括下列幾個運行時數據區域:
1.程序計數器(Program Counter Register):
每一個Java線程都有一個程序計數器來用於保存程序執行到當前方法的哪一個指令,對於非Native方法,這個區域記錄的是正在執行的VM原語的地址,如果正在執行的是Natvie方法,這個區域則爲空(undefined)。此內存區域是唯一一個在VM Spec中沒有規定任何OutOfMemoryError情況的區域。
2.Java虛擬機棧(JavaVirtual 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方法服務。它的實現的語言、方式與結構並沒有強制規定,甚至有的虛擬機(譬如SunHotspot虛擬機)直接就把本地方法棧和VM棧合二爲一。和VM棧一樣,這個區域也會拋出StackOverflowError和OutOfMemoryError異常。
4.Java堆(Java Heap)
對於絕大多數應用來說,Java堆是虛擬機管理最大的一塊內存。Java堆是被所有線程共享的,在虛擬機啓動時創建。Java堆的唯一目的就是存放對象實例,絕大部分的對象實例都在這裏分配。這一點在VMSpec中的描述是:所有的實例以及數組都在堆上分配(原文:The heap is the runtime data area from which memory for all classinstances 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)
叫“方法區”可能認識它的人還不太多,如果叫永久代(PermanentGeneration)它的粉絲也許就多了。它還有個別名叫做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異常出現,做出了什麼樣的努力。