Java編程拾遺『JVM內存區域』

作爲一名Java開發人員,JVM是我們每天都會打交道的對象。但是由於JVM處於知識體系的底層,同時工作中有可能接觸的機會不是很多,導致很多人都對JVM相關的知識一知半解。一般只會在面試的時候,纔來準備這部分內容。但JVM是爲了讓我們更好的理解Java,更深入瞭解我們每天開發程序的執行機制。

所以從本篇文章開始,我們來介紹以下JVM相關的知識。大致規劃一下應該會包括 以下內容:JVM內存區域、垃圾收集器介紹、JVM性能監控指標及工具。另外,JVM系列的文章,基本都來自對《深入理解java虛擬機》一書的理解與總結及相關的博客文章,有可能有不對的地方,還希望大佬可以不吝指出。在文章開始我先給一下我在看博客過程中,遇到的比較優秀的JVM文章的相關作者:

關於上述兩位大神,我這裏就不多介紹了,去盤大佬們的文章就好。

Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分爲若干個不同的數據區域。這些區域都有各自的用途,以及創建和銷燬的時間,有的區域隨着虛擬機進程啓動而存在,有寫區域則是依賴用戶線程的啓動和結束而建立和銷燬。根據《Java虛擬機規範》的規定,Java虛擬機所管理的內存將會包括一下幾個運行時數據區域,如下所示:

1. 運行時數據區

1.1 程序計數器

程序計數器是一塊比較小的內存空間,它的作用可以看作是當前線程所執行字節碼的行號指示器。在虛擬機的概念模型裏(這裏強調一下,是概念模型,各種虛擬機可以通過一些更高效的方式去實現),字節碼解釋器工作時就是通過改變這個計數器的值來選取嚇一跳需要執行的字節碼指令,分之、循環、跳轉、異常處理、線程恢復等基礎功能都需要依賴這個計數器來完成。

由於Java虛擬機的多線程通過線程輪流切換並分配處理器執行時間來實現的,在任何一個時刻,一個處理器(對於多核處理器來說是一個內核)只會執行一條線程中的指令。因此爲了線程切換後能夠恢復到正確的位置,每個線程都需要有一個獨立的程序計數器,各個線程之間的計數器互不影響,獨立存儲,我們稱這類內存區域爲“線程私有”的內存。

如果線程正在執行的是一個Java方法,這種歌計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果正在執行的是native方法,這個計數器值則爲空。此區域是唯一一個在Java虛擬機規範中沒有規定OutOfMemoryError情況的區域。

1.2 Java虛擬機棧

與程序計數器一樣,Java虛擬機棧也是線程私有的。它的聲明週期和線程相同。虛擬機棧描述的是Java方法執行的內存模型:每個方法被執行的時候都會同時創建一個棧幀用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法被調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程。

局部變量表存放了編譯期可知的8中基本類型(byte、short、int、long、float、double、char、boolean)、對象引用(reference類型,它不等同與對象本身,根據不同的虛擬機實現,它可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄)和returnAddress類型(指向了一條字節碼指令的地址)。

在Java虛擬機規範中,對這個區域規範了兩種異常狀況:如果線程請求的棧深度大於虛擬機所允許的深度,將拋出StackOverflowError;如果虛擬機棧可以動態擴展(當前大部分Java虛擬機都可動態擴展,只不過Java虛擬機規範也允許固定長度的虛擬機棧),當擴展時無法申請到足夠的內存時會拋出OutOfMemoryError異常。

1.3 本地方法棧

本地方法棧與Java虛擬機棧發揮的作用非常類似,區別在於Java虛擬機棧是爲Java方法(也就是字節碼)服務的,而本地方法棧則是爲虛擬機所使用到的Native方法服務的。虛擬機規範中對於本地方法棧中的方法使用的語言、使用方式與數據結構並沒有強制規定,因此具體的虛擬機可以自由實現。甚至有的虛擬機(比如Sun HotSpot虛擬機)直接就把本地方法棧和Java虛擬機棧合二爲一。與虛擬機棧一樣,本地方法棧區域也會拋出StackOverFlowError和OutOfMemoryError異常。

1.4 Java堆

對大多數應用來說,Java堆是Java虛擬機所管理內存中最大的一塊。Java堆是被所有線程共享的一塊區域,在虛擬機啓動時創建。詞內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例以及數組都在堆上分配(這裏說的是幾乎,隨着JIT編譯器的發展和逃逸分析技術的逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在堆上也逐漸變得不那麼“絕對”了)。

