深入理解java虛擬機-第二章:java內存區域與內存泄露異常

2.1概述:

java將內存的管理(主要是回收工作),交由jvm管理,確實很省事,但是一點jvm因內存出現問題,排查起來將會很困難,爲了能夠成爲獨當一面的大牛呢,自然要了解vm是怎麼去使用內存的。

2.2運行時的數據區域

  vm會將管理的內存劃分爲不同的區域,不同的區域間有各自的用途,以及創建和銷燬時間。具體的區域劃分如下圖:

  注:執行引擎跟本地庫接口不是內存數據區,方法區跟堆內存纔是共享的內存數據區

2.2.1程序計數器

  是一塊較小的內存地址,可以認爲是當前線程所執行的字節碼的行號指示器。在概念模式中(不同的虛擬機可以選擇自己的實現方式),字節碼解釋器工作時,通過改變這個計數器的值來選擇下一條執行的字節碼命令。分支、循環、跳轉、異常處理、線程恢復等基礎功能都是依賴這個計數器完成的。

  在JVM中多線程是通過線程輪流切換並分配處理器執行時間實現的,即在同一個時刻,一個處理器只會執行一個線程的命令,所以爲了線程切換能夠回到正確的執行位置,每條線程都要有獨立的計數器。

  補充:

  如果線程執行的是java方法,那麼計數器記錄的是字節碼指令的地址,如果是Native方法,計數器則爲空(Undefined),該區域在jvm規範中也沒有OOM。

2.2.2java虛擬機棧

  是線程私有的,生命週期與線程相同。

  虛擬機棧描述的是方法執行的內存模型,方法在執行的時候會創建棧幀(Stack Frame),用於存儲局部變量表,操作數棧、動態鏈接、方法出口等信息。方法從調用到執行完成,對應一個棧幀從入棧到出棧的過程。

  局部變量表中存放的是:編譯期可知的各種基本數據類型(八個基本數據類型),對象引用(可能是指向對象地址的引用指針,也可能是執行代表對象的句柄)和returnAddress類型(指向了一條字節碼指令的地址)。

  long與double會佔據兩個局部變量空間(slot),其他佔據一個,局部變量表所需內存大小編譯期間就已經完成分配, 方法運行期間不會改變局部變量表的大小。

  jvm規範中,對棧規定了兩個異常狀態,線程請求的棧深度大於虛擬機所允許的深度會拋出StackOverflowError異常。

  虛擬機棧可以動態擴展來避免棧溢出,但是當擴展無法申請到足夠的內存時,就會拋出OutofMemoryError異常。

2.2.3本地方法棧

  功能作用與虛擬機棧是非常一致的,區別就在於:java虛擬機棧爲執行java方法服務,本地方法棧爲虛擬機使用的Native方法服務。虛擬機規範並沒有對本地方法棧做硬性要求。

  HotSpot直接把本地方法棧跟虛擬機棧合二爲一,本地方法棧也會拋出兩個異常。棧溢出與內存溢出。

2.2.4java堆

  java堆(java Heap)內存是vm管理的最大的內存。

  java堆被所有線程共享的內存區域。該內存區域存在的目的是存放對象實例。

  規範中:所有的對象實例以及數組都是要求在堆上進行分配,但是隨着JIT編譯器的發展與逃逸分析技術,出現了棧上分配和標量替換,這會導致有一些微妙的變化。

  java堆是垃圾收集器的主要管理區域,也成爲GC堆(Garbage Collected Heap),收集器都選擇分代收集算法。

  java堆可以細分爲:新生代和老年代:再細緻點有Eden空間、From Survivor空間、 To Survivor空間等。

  從內存分配的角度看,java堆內是可以劃分出多個線程私有的分配緩衝區的(Thread Local Allocation Buffer TLAB)。

  劃分的詳細的目的在於方便更好地回收內存。

  補充:java堆可以不處於物理上的連續內存,只要邏輯上連續就可以了,當堆無法繼續擴展時,也會拋出OutOfMemoryError。

2.2.5方法區:

  方法區(Method Area)跟java堆一樣,是線程共享的內存區域,用於儲存VM加載的類信息,常量,靜態變量,即時編譯器編譯後的代碼等,還有一個別名(Non-Heap)非堆

  對於HotSpot來說,方法區也可稱爲永久代(Permanent Generation),本質上兩者並不等價,僅僅是因爲HotSpot虛擬機把分帶收集器擴展到了方法區(用永久代來實現方法區)。

  用永久代實現方法區會容易導致內存溢出問題(永久代有-XX:MaxPermSize的上限)。在jdk1.7中,已經把原來放在永久代的字符串常量池移出永久代了。

  VM規範對於方法區來說,也可以不選擇連續的物理內存,還可以選擇固定大小或者可擴展,甚至你還可以選擇不實現垃圾回收。

  針對方法區的回收主要是針對常量池的回收和對類型的卸載,當方法區無法滿足內存分配的時候,就會出現OutOfMemoryError異常。

2.2.6運行時常量池

  運行時常量池(Runtime Constant Pool)是方法區的一部分。

  class文件除了有類的版本、字段、方法、接口等描述信息外,還有一項信息是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類加載後進入方法區的運行時常量池中。

  VM規範沒有對運行時常量池的細節規範,一般情況下除了class的符號引用外,還會把直接引用也存在運行時常量池。

  運行時常量池相對於Class文件的常量池另一個重要特徵是動態性,運行期間也可以放入新的常量進入常量池。比較多的用法是String類的intern()。

  作爲方法區的一部分,當然也會OutofMemoryError異常

2.2.7直接內存

  直接內存(Direct Memory)並不是VM運行時數據區的一部分,也不是VM規範中定義的內存區域,但是如果該區域被頻繁使用,也會導致OutOfMemoryError異常。

  NIO,引入了基於通道(Channel)與緩衝區(Buffer)的I/O方式,可以直接使用Native函數分配堆外內存,然後通過在堆中的DirectByteBuffer對象作爲該內存的引用進行操作。

  雖然該內存不受堆的限制,但是也可能受物理內存的限制,所以也可能因爲設置的參數問題,導致動態擴展時出現OutOfMemoryError異常。

簡單總結:講了各個內存區域的一些實現細節跟部分VM規範,除了程序計數器外其他所有的內存區域都存在內存溢出異常,甚至於非jvm的內存區域直接內存,也有可能出現內存溢出異常。


2.3HotSpot虛擬機的對象探祕

 2.3.1對象的創建

  創建對象是通過關鍵字new來創建的。

  虛擬機收到new命令時,會去常量池中檢查是否有對應參數的類的符號引用,並檢查這個符號引用是否已經被加載、解析和初始化過,如果沒有的話,那麼要先進行類加載。

  類加載的檢查完成後,就要對新生對象進行內存分配了,分配方式有兩種,根據堆內存是否規整可以分爲兩類:指針碰撞(Bump the Pointer)、空閒列表(Free List)

  指針碰撞:堆內存規整,分配內存的過程僅僅是將指針向空閒空間挪動一段與對象大小一致的距離。

  空閒列表:如果內存不規整,那麼已使用的內存與空閒內存交互,虛擬機會維護一個記錄表,記錄內存是否可用,在分配時從列表中找足夠內存劃分給實例,更新記錄表。

  堆是否規整又跟垃圾收集器有關,使用Serial、ParNew等帶Compact過程的收集器時,採用指針碰撞;使用CMS這種基於Mark-Sweep算法的收集器時,採用空閒列表的方式。

  除了分配內存外,還需要考慮在併發下的安全問題,虛擬機採用了CAS配上失敗重試的方式保證更新操作的原子性;另一種方式是把內存分配的動作按照線程劃分在不同的空間中,即每個線程在java堆中預先分配一個內存,稱爲本地線程分配緩衝(Thread Local Allocation Buffer,TLAB),哪個線程需要分配內存,就在哪個線程的TLAB分配,只有TLAB用完需要進行新增時,才進行同步鎖定操作。虛擬機是否使用TLAB,可以通過配置:-XX:+/-UseTLAB參數來設定。

  內存分配完成,VM還需要將分配的內存空間都初始化爲零值(對象頭除外),如果使用TLAB的話,那麼該過程也會提前至TLAB時進行,這一步操作保證了對象實例字段在java代碼中可以不賦初始值就直接使用,程序可以直接訪問到這些字段數據類型的對應值。

  完成初始化工作後,VM要設置對象的對象頭,相關信息:對象是哪個類的實例,如何找到類的元數據信息,對象的哈希值,對象的GC分帶年齡等信息。

  完成以上步驟後,VM的視角,新的對象已經產生了。但是java代碼角度,對象創建纔剛開始,<init>方法沒有執行,所有字段都還是0,執行完new指令後執行<init>方法後纔算對象創建完畢。

  簡單描述一下VM視角與程序視角下對象的創建流程:

    VM  ->  類是否初始化  內存分配  內存空間初始化  對象頭賦值

    java程序  ->  類是否初始化  內存分配  內存空間初始化  對象頭賦值  <init>方法

2.3.2對象的內存佈局

  對象在內存中的佈局分爲三個區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)

  HotSpot的對象頭包含兩部分信息,第一部分:存儲對象自身的運行時數據、第二部分類型指針。

  存儲運行時數據有:哈希嗎,GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等,根據虛擬機位數不同分別爲32/64bit,稱爲Mark Word,實際上是一個可動態的數據結構,以便以小空間存儲更多的信息。

  類型指針:即對象指向它的類元數據的指針,VM通過指針確定對象屬於哪個類。

  注:如果對象是一個數組,那麼對象頭中還必須有一塊用於記錄數組長度的數據。

  實例數據:對象真正存儲的有效信息。

  父類定義在前,子類災後,存儲的順序還受VM分配策略參數(FieldsAllocationStyle)和java源碼中定義順序影響。

  HotSpot的順序是:從長到短,且字段相同的放在一起。

  第三部分對齊填充不是必然存在的,僅僅是佔位符的作用。由於HotSpot的VM自動內存管理系統要求對象起始地址必須是8字節的整數倍,那麼對象就必須是8字節的整數倍了,因爲對象頭部分是8字節的倍數,所有當實例數據沒有對齊時,對齊填充就用來補齊。

2.3.3對象的訪問定位

  虛擬機規範只規定了一個指向對象的引用,但是沒有規定具體的方式。所以還是根據虛擬機的具體實現來表述對對象的訪問。常規的是兩種句柄式與直接指針式:

  如果是採取:句柄訪問的話,那麼java堆會劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了實例數據地址跟數據類型地址

  如果採用直接指針訪問,那麼java堆對象的佈局就必須考慮如何放置訪問類型數據的相關信息,reference中存儲的是對象地址。

兩種對象的訪問方式各有優劣,句柄的好處時,reference中存儲的是穩定的句柄地址,對象被移動時,只改變句柄中的實例數據指針,而reference不改變。

  直接指針訪問最大的好處是速度快,節省了一次指針定位的時間開銷。

   HotSpot採用的就是直接指針訪問的方式。


2.4實戰OOM異常

  本節的目的:1、通過代碼驗證java虛擬機規範中描述的各個運行時區域存儲的內容。

        2、幫助判斷實際工作中是什麼問題導致哪些區域內存溢出,什麼原因導致該區域內存溢出,出現問題該怎麼辦。

  使用如下jvm參數:

-verbose:gc -Xms20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

2.4.1java堆溢出

  堆中存放對象實例,只要不停創造對象,且保證GC Roots到對象之間有可達路徑來避免垃圾回收機制清楚對象(將在垃圾回收機制處講),那麼對象達到閾值後自然會產生內存溢出。

  使用參數:-XX:+HeapDumpOnOutOfMemoryError可讓虛擬機在出現內存溢出時Dump當前的內存堆轉儲快照,便於事後分析。

示例代碼:

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

    }
}

  解決這個區域的異常,一般通過內存映像分析工具,對Dump出來的堆轉儲快照進行分析,重點是確認內存中的對象是否是必要的,也就是確認是內存泄漏(Memory Leak)還是內存溢出(Memory Overflow)。

  注:上面這句也簡潔直觀地表達了內存泄漏與內存溢出的區別。

  內存泄漏:可以通過工具看泄漏對象到GC Roots的引用鏈,可以找到泄漏對象時通過怎樣的路徑與GC Roots相關聯並導致垃圾回收器無法自動回收它們。只要掌握了泄漏對象的類型信息及GC Roots引用連的信息,就可以準確地定位出泄漏代碼的位置。

  內存溢出:如果對象都必須存活,那麼虛擬機的堆參數(-Xmx與-Xms)與機器物理內存對比看是否能夠調大;從代碼角度看,是否有對象的生命週期過長、持有狀態時間過長,以期減少程序運行期間內存的消耗。

2.4.2虛擬機棧以及本地方法棧溢出

  HotSpot中不區分虛擬機棧與本地方法棧,所有-Xoss實際無效,只設置-xss即可。

  對於虛擬機棧和本地方法棧來說,會出現兩種異常:

    線程請求的棧深度大於虛擬機所允許的最大深度,即拋出:StackOverflowError。

    虛擬機擴展棧時,無法申請到足夠的內存空間,即拋出:OutOfMemoryError

  其實,當棧空間無法繼續分配時,到底是內存太小,還是已用棧空間太大,本質都是同一件事情的兩種描述。

  註明:在單線程條件下,無論是棧幀過大還是虛擬機容量太小,都會拋出異常StackOverflowError。

  在多線程條件下,通過不斷創建線程的方式是會產生內存溢出的,但是產生內存溢出與棧空間是否足夠大無關,在這種情況下,爲每個線程分配的內存越大,越容易棧溢出(總量一定,單次消耗越大,越容易滿)。

2.4.3方法區和運行時常量池溢出

  String.intern()是一個Native方法,它的作用是:如果字符串常量池中已經包含了一個等價次string對象的字符串,返回常量池中的該對象,否則將string對象包含的字符串添加到常量池中,返回此string對象的引用。

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