JVM 內存模型概述

 我們都知道,Java程序在執行前首先會被編譯成字節碼文件,然後再由Java虛擬機執行這些字節碼文件從而使得Java程序得以執行。事實上,在程序執行過程中,內存的使用和管理一直是值得關注的問題。Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分爲若干個不同的數據區域,這些數據區域都有各自的用途,以及創建和銷燬的時間,並且它們可以分爲兩種類型:線程共享的方法區和堆,線程私有的虛擬機棧、本地方法棧和程序計數器。在此基礎上,我們探討了在虛擬機中對象的創建和對象的訪問定位等問題,並分析了Java虛擬機規範中異常產生的情況。
本文內容是基於 JDK 1.6 的,不同版本虛擬機之間也許會有些許差異,但不影響我們對JVM 內存模型的整體把握和了解。

一. Java 虛擬機內存模型

Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分爲若干個不同的數據區域,這些數據區域可以分爲兩個部分:一部分是線程共享的,一部分則是線程私有的。其中,線程共享的數據區包括方法區和堆,線程私有的數據區包括虛擬機棧、本地方法棧和程序計數器。如下圖所示:


 

1、線程私有的數據區

  線程私有的數據區 包括 程序計數器 虛擬機棧 和 本地方法棧 三個區域,它們的內涵分別如下:

1)、程序計數器

  我們知道,線程是CPU調度的基本單位。在多線程情況下,當線程數超過CPU數量或CPU內核數量時,線程之間就要根據 時間片輪詢搶奪CPU時間資源。也就是說,在任何一個確定的時刻,一個處理器都只會執行一條線程中的指令。因此,爲了線程切換後能夠恢復到正確的執行位置,每條線程都需要一個獨立的程序計數器去記錄其正在執行的字節碼指令地址。

  因此,程序計數器是線程私有的一塊較小的內存空間,其可以看做是當前線程所執行的字節碼的行號指示器。如果線程正在執行的是一個 Java 方法,計數器記錄的是正在執行的字節碼指令的地址;如果正在執行的是 Native 方法,則計數器的值爲空。

  程序計數器是唯一一個沒有規定任何 OutOfMemoryError 的區域。

2)、虛擬機棧

  虛擬機棧描述的是Java方法執行的內存模型,是線程私有的。每個方法在執行的時候都會創建一個棧幀,用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息,而且 每個方法從調用直至完成的過程,對應一個棧幀在虛擬機棧中入棧到出棧的過程。其中,局部變量表主要存放一些基本類型的變量(int, short, long, byte, float, double, boolean, char)和 對象句柄,它們可以是方法參數,也可以是方法的局部變量。

  虛擬機棧有兩種異常情況:StackOverflowError 和 OutOfMemoryError。我們知道,一個線程擁有一個自己的棧,這個棧的大小決定了方法調用的可達深度(遞歸多少層次,或嵌套調用多少層其他方法,-Xss 參數可以設置虛擬機棧大小),若線程請求的棧深度大於虛擬機允許的深度,則拋出 StackOverFlowError 異常。此外,棧的大小可以是固定的,也可以是動態擴展的,若虛擬機棧可以動態擴展(大多數虛擬機都可以),但擴展時無法申請到足夠的內存(比如沒有足夠的內存爲一個新創建的線程分配棧空間時),則拋出 OutofMemoryError 異常。下圖爲棧幀結構圖:

3)、本地方法棧

  本地方法棧與Java虛擬機棧非常相似,也是線程私有的,區別是虛擬機棧爲虛擬機執行 Java 方法服務,而本地方法棧爲虛擬機執行 Native 方法服務。與虛擬機棧一樣,本地方法棧區域也會拋出 StackOverflowError 和 OutOfMemoryError 異常。


 2、線程共享的數據區

  線程共享的數據區 具體包括 Java堆 和 方法區 兩個區域,它們的內涵分別如下

1)、Java 堆

  Java 堆的唯一目的就是存放對象實例,幾乎所有的對象實例(和數組)都在這裏分配內存。Java堆是線程共享的,類的對象從中分配空間,這些對象通過new、newarray、 anewarray 和 multianewarray 等指令建立,它們不需要程序代碼來顯式的釋放。

  由於Java堆唯一目的就是用來存放對象實例,因此其也是垃圾收集器管理的主要區域,故也稱爲稱爲 GC堆。從內存回收的角度看,由於現在的垃圾收集器基本都採用分代收集算法,所以爲了方便垃圾回收Java堆還可以分爲 新生代老年代 。新生代用於存放剛創建的對象以及年輕的對象,如果對象一直沒有被回收,生存得足夠長,對象就會被移入老年代。新生代又可進一步細分爲 eden、survivorSpace0 和 survivorSpace1。剛創建的對象都放入 eden,s0 (也有叫to)和 s1(from) 都至少經過一次GC並倖存。如果倖存對象經過一定時間仍存在,則進入老年代。下圖給出了Java堆的結構圖:

注意,Java堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可。而且,Java堆在實現時,既可以是固定大小的,也可以是可拓展的,並且主流虛擬機都是按可擴展來實現的(通過-Xmx(最大堆容量) 和 -Xms(最小堆容量)控制)。如果在堆中沒有內存完成實例分配,並且堆也無法再拓展時,將會拋出 OutOfMemoryError 異常。

(1)、TLAB (Thread Local Allocation Buffer,線程私有分配緩衝區)

  Sun Hotspot JVM 爲了提升對象內存分配的效率,對於所創建的線程都會分配一塊獨立的空間 TLAB,其大小由JVM根據運行的情況計算而得。在TLAB上分配對象時不需要加鎖(相對於CAS配上失敗重試方式 ),因此JVM在給線程的對象分配內存時會盡量的在TLAB上分配,在這種情況下JVM中分配對象內存的性能和C基本是一樣高效的,但如果對象過大的話則仍然是直接使用堆空間分配。

  在下文中我們提到,虛擬機爲新生對象分配內存時,需要考慮修改指針 (該指針用於劃分內存使用空間和空閒空間) 時的線程安全問題,因爲存在可能出現正在給對象A分配內存,指針還未修改,對象B又同時使用原來的指針分配內存的情況。TLAB 的存在就是爲了解決這個問題:每個線程在Java堆中預先分配一小塊內存 TLAB,哪個線程需要分配內存就在自己的TLAB上進行分配,若TLAB用完並分配新的TLAB時,再加同步鎖定,這樣就大大提升了對象內存分配的效率。


2)、方法區

  方法區與Java堆一樣,也是線程共享的並且不需要連續的內存,其用於存儲已被虛擬機加載的 類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。方法區通常和永久區(Perm)關聯在一起,但永久代與方法區不是一個概念,只是有的虛擬機用永久代來實現方法區,這樣就可以用永久代GC來管理方法區,省去專門內存管理的工作。根據Java虛擬機規範的規定,當方法區無法滿足內存分配的需求時,將拋出 OutOfMemoryError 異常。

(1)、運行時常量池

  運行時常量池(Runtime Constant Pool)是方法區的一部分,用於存放編譯期生成的各種 字面量符號引用其中,字面量比較接近Java語言層次的常量概念,如文本字符串、被聲明爲final的常量值等;而符號引用則屬於編譯原理方面的概念,包括以下三類常量:類和接口的全限定名、字段的名稱和描述符 和 方法的名稱和描述符。因爲運行時常量池(Runtime Constant Pool)是方法區的一部分,那麼當常量池無法再申請到內存時也會拋出 OutOfMemoryError 異常。

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


3)、Java堆 與 方法區的區別

  Java堆是 Java代碼可及的內存,是留給開發人員使用的;而非堆(Non-Heap)是JVM留給自己用的,所以方法區、JVM內部處理或優化所需的內存 (如JIT編譯後的代碼緩存)、每個類結構 (如運行時常量池、字段和方法數據)以及方法和構造方法的代碼都在非堆內存中。


4)、方法區的回收

  方法區的內存回收目標主要是針對 常量池的回收對類型的卸載。回收廢棄常量與回收Java堆中的對象非常類似。以常量池中字面量的回收爲例,假如一個字符串“abc”已經進入了常量池中,但是當前系統沒有任何一個String對象是叫做“abc”的,換句話說是沒有任何String對象引用常量池中的“abc”常量,也沒有其他地方引用了這個字面量,如果在這時候發生內存回收,而且必要的話,這個“abc”常量就會被系統“請”出常量池。常量池中的其他類(接口)、方法、字段的符號引用也與此類似。

  判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面3個條件才能算是“無用的類”:

  • 該類所有的實例都已經被回收,也就是Java堆中不存在該類的任何實例;
  • 加載該類的ClassLoader已經被回收;
  • 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

  虛擬機可以對滿足上述3個條件的無用類進行回收(卸載),這裏說的僅僅是“可以”,而不是和對象一樣,不使用了就必然會回收。特別地,在大量使用反射、動態代理、CGLib等bytecode框架的場景,以及動態生成JSP和OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。


 

二. Java對象在虛擬機中的創建與訪問定位

  Java是一門面向對象的編程語言,在Java程序運行過程中無時無刻都有對象被創建和使用。在此,我們以最流行的HotSpot虛擬機以及常用的內存區域Java堆爲例來探討在虛擬機中對象的創建和對象的訪問等問題。

1、對象在虛擬機中的創建過程

  (1). 檢查虛擬機是否加載了所要new的類,若沒加載,則首先執行相應的類加載過程。虛擬機遇到new指令時,首先去檢查這個指令的參數是否能在常量池中定位到一個類的符號引用,並且檢查這個引用代表的類是否已經被加載、解析和初始化過。

    (2). 在類加載檢查通過後,對象所需內存的大小在類加載完成後便可完全確定,虛擬機就會爲新生對象分配內存。一般來說,根據Java堆中內存是否絕對規整,內存的分配有兩種方式:

  • 指針碰撞:如果Java堆中內存絕對規整,所有用過的內存放在一邊,空閒內存放在另一邊,中間一個指針作爲分界點的指示器,那分配內存就僅僅是把那個指針向空閒空間那邊挪動一段與對象大小相同的距離。
  • 空閒列表:如果Java堆中內存並不規整,那麼虛擬機就需要維護一個列表,記錄哪些內存塊是可用的,以便在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄。

 除了如何劃分可用空間之外,還需要考慮修改指針 (該指針用於劃分內存使用空間和空閒空間)時的線程安全問題,因爲存在可能出現正在給對象A分配內存,指針還未修改,對象B又同時使用原來的指針分配內存的情況。解決這個問題有兩種方案:

  •  對分配內存空間的動作進行同步處理:採用CAS+失敗重試的方式保證更新操作的原子性;
  • 把內存分配的動作按照線程劃分的不同的空間中:每個線程在Java堆中預先分配一小塊內存,稱爲本地線程分配緩衝(TLAB),哪個線程要分配內存,就在自己的TLAB上分配,如果TLAB用完並分配新的TLAB時,再加同步鎖定。

  (3). 內存分配完成後,虛擬機需要將分配到的內存空間都初始化爲零值。如果使用TLAB,也可以提前到TLAB分配時進行。這一步操作保證了對象的實例字段在Java代碼中可以不賦初值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。

  (4). 在上面的工作完成之後,從虛擬機的角度來看,一個新的對象已經產生了,但從Java程序的視角來看,對象的創建纔剛剛開始,此時會執行<init>方法把對象按照程序員的意願進行初始化,從而產生一個真正可用的對象。


 

2、對象在虛擬機中的訪問定位

 

  創建對象是爲了使用對象,我們的Java程序通過棧上的reference數據來操作堆上的具體對象。在虛擬機規範中,reference類型中只規定了一個指向對象的引用,並沒有定義這個引用使用什麼方式去定位、訪問堆中的對象的具體位置。目前的主流的訪問方式有使用句柄訪問和直接指針訪問兩種。

  • 句柄訪問:Java堆中會劃分出一塊內存作爲句柄池,棧中的reference指向對象的句柄地址,句柄中包含了對象實例數據和類型數據各自的具體地址信息,如下圖所示。

 

 

  • 直接指針訪問:reference中存儲的就是對象地址。

 

 總的來說,這兩種對象訪問定位方式各有千秋。使用句柄訪問的最大好處就是reference中存儲的是穩定的句柄地址,對象被移動(垃圾收集時移動對象是非常普遍的行爲)時只會改變句柄中的實例數據指針,reference本身不需要修改;而使用直接指針訪問的最大好處就是速度快,節省了一次指針定位的時間開銷。

三. 內存異常產生情況分析

1、Java堆溢出 (OOM)

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

public class Test {

public static void main(String[] args){
        List list=new ArrayList();   // 持有“大對象”的引用,防止垃圾回收
        while(true){
            int[] tmp = new int[10000000];  // 不斷創建“大對象”
            list.add(tmp);
        }
    }
}

要解決這個異常,一般先通過內存映像分析工具對堆轉儲快照分析,確定內存的對象是否是必要的,即判斷是 內存泄露 還是 內存溢出。如果是內存泄露,可以進一步通過工具查看泄露對象到GC Roots的引用鏈,比較準確地定位出泄露代碼的位置。如果是內存溢出,可以調大虛擬機堆參數,或者從代碼上檢查是否存在某些對象生命週期過長的情況。

2、虛擬機棧和本地方法棧溢出 (SOF/OOM)

(1). SOF

  如果線程請求的棧深度大於虛擬機棧允許的最大深度,將拋出StackOverflowError異常。我們知道,每當Java程序啓動一個新的線程時,Java虛擬機會爲它分配一個棧,並且Java虛擬機棧以棧幀爲單位保持線程運行狀態。每當線程調用一個方法時,JVM就壓入一個新的棧幀到這個線程的棧中,只要這個方法還沒返回,這個棧幀就存在。 那麼可以想象,如果方法的嵌套調用層次太多,比如遞歸調用,隨着Java虛擬機棧中的棧幀的不斷增多,最終很可能會導致這個線程的棧中的所有棧幀的大小的總和大於-Xss設置的值,從而產生StackOverflowError溢出異常。看下面的栗子:

public class Test {

    public static void main(String[] args) {
          method();
    }

    // 遞歸調用導致 StackOverflowError
    public static void method(){
        method();
    }
}

上面的SOF異常就是由遞歸引起的,具體而言就是因爲method()方法中沒有遞歸終止條件,從而使得該方法不斷遞歸調用、不斷創建棧幀導致的。


 

(2). OOM

  如果虛擬機在拓展棧時無法申請到足夠的內存空間,則拋出OutOfMemoryError異常。在虛擬機棧和本地方法棧發生OOM異常場景如下:當Java 程序啓動一個新線程時,若沒有足夠的空間爲該線程分配Java棧(一個線程Java棧的大小由-Xss設置決定),JVM將拋出OutOfMemoryError異常。

 


 

3、方法區和運行時常量池溢出 (OOM)

  運行時常量池溢出的情況: String.intern()是一個native方法,在JDK1.6及之前的版本中,它的作用是:如果字符串常量池中已經包含一個等於此String對象的字符串,則返回代表池中這個字符串的String對象,否則將此String對象包含的字符串添加到常量池中,並且返回此String對象的引用。由於常量池分配在永久代中,如果不斷地使用intern方法手動入池字符串,則會拋出OutOfMemoryError異常。但在JDK1.7及其以後的版本中,對intern()方法的實現作了進一步改進,其不會再複製實例到常量池中,而僅僅是在常量池中記錄首次出現的實例的引用。看下面的例子(在JDK1.7中運行),

public class Test {  
    public static void main(String[] args) {  

        String str1 = new StringBuilder("計算機").append("軟件").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("java").toString();
        System.out.println(str2.intern() == str2);
    }/* Output: 
        true
        false
     *///:~  

爲什麼第一個返回true,而第二個返回false呢?因爲在JDK1.7中,intern()方法的實現不會再複製實例,只是在常量池中記錄 首次 出現的實例的引用,因此str1.intern()和str1指向的是同一個字符串,所以返回true。同一個引用。對於“java”這個字符串,由於在執行StringBuilder.toString() 之前已經出現過,所以字符串常量池中在new StringBuilder(“java”).toString()之前已經有它的引用了,不符合首次出現的原則,因此返回fasle。有人可能心裏可能就要嘀咕了,爲啥第二個不符合首次出現的原則,而第一個就符合首次出現的原則呢? 實際上,

String str2 = new StringBuilder("java").toString();

等價於:

String s1 = "java";
StringBuilder sb = new StringBuilder(s1);
String str2 = sb.toString();

// StringBuilder 的 toString()方法
public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
}

由上面代碼可知,字符串”java”早就出現了,因此不符合首次出現的原則,返回false。同理,“計算機軟件”這個字符串在new StringBuilder(“計算機”).append(“軟件”).toString()之前從未出現過,因此符合首次出現的原則,返回true。

方法區溢出的情況:一個類要被垃圾回收器回收掉,判斷條件是比較苛刻的。 在經常動態產生大量Class的應用中,需要特別注意類的回收狀況,比如動態語言、大量JSP或者動態產生JSP文件的應用(JSP第一次運行時需要編譯爲Java類)、基於OSGi的應用(即使是同一個類文件,被不同的加載器加載也會視爲不同的類)等。
轉載博客:https://blog.csdn.net/justloveyou_/article/details/71189093

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章