Java堆是垃圾收集器管理的主要區域,因此有時候也被稱作“GC堆”。如果從內存回收角度看,由於現在的收集器基本都是採用分代回收算法,所以Java堆中還可以細分爲:新生代和老年代。在細緻一點可以劃分爲Enden空間、From Survivor空間、To Survivor空間。如果從內存分配角度看,線程共享的Java堆中可能劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB)。不過無論如何劃分,都與存放內容無關,無論哪個區域,存儲的依然都是對象實例。進一步劃分的目的是爲了更好地回首內存,或者更快地分配內存。

根據Java虛擬機規範的規定,Java堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可,就像磁盤空間一樣。在實現時,既可以是線程固定大小的,也可以是可擴展的,不過當前主流的虛擬機都是按照可擴展來實現的(通過-Xmx和-Xms控制)。如果在隊中沒有內存完成實例分配,並且堆也無法在擴展時,將會拋出OutOfMemoryError異常。

1.5 方法區

方法區與Java堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯後的代碼等數據。雖然Java虛擬機規範吧方法區描述爲堆的一個邏輯部分,但是它卻又一個別名叫做Non-Heap(非堆),目的是與Java堆區分開來。

對於習慣在HotSpot虛擬機上開發和部署程序的開發者來說,很多人願意吧方法區稱爲“永久代”,本質上兩者並不等價,僅僅是因爲HotSpot虛擬機團隊選擇把GC分代收集擴展是方法區,或者說使用永久代來實現方法區而已。對於其它虛擬機(如BEA JRockit、IBM J9等)來說是不存在永久帶的概念的。即使是HotSpot虛擬機本身,現在也放棄了永久代,轉而通過Native Memory(元空間)來實現方法區了。

Java虛擬機規範對於這個區域的限制分廠寬鬆,除了和Java堆一樣不需要連續的內存空間和可以選擇固定大小和擴展外,還可以選擇不實現垃圾收集。相對而言,垃圾手機行爲在這個區域是比較少出現的,但並非數據進入方法區就如永久代的名字一樣“永久”存在了。這個區域的內存回收目標主要是針對常量池的回收和堆類型的卸載,一般來說這個區域的回收“成績”比較難以令人滿意,尤其是類型的卸載,條件相當苛刻,但是這部分區域的回收確實是有必要的。根據Java虛擬機規範規定,當方法區無法滿足內存分配需求時,將拋出OutOfMemoryError異常。

1.6 運行時常量池

運行時常量池是方法區的一部分(Java7之前,Java7及之後的版本,運行時常量池被移到堆中)。Class文件中除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後存放到方法區的運行時常量池中。(這部分的示例可以區參考之前的一篇文章Java編程拾遺『String類』

Java虛擬機對Class文件的每一部分的格式都有嚴格的規定,每一個字節用於存儲那種數據都必須符合規範上的要求,這樣纔會被虛擬機認可、裝載和執行。但對於運行時常量池,Java虛擬機規範沒有做任何細節的要求,不同的提供商實現的虛擬機可以按照自己的需要來實現這個區域。不過一般來說,除了保存Class文件中描述的符號飲用外,還會把翻譯出來的直接引用也存儲在運行時常量池中。

運行時常量池相對於Class文件常量池的另一個重要特徵是具備動態性,Java語言並不要求常量一定只能在編譯期產生,運行期間也可能將新的常量放入運行時常量池中,比如String類的intern()方法。

既然運行時常量池是方法區的一部分,自然也會受到方法去內存的限制,當運行時常量池無法在申請到內存時會拋出OutOfMemoryError異常。

上述6個部分基本就是JVM管理的運行時數據區,更細緻的劃分及相應的控制參數如下圖所示,對於下圖中出現的控制參數會在後續的文章中介紹:

2. Java對象訪問

介紹完JVM運行時數據區後,我們來看一個比較Java中比較基礎的一個問題:在Java語言中,對象是如何訪問的?對象訪問在Java中無處不在,是最基礎的行爲,那麼對象的訪問跟我們之前介紹的JVM運行時數據區有什麼關聯?比如下面這行代碼:

Object obj = new Object();

假設這句代碼出現在方法體中,那麼“Object obj”這部分的語義將會反映到Java虛擬機棧的局部變量表中,作爲一個reference類型數據出現。而“new Object()”這部分的語義將會反映到Java堆中,形成一塊存儲了Object類型所有實例數據值(對象中各個實例成員變量的字段)的結構化內存,根據具體類型以及虛擬機實現的對象內存佈局的不同,這塊內存的長度是不固定的。另外,在Java堆中還必須包含能夠查找到詞對象類型數據(如對象的類型、父類、實現的接口、方法)的地址信息,這些數據類型存儲在方法區中。

由於reference類型在Java虛擬機規範裏只規定了一個指向對象的引用,並沒有定義這個引用應該通過哪種方式去定位,以及訪問到Java堆中的對象的具體位置。因此不同的虛擬機實現的對象訪問方式會有所不同,主流的訪問方式有兩種:使用句柄和直接指針。

  • 使用句柄的方式,Java堆中會規劃出一塊內存作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據和類型數據各自的地址信息,如下圖所示:

  • 使用直接指針訪問的方式,Java堆對象的佈局中就必須考慮如何放置訪問類型的相關信息,reference中存儲的就是對象的地址,如下圖所示:

這兩種對象訪問的方式各有優勢,使用句柄訪問的方式最大的好處就是reference中存儲的是穩定的句柄地址,在對象被移動(比如垃圾收集時,整理內存空間)時只需要改變句柄中的實例數據地址,而reference本身不需要被修改。

使用直接指針的方式最大的好處在於速度更快,它節省了一次指針定位的時間開銷,對於對象的訪問在Java中非常頻繁,因此這類開銷積少成多後也是一項非常大的執行成本。Sun HotSpot虛擬機而言,他使用的是直接指針進行對象訪問的。

3. 模擬運行時數據區異常

上面介紹了JVM運行時數據區的各個區域,並提到每個區域可能的遇到的異常,本節就來通過Java代碼來模擬一下各個區域的異常。以下代碼實驗環境:

體系結構: x86_64 64bit
Java 版本: 1.8.0_221
JVM: Java HotSpot(TM) 64-Bit Server VM (25.221-b11, mixed mode)
Java 供應商: Oracle Corporation

3.1 Java堆溢出

Java堆用於存儲對象實例,我們只要不斷創建對象,並保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清除這些對象,就會在對象數量達到堆的容量限制後產生內存溢出異常。

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 *
 */
public class HeapOOM {

    static class OOMObject {
    }

    public static void main(String[] args) throws InterruptedException {
        List<OOMObject> list = new ArrayList<>();
        while (true) {
            list.add(new OOMObject());
        }
    }
}

上述代碼,我們限制堆空間大小爲20M,不可擴展(將堆的最小值-Xms參數與最大值-Xmx參數設置爲一樣即可避免堆自動擴展),通過-XX:+HeapDumpOnOutOfMemoryError可以讓虛擬機在出現內存溢出異常時Dump出當前的內存堆轉儲快照以便事後分析。

運行結果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid12597.hprof ...
Heap dump file created [27795654 bytes in 0.127 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:265)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
	at java.util.ArrayList.add(ArrayList.java:462)
	at com.zhuoli.service.thinking.java.jvm.HeapOOM.main(HeapOOM.java:22)

Java堆內存的OOM異常是實際應用中最常見的內存溢出異常情況。出現Java堆內存溢出時,異常堆棧信息“Java heap space”。要解決這個區域的異常,一般手段是首先通過內存映像分析工具(如Eclipse Memory Analyzer)對dump出來的堆轉儲快照進行分心,重點是確認內存中的對象是否有必要,也就是線分清楚到底是出現了內存泄漏(Memory Leak)還是內存溢出(Memory Overflow)。

如果是內存泄漏,可以進一步通過工具查看泄漏對象到GC Roots的引用鏈。於是就能找到泄漏對象是通過怎樣的路徑與GC Roots相關聯並導致垃圾收集器無法自動回收的。掌握了泄漏對象的類型信息,以及GC Toors引用鏈信息,就可以比較精確地定位到泄漏代碼的位置。

如果不存在泄漏,換句話說就是內存中的對象確實都還必須存活着,那就應該檢查虛擬機堆參數(-Xmx和-Xms),與物理內存相比看是否還可以調大,從代碼上檢查是否存在某些對象生命週期過長、持有狀態時間過長的情況,嘗試減少程序運行期的內存消耗。

3.2 虛擬機棧溢出

由於HotSpot虛擬機並不區分虛擬機棧和本地方法棧,因此對於HotSpot虛擬機來說,-Xoss參數(設置本地方法棧大小)雖然存在,但實際上是無效的,棧容量只由-Xss參數設定。關於虛擬機棧和本地方法棧,在Java虛擬機規範中描述了兩種異常:

  • 如果線程請求棧的深度大於虛擬機所允許的最大深度,將跑出StackOverflowError異常。
  • 如果虛擬機在擴展棧時無法申請到足夠的內存空間,則跑出OutOfMemoryError異常。

這裏把異常氛圍兩種情況看似更加嚴謹,但卻存在一些互相重疊的地方:當棧空間無法繼續分配時,到底是內存太小,還是已使用棧空間太大,其本質只是對同一問題的兩種描述而已

在筆者的實驗中,如果將實驗範圍限制於單線程中的操作,嘗試下面的兩種方法均無法讓虛擬機產生OutOfMemoryError異常,嘗試的結果都是獲得了StackOverflowError異常。

  • 使用-Xss參數減少棧內存容量。結果:拋出StackOverflowError異常,異常輸出時棧的深度相應縮小。
  • 定義了大量的本地變量,增加此方法棧幀中本地變量表的長度。結果,拋出StackOverflowError異常,異常輸出時棧的深度相應縮小。
/**
 * @author zhuoli
 * VM Args: -Xss256k
 */
public class JVMStackOverflow {

    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) {
        JVMStackOverflow oom = new JVMStackOverflow();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

運行結果:

stack length:2414
Exception in thread "main" java.lang.StackOverflowError
	at com.zhuoli.service.thinking.java.jvm.JVMStackOverflow.stackLeak(JVMStackOverflow.java:13)
	at com.zhuoli.service.thinking.java.jvm.JVMStackOverflow.stackLeak(JVMStackOverflow.java:13)
	at com.zhuoli.service.thinking.java.jvm.JVMStackOverflow.stackLeak(JVMStackOverflow.java:13)
	at com.zhuoli.service.thinking.java.jvm.JVMStackOverflow.stackLeak(JVMStackOverflow.java:13)
……

實驗證明,在單個線程下,無論是由於棧幀太大,還是虛擬機棧容量太小,當內存無法分配的時候,虛擬機拋出的都是StackOverflowError異常。

如果測試時不限於單線程,通過不斷建立線程的方式倒是可以產生內存溢出異常,代碼如下所示。但是,這樣產生的內存溢出異常與棧空間是否足夠大並不存在直接聯繫,或者準確地說,在這種情況下,給每個線程的棧分配的內存越大,反而越容易產生內存溢出異常。

原因也很好理解,操作系統分配給每個進程的內存是有限制的,譬如32位的windows限制爲2GB。虛擬機提供了參數來控制Java堆和方法去這兩部分內存的最大值。生育的內存爲2GB(操作系統限制)減去Xmx(最大堆容量),再減去MaxPermSize(最大方法區容量),程序計數器消耗內存很小,可以忽略掉。如果虛擬機進程本身消耗的內存不計算在內,生育的內存就由虛擬機棧和本地方法棧瓜分了。每個線程分配到的棧容量越大,可以建立的線程數量自然就越少,建立線程時就越容易把剩下的內存耗盡。

這一點,在開發多線程應用的時候要特別注意,出現StackOverflowError異常時有錯誤堆棧可以閱讀,相對來說,比較容易找到問題所在。而且,如果使用虛擬機默認參數,棧深度在在大多數情況下達到1000~2000是沒問題的,對於正常的方法調用(包括遞歸),這個深度應該是夠用了。但是,如果是建立多線程導致的內存溢出,在不能減少線程數或者更換64爲虛擬機的情況下,只能通過減少最大堆和減少棧容量來換取更多線程。如果沒有這方面經驗,這種通過“減少內存”的手段來解決內存溢出的方式會比較難以想到。

/**
 * @author zhuoli
 * VM Args : -Xss2M
 */
public class JVMStackOutOfMemory {

    private void dontStop() {
        while (true) {
            
        }
    }
    
    public void stackLeakByThread() {
        while (true) {
            Runnable runnable = this::dontStop;
            new Thread(runnable).start();
        }
    }

    public static void main(String[] args) {
        JVMStackOutOfMemory jvmStackOutOfMemory = new JVMStackOutOfMemory();
        jvmStackOutOfMemory.stackLeakByThread();
    }
}

上面這段代碼,可以實現虛擬機棧的OutOfMemoryError異常。

3.3 運行時常量池溢出

向運行時常量池中添加內容,最簡單的做飯就是使用String.intern()這個方法。該方法的作用是:在運行時,檢查常量池中是否存在等於此String對象的字符串,如果存在,直接返回常量池中這個字符串String對象的引用。否則,將此String對象包含的字符串添加到常量池中,並且返回此String對象的引用。由於Java6及之前的版本,常量池分配在方法區內,我們可以通過-XX:PermSize和-XX:MaxPermSize限制方法區的大小,從而間接限制其中常量池的容量,如下所示:

/**
 * @author zhuoli
 * VM Args : -XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        //使用List保持對常量池的引用,避免Full GC回收常量池行爲
        List<String> list = new ArrayList<>();
        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)

從運行結果中可以看到,運行時常量池溢出,在OutOfMemoryError後面跟隨的提示信息”PermGen space”,可以證明在Java6中,運行時常量池屬於方法區的一部分。但是在Java8中運行,首先會提示上述兩個JVM參數已經被移除:

Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=10M; support was removed in 8.0
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=10M; support was removed in 8.0

其次,上述代碼也不會拋出OutOfMemoryError異常,while循環可以一直運行下去(前提是堆空間足夠)。

關於字符串常量池實現,由於常量池的轉移,在Java6及之後的版本,實現也不完全相同,看一段代碼:

public static void main(String[] args) {

    String s1 = new StringBuilder("1").append("2").toString();
    System.out.println(s1.intern() == s1);

    String s2 = new StringBuilder("1").append("2").toString();
    System.out.println(s2.intern() == s2);
}

這段代碼在Java6中運行,會得到兩個false,而在Java7及之後的版本中運行,會得到一個true和一個false。產生差異的原因是:在Java6中,intern()方法會把首次遇到的字符串實例複製到方法區的常量池中,返回的也是永久代中這個字符串實例的引用,而由StringBuilder創建的字符串實例在Java堆上,所以肯定不是同一個引用,將返回false。而Java7及之後的版本,intern()實現不會再複製實例,只是在常量池中記錄首次出現的實例引用,因此intern()返回的引用和由StringBuilder創建的那個字符串實例是同一個,intern()返回的是該string對象第一次出現的位置(在Heap中)。所以,s1通過StringBuilder的toString()方法在堆中創建了一個字符串對象”12″,並且是首次出現,所以s1.intern()返回的就是堆中的字符串對象”12″的引用,第一個判斷返回true。s2也通過StringBuilder的toString()方法在堆中創建了一個字符串對象”12″,但是s2.intern()返回的卻是字符串”12″第一次出現的位置,所以肯定跟s2不相等,返回false。

在Java7及之後的版本中,運行時常量池已經轉移到堆中,所以一般不會出現運行時常量池的溢出。

3.4 方法區溢出

方法區用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯後的代碼等數據。對於這個區域溢出的測試,基本思路是在運行時產生大量的類區填滿方法區,直到溢出。在下述實驗代碼中,藉助CGLIB動態代理技術,在運行時動態地生成並加載類。

特別值得注意的是:我們這個例子中模擬的場景並非單純是一個實驗,這樣的應用通常會出現在實際引用中:當前很多主流框架,比如Spring對類進行增強時,都會使用到CGLIB這類字節碼技術,增強類越多,就需要越大的方法區來保證動態生成的Class可以加載到內存。另外由於在Java7 HotSpot虛擬機開始移除永久代,並在Java8中使用元空間代替。所以JVM參數中,方法區的參數已失效,以下代碼在Java8中實驗。

/**
 * @author zhuoli
 * -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M
 */
public class JavaMethodAreaOOM {
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) -> proxy.invokeSuper(obj, args1));
            enhancer.create();
        }
    }

    static class OOMObject {

    }
}

運行結果:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
	at java.lang.Class.forName0(Native Method)
	at java.lang.Class.forName(Class.java:348)

方法區溢出也是一種常見的內存溢出異常,一個類要被垃圾收集器回收掉,判定條件是比較苛刻的。在經常動態生成大量Class的應用中,需要特別注意類的回收狀況。這類場景除了上面提到的程序使用了CGLib字節碼增強和動態語言之外,常見的還有:大量JSP或動態產生JSP文件的應用(JSP第一次運行時需要編譯爲Java類)、基於OSGi的應用(即使是同一個類文件,被不同的加載器加載也會視爲不同的類)等。

參考鏈接:

1. 《深入理解Java虛擬機》

2. JVM內存結構

3. Java 內存之方法區和運行時常量池

發佈了117 篇原創文章 · 獲贊 33 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章