JVM-記憶體管理

牛覓發表於2018-01-17

對於Java程式設計師來說,在虛擬機器自動記憶體管理機制幫助下,不需要為每一個new操作去寫配對的delete/free程式碼,不容易出現記憶體洩漏和溢位方面的問題。一旦出現記憶體洩漏和溢位問題,如果不瞭解虛擬機器怎樣使用記憶體的,那麼排查錯誤將會成為一項異常艱難的工作。

執行時資料區域

Java虛擬機器在執行Java程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域。根據《Java虛擬機器規範(Java SE 7版)》的規定,Java虛擬機器所管理的記憶體將會包括以下幾個執行時資料區域,如圖所示:

執行時資料區域

程式計數器

程式計數器(Program Counter Register)是一塊較小的記憶體區域,可以把它看作是當前執行緒所執行的位元組碼的行號的指示器。在虛擬機器概念模型中,位元組碼直譯器工作時就是通過改變程式計數器的值來選取下一條需要執行的位元組碼指令,分支,迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要依賴這個計數器來完成。

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

如果執行緒正在執行的是一個Java方法,計數器記錄的是正在執行的虛擬機器位元組碼指令的地址;如果正在執行的是Native方法,計數器值則為空。此記憶體區域是唯一一個在Java虛擬機器規範中沒有規定任何OutOfMemeryError情況的區域。

Java虛擬機器棧

Java虛擬機器棧(Java Virtual Machine Stacks)也是執行緒私有的,它的生命週期與執行緒相同。虛擬機器棧描述的是Java方法執行的記憶體模型;每個方法在執行的同時都會建立一個棧幀(Stack Frame)用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。每一個方法從呼叫直到執行完成的過程,就對應著一個棧幀在虛擬機器棧中入棧到出棧的過程。

在java虛擬機器規範中,對這個區域規定了兩種異常情況狀況:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常;如果虛擬機器可以動態擴充套件(大部分Java虛擬機器都可動態擴充套件,同時也允許固定長度的虛擬機器棧),如果擴充套件時無法申請到足夠的記憶體,就會丟擲OutOfMemoryError異常。

本地方法棧

本地方法棧是為虛擬機器執行Native方法服務。在虛擬機器規範中對本地方法棧使用的語言、使用方式與資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。

Sun HotSpot虛擬機器將本地方法棧和虛擬機器棧合二為一。與虛擬機器棧一樣,本地方法棧也會丟擲StackOverflowError和OutOfMemoryError異常。

Java堆

對於大多數應用來說,Java堆(Java Heap)是Java虛擬機器所管理的記憶體中最大的一塊。Java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立,幾乎所有的物件例項和陣列都在這裡分配。Java堆是垃圾收集器管理的主要區域,因此很多時候也稱做“GC堆”。

根據Java虛擬機器規範的規定,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上連續的即可。在實現時,即可實現成固定大小的,也可以是可擴充套件的,不過當前主流的虛擬機器都是按照擴充套件來實現的(通過-Xmx和-Xms控制)。如果在堆中沒有記憶體完成例項分配,並且堆也無法擴充套件時,將會丟擲OutOfMemoryError異常。

方法區

方法區(Method Area)是各個執行緒共享的記憶體區域,它用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼等資料。雖然Java虛擬機器規範把方法區描述為堆的一個邏輯部分,但它卻有一個別名叫做“Non-Heap(非堆)”,目的應該是與Java堆區分開來。

Java虛擬機器規範對方法區的限制非常寬鬆,除了和Java堆一樣不需要連續的記憶體和可以選擇固定大小或者可擴充套件外,還可以選擇不實現垃圾收集。這區域的記憶體回收目標主要針對常量池的回收和對型別解除安裝,一般來說,這個區域的回收“成績”比較難以令人滿意,尤其是型別的解除安裝,條件相當苛刻,但是這部分割槽域的回收確實是必要的。

根據Java虛擬機器規範的規定,當方法區無法滿足記憶體分配需求時,將丟擲OutOfMemoryError異常。

執行時常量池

執行時常量池(Runtime Constant Pool)是方法區的一部分。Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有一項資訊是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後進入方法區的執行常量池中存放。

執行時常量池是具備動態性,Java語言並不要求常量一定只有編譯器才能產生,也就是並非預置入Class檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,這種特性被開發人員利用比較多是String類的intern()方法。

直接記憶體

直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是Java虛擬機器規範中定義的記憶體區域,但是這部分記憶體也被頻繁地使用,而且也可能導致OutOfMemoryError異常。

在JDK 1.4中新加入了NIO類,引入了一種基於通道(Channel)與快取區(Buffer)的I/O方式,它可以使用Native函式庫直接分配堆外記憶體,然後一個儲存在Java堆中的DirectByteBuffer物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在Java堆和Native堆中來回複製資料。

顯然,本機直接記憶體的分配不會受到Java堆大小的限制,但是,既然是記憶體,肯定還是會受到本機總記憶體大小以及處理器定址空間的限制。

如果各個記憶體區域總和大於實體記憶體限制,從而導致動態擴充套件時出現OutOfMemoryError異常。

HotSpot虛擬機器如何處理物件分配、佈局和訪問

物件分配

虛擬機器遇到一條new指令時,首先將去檢查這個指令的引數是否能在子常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過。如果沒有,那必須先執行相應的類載入過程。在類載入檢查通過後,虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定,為物件分配空間的任務等同於把一塊確定大小的記憶體從Java堆中劃分出來。

物件記憶體分配方式:

1、指標碰撞

假設Java堆中記憶體是絕對規整的,所有用過的記憶體都放在一邊,空閒的記憶體放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離。

2、空閒列表

如果Java堆中記憶體並不是規整的,已使用的記憶體和空閒的記憶體相互交錯,虛擬機器就必須維護一個列表,記錄上那些記憶體塊是可用的,在分配的時候從列表中找到一個足夠大的空間劃分給物件例項,並更新列表上的記錄。

選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。因此,在使用Serial、ParNew等帶Compact過程的收集器時,系統採用的分配演算法是指標碰撞;而使用CMS這種基於Mark-Sweep演算法的收集器時,通常採用空閒列表。

分配過程中如何解決執行緒安全

在併發情況下可能出現正在給物件A分配記憶體,指標還沒來得及修改,物件B又同時使用了原來的指標來分配記憶體的情況。虛擬機器通過以下兩種方式解決執行緒安全問題:

1、對分配記憶體空間的動作進行同步處理——實際上虛擬機器採用CAS配上失敗重試的方式保證更新操作的原子性;

2、把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在Java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝(Thread Local Allocation Buffer,TLAB)。那個執行緒要分配記憶體,就在哪個執行緒的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定。虛擬機器是否使用TLAB,可以通過-XX:+/-UseTLAB引數來設定。

記憶體分配完成後,虛擬機器需要將分配到記憶體空間都初始化為零值(不包括物件頭),如果使用TLAB,這一工作過程也可以提前至TLAB分配時進行。這一步操作保證了物件的例項欄位在Java程式碼中可以不賦初始值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。

接下來,虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的後設資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊存放在物件的物件頭(Object Header)之中。根據虛擬機器當前的執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式。

在上面工作都完成之後,從虛擬機器的視覺來看,一個新的物件已經產生了,但從Java程式的檢視來看,物件建立才剛剛開始——init方法還沒有執行,所有的欄位都還為零。一般類說(由位元組碼中是否隨invokespecial指令所決定),執行new指令之後會接著執行init方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全產生出來。

物件的記憶體佈局

在HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以分為3塊區域:物件頭(Header)、例項資料(Instance Data) 和對齊填充(Padding)。

物件頭

HotSpot虛擬機器的物件頭包括兩部分資訊,用於儲存物件自身的執行時資料和型別指標。

1、儲存物件自身的執行時資料

如雜湊碼、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,這部分資料的長度在32位和64位的虛擬機器(未開啟壓縮指標)中分別為32bit和64bit,官方稱為“Mark Word”。物件需要儲存的執行時資料很多,其實已經超出了32位和64位Bitmap結構所能記錄的限度,但是物件頭資訊是與物件自身定義的資料無關的額外儲存成本,考慮到虛擬機器的空間效率,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體儲存儘量多的資訊,它會根據物件的狀態複用自己的儲存空間。

2、型別指標

即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。並不是所有的虛擬機器實現都必須在物件資料上保留型別指標,換句話說,查詢物件的後設資料資訊並不一定要經過物件本身。

例項資料

例項資料是物件真正儲存的有效資訊,也是在程式程式碼中所定義的各種型別的欄位內容。無論是從父類繼承下來的,還是在子類中定義的,都需要記錄起來 。這部分的儲存順序會受到虛擬機器分配策略引數和欄位在Java原始碼中定義順序的影響。

對其填充

對其填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說,就是物件的大小必須是8位元組的整數倍。由於物件頭部分正好是8位元組的整數倍,因此,當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。

物件的訪問定位

建立物件是為了使用物件,Java程式需要通過棧上的reference資料來操作堆上的具體物件。由於reference型別在Java虛擬機器規範中規定了一個指向物件的引用,並沒有定義這個引用應該通過何種方式去定位、訪問堆中物件的具體位置,所以物件訪問方式也是取決於虛擬機器實現而定的。

目前主流的訪問方式有使用控制程式碼和直接指標兩種。如下:

1、控制程式碼訪問

Java堆中會劃分出一塊記憶體來作為控制程式碼池,reference中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與型別資料各自的具體地址資訊。

控制程式碼訪問

2、直接指標訪問

Java堆物件的佈局中就必須考慮如何放置型別資料的相關資訊,而reference中儲存的直接就是物件地址。

直接指標訪問

使用控制程式碼訪問的最大好處就是reference中儲存的是穩定的控制程式碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的)時只會改變控制程式碼的例項資料指標,而reference本身不需要修改。

使用直接指標訪問的最大好處就是速度更快,節省了一次指標定位的時間開銷,由於物件的訪問在Java中非常頻繁,因此也是一項非常客觀的執行成本。Sun HotSpot虛擬機器採用直接指標進行物件訪問的。

OutOfMemoryError異常

在Java虛擬機器規範的描述中,除了程式計數器外,虛擬機器記憶體的其他幾個執行時區域都發生OutOfMemoryError異常的可能。

Java堆溢位

Java堆用於儲存物件例項,只要不斷地建立物件,並且保證GC Roots到物件之間有可達路徑來避免垃圾回收機制清除這些物件,那麼在物件數量到達最大堆的容量限制後就會產生記憶體溢位異常。

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

    static class OOMObject{}


    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<OOMObject>();

        while(true){
            list.add(new OOMObject());
        }
    }
}
複製程式碼

執行結果:

OutOfMemoryError異常

要解決這個區域的異常,一般的手段是先通過記憶體映像分析工具對Dump出來的堆轉儲快照進行分析,重點是確認記憶體中的物件是否是必要的,也就是要先分清楚到底是出現了記憶體洩漏還是記憶體溢位。如果是記憶體洩漏,可進一步通過工具檢視洩漏物件到GC Roots的引用鏈。於是就能找到洩漏物件是通過怎樣的路徑與GC Roots相關聯並導致垃圾收集器無法自動回收它們的。如果不存在洩漏,就是記憶體中的物件確實都還必須存活著,那就應當檢查虛擬機器的堆引數,與機器實體記憶體對比看是否還可以調大,從程式碼上檢查是否存在某些物件生命週期過長、持有狀態時間過長的情況,嘗試減少程式執行期的記憶體消耗。

虛擬機器棧和本地方法棧溢位

由於在HotSpot虛擬機器中並不區分虛擬機器棧和本地方法棧,因此,對於HotSpot來說,雖然-Xoss引數(設定本地方法棧大小)存在,但實際上是無效的,棧容器只由-Xss引數設定。

/**
 * VM Args;-Xss160k
 */
public class JavaVMStackSOF {

    private int stackLength = 1;

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

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        oom.stackLeak();
    }

}
複製程式碼

方法區和執行時常量池溢位

String.intern()是一個Native方法,它的作用是:如果字串常量池中已經包含一個等於此String物件的字串,則返回代表池中這個字串的String物件;否則,將此String物件包含的字串新增到常量池中,並且返回此String物件的引用。在JDK 1.6及之前的版本中,由於常量池分配在永久代內,我們可以通過-XX:PermSize和-XX:MaxPermSize限制方法區大小,從而間接限制其中常量池的容量。

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class RuntimeConstantPoolOOM {


    public static void main(String[] args) {

        List<String> list = new ArrayList<String>();

        int i = 0;
        while (true){
            list.add(String.valueOf(i).intern());
        }
    }
}
複製程式碼

在JDK 1.8中執行出現如下提示,並沒有出現記憶體溢位:

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

網上搜尋資料找到如下一段內容,說明其原因:

類的後設資料, 字串池, 類的靜態變數將會從永久代移除, 放入Java heap或者native memory. 其中建議JVM的實現中將類的後設資料放入native memory, 將字串池和類的靜態變數放入java堆中. 這樣可以載入多少類的後設資料就不在由MaxPermSize控制, 而由系統的實際可用空間來控制。

根據上述的描述,可以推測在JDK 1.8中測試方法區異常也是不會出現。

歡迎留言補充,共同交流。個人微信公眾號求關注

JVM-記憶體管理

相關文章