1. 程式人生 > >JVM 記憶體模型概述(轉載)

JVM 記憶體模型概述(轉載)

摘要:   我們都知道,Java程式在執行前首先會被編譯成位元組碼檔案,然後再由Java虛擬機器執行這些位元組碼檔案從而使得Java程式得以執行。事實上,在程式執行過程中,記憶體的使用和管理一直是值得關注的問題。Java虛擬機器在執行Java程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域,這些資料區域都有各自的用途,以及建立和銷燬的時間,並且它們可以分為兩種型別:執行緒共享的方法區和堆,執行緒私有的虛擬機器棧、本地方法棧和程式計數器。在此基礎上,我們探討了在虛擬機器中物件的建立和物件的訪問定位等問題,並分析了Java虛擬機器規範中異常產生的情況。

友情提示:   本文內容是基於 JDK 1.6 的,不同版本虛擬機器之間也許會有些許差異,但不影響我們對JVM 記憶體模型的整體把握和了解。   關於JVM垃圾回收機制的更多內容,請移步我的博文

《 Java 垃圾回收機制概述》。

一. Java 虛擬機器記憶體模型

  Java虛擬機器在執行Java程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域,這些資料區域可以分為兩個部分:一部分是執行緒共享的,一部分則是執行緒私有的。其中,執行緒共享的資料區包括方法區和堆,執行緒私有的資料區包括虛擬機器棧、本地方法棧和程式計數器。如下圖所示:   這裡寫圖片描述

  1. 執行緒私有的資料區 執行緒私有的資料區 包括 程式計數器、 虛擬機器棧 和 本地方法棧 三個區域,它們的內涵分別如下: 1)、程式計數器(Program Counter Register)   我們知道,執行緒是CPU排程的基本單位。在多執行緒情況下,當執行緒數超過CPU數量或CPU核心數量時,執行緒之間就要根據 時間片輪詢搶奪CPU時間資源。也就是說,在任何一個確定的時刻,一個處理器都只會執行一條執行緒中的指令。因此,為了執行緒切換後能夠恢復到正確的執行位置,每條執行緒都需要一個獨立的程式計數器去記錄其正在執行的位元組碼指令地址。   因此,程式計數器是執行緒私有的一塊較小的記憶體空間,其可以看做是當前執行緒所執行的位元組碼的行號指示器。如果執行緒正在執行的是一個 Java 方法,計數器記錄的是正在執行的位元組碼指令的地址;如果正在執行的是 Native 方法,則計數器的值為空。   程式計數器是唯一一個沒有規定任何 OutOfMemoryError 的區域。 2)、虛擬機器棧(VM Stack)   虛擬機器棧描述的是Java方法執行的記憶體模型,是執行緒私有的。每個方法在執行的時候都會建立一個棧幀,用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊,而且 每個方法從呼叫直至完成的過程,對應一個棧幀在虛擬機器棧中入棧到出棧的過程。其中,區域性變量表主要存放一些基本型別的變數(int, short, long, byte, float, double, boolean, char)和 物件控制代碼,它們可以是方法引數,也可以是方法的區域性變數。   虛擬機器棧有兩種異常情況:StackOverflowError 和 OutOfMemoryError。我們知道,一個執行緒擁有一個自己的棧,這個棧的大小決定了方法呼叫的可達深度(遞迴多少層次,或巢狀呼叫多少層其他方法,-Xss 引數可以設定虛擬機器棧大小),若執行緒請求的棧深度大於虛擬機器允許的深度,則丟擲 StackOverFlowError 異常。此外,棧的大小可以是固定的,也可以是動態擴充套件的,若虛擬機器棧可以動態擴充套件(大多數虛擬機器都可以),但擴充套件時無法申請到足夠的記憶體(比如沒有足夠的記憶體為一個新建立的執行緒分配棧空間時),則丟擲 OutofMemoryError 異常。下圖為棧幀結構圖:   這裡寫圖片描述

    3)、本地方法棧(Native Method Stack)   本地方法棧與Java虛擬機器棧(JVM Stack)非常相似,也是執行緒私有的,區別是虛擬機器棧為虛擬機器執行 Java 方法服務,而本地方法棧為虛擬機器執行 Native 方法服務。與虛擬機器棧一樣,本地方法棧區域也會丟擲 StackOverflowError 和 OutOfMemoryError 異常。

  2. 執行緒共享的資料區 執行緒共享的資料區 具體包括 Java堆 和 方法區 兩個區域,它們的內涵分別如下: 1)、Java 堆(Heap)   Java 堆的唯一目的就是存放物件例項,幾乎所有的物件例項(和陣列)都在這裡分配記憶體。Java堆是執行緒共享的,類的物件從中分配空間,這些物件通過new、newarray、 anewarray 和 multianewarray 等指令建立,它們不需要程式程式碼來顯式的釋放。   由於Java堆唯一目的就是用來存放物件例項,因此其也是垃圾收集器管理的主要區域,故也稱為稱為 GC堆。從記憶體回收的角度看,由於現在的垃圾收集器基本都採用分代收集演算法,所以為了方便垃圾回收Java堆還可以分為 新生代 和 老年代 。新生代用於存放剛建立的物件以及年輕的物件,如果物件一直沒有被回收,生存得足夠長,物件就會被移入老年代。新生代又可進一步細分為 eden、survivorSpace0 和 survivorSpace1。剛建立的物件都放入 eden,s0 和 s1 都至少經過一次GC並倖存。如果倖存物件經過一定時間仍存在,則進入老年代。更多關於Java堆和分代收集演算法的介紹,請移步我的博文

    《Java 垃圾回收機制概述》。下圖給出了Java堆的結構圖: 這裡寫圖片描述 注意,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可。而且,Java堆在實現時,既可以是固定大小的,也可以是可拓展的,並且主流虛擬機器都是按可擴充套件來實現的(通過-Xmx(最大堆容量) 和 -Xms(最小堆容量)控制)。如果在堆中沒有記憶體完成例項分配,並且堆也無法再拓展時,將會丟擲 OutOfMemoryError 異常。 ※執行緒私有分配緩衝區 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)、方法區(Method Area)   方法區與Java堆(Heap)一樣,也是執行緒共享的並且不需要連續的記憶體,其用於儲存已被虛擬機器載入的 類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。方法區通常和永久區(Perm)關聯在一起,但永久代與方法區不是一個概念,只是有的虛擬機器用永久代來實現方法區,這樣就可以用永久代GC來管理方法區,省去專門記憶體管理的工作。根據Java虛擬機器規範的規定,當方法區無法滿足記憶體分配的需求時,將丟擲 OutOfMemoryError 異常。 ※執行時常量池   執行時常量池(Runtime Constant Pool)是方法區的一部分,用於存放編譯期生成的各種 字面量 和 符號引用。其中,字面量比較接近Java語言層次的常量概念,如文字字串、被宣告為final的常量值等;而符號引用則屬於編譯原理方面的概念,包括以下三類常量:類和介面的全限定名、欄位的名稱和描述符 和 方法的名稱和描述符。因為執行時常量池(Runtime Constant Pool)是方法區的一部分,那麼當常量池無法再申請到記憶體時也會丟擲 OutOfMemoryError 異常。   執行時常量池相對於Class檔案常量池的一個重要特徵是具備動態性。Java語言並不要求常量一定只有編譯期才能產生,執行期間也可能將新的常量放入池中,比如字串的手動入池方法intern()。 3)、Java堆(Heap)與 方法區(Method Area)的區別   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程式的視角來看,物件的建立才剛剛開始,此時會執行方法把物件按照程式設計師的意願進行初始化,從而產生一個真正可用的物件。
  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。

  要想更徹底地瞭解本例項,建議移步我的博文 《Java String 綜述(上篇)》《Java String 綜述(下篇)》進行進一步瞭解。 方法區溢位的情況:一個類要被垃圾回收器回收掉,判斷條件是比較苛刻的。 在經常動態產生大量Class的應用中,需要特別注意類的回收狀況,比如動態語言、大量JSP或者動態產生JSP檔案的應用(JSP第一次執行時需要編譯為Java類)、基於OSGi的應用(即使是同一個類檔案,被不同的載入器載入也會視為不同的類)等。   更多關於JSP本質的全面介紹,請移步我的博文 《Java Web基礎 — Jsp 綜述(上)》《Java Web基礎 — Jsp 綜述(下)》

四. 更多

  更多關於Java 垃圾回收機制概述的知識,包括物件是否可以回收的判別演算法,典型的垃圾回收演算法的基本思想、經典垃圾收集器的介紹及記憶體分配規則等,請見我的博文《 Java 垃圾回收機制概述》

  更多關於 Java SE 進階 方面的內容,請關注我的專欄 《Java SE 進階之路》。本專欄主要研究 JVM基礎、Java原始碼和設計模式等Java進階知識,從初級到高階不斷總結、剖析各知識點的內在邏輯,貫穿、覆蓋整個Java知識面,在一步步完善、提高把自己的同時,把對Java的所學所思分享給大家。萬丈高樓平地起,基礎決定你的上限,讓我們攜手一起勇攀Java之巔…