《深入理解Java虛擬機》之Java內存區域與內存溢出異常

閱讀《深入理解Java虛擬機》第2版,結合JDK8的讀書筆記。當前文章爲書本的第2章節。

2.1.概述

從概念上介紹Java虛擬機內存的各個區域,講解這些區域的作用,服務對象以及其中可能產生的問題。

2.2.運行時數據區域

Java虛擬機在執行Java程序的過程中會把它所管理的內存劃分爲若干個不同的數據區域。有:程序計數器,虛擬機棧,本地方法棧,堆,方法區。爲了提升性能針對內存區域,分爲線程共享區域(堆和方法區)和線程隔離區域(程序計數器,虛擬機棧和本地方法棧)。

2.2.1.程序計數器

程序計算器(Program Counter Register)是一塊較小的內存空間,可以看作是當前線程所執行的字節碼的行號指示器。

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

如果線程正在執行的是一個Java方法,計數器記錄的是正在執行的虛擬機字節碼指令的地址;如果在執行的是Native方法,這個計數器值則爲空(Undefined)。此內存區域也是Java虛擬機中唯一一個沒有規定任何OutOfMemoryError情況的區域。

2.2.2.虛擬機棧

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

在Java虛擬機規範中,對虛擬機棧規定了兩種異常狀況:1).如果線程請求的棧深度大於虛擬機所允許的深度,則拋出StackOverflowError異常;2).如果虛擬機棧可以的動態擴展,但擴展時無法申請到足夠的內存,則會拋出OutOfMemoryError異常。

2.2.3.本地方法棧

本地方法棧(Native Method Stack)和虛擬機棧發揮的作用相似,虛擬機棧爲虛擬機執行Java方法(也就是字節碼)服務,而本地方法棧是爲虛擬機使用到Native方法服務。

在虛擬機規範中沒有強制規定本地方法棧中方法使用的語言,使用方式與數據結構並沒有強制規定,可以自由實現。甚至有的虛擬機直接把本地方法棧和虛擬機棧合二爲一。

2.2.4.堆

Java堆(Heap)是虛擬機中內存最大的一塊。Java堆是被所有線程共享的一塊內存區域,在虛擬機啓動時創建。

Java虛擬機規範中的描述是:所有的對象實例以及數組都要在堆上分配,但是隨着JIT編譯器的發展與逃逸分析技術逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化發生,所有的對象都分配在堆上也漸漸變得不是那麼“絕對”了。

逃逸分析(Escape Analysis)就是分析對象動態作用域,當一個對象在方法中被定義後,它可能被外部方法所引用(例如作爲調用參數傳遞到其他方法中),稱爲方法逃逸。甚至還有可能被外部線程訪問到(例如賦值給類變量),稱爲線程逃逸。

棧上分配(Stack Allocation)是依據逃逸分析技術得出的優化手段。如果能夠確定一個對象不會逃逸出方法之外,那讓這個對象在棧上分配內存將會是一個很不錯的主意,對象所佔用的內存空間就可以隨棧幀出棧而銷燬。在一般應用中,不會逃逸的對象佔比很大,如果能使用棧上分配,大量的對象會隨着方法的結束而自動銷燬,垃圾收集系統的壓力將會小很多。

標量替換(Scalar Replacement)是依據逃逸分析技術得出的優化手段。標量是指一個數據已經無法再分解成更小的數據來表示了。例如Java虛擬機的原始數據類型(int,long等數值類型以及reference類型等)都不能再進一步分解,可以稱爲標量。如果逃逸分析證明一個對象不會被外部訪問,並且這個對象可以被拆散,那程序真正執行的時候可能不創建對象,而改爲直接創建它的若干個被這個方法使用到的成員變量來代替。將對象拆分之後,除了可以讓對象的成員變量在棧上分配(棧上存儲的數據,有很大的概率會被分配到物理機器的告訴寄存器中存儲)和讀寫之外,還可以爲後續進一步的優化手段創建條件。

從內存回收的角度來看,現在收集器基本都採用分代收集算法,所以堆還可以細分爲:新生代和老年代,其中新生代還分爲Eden空間、From Survivor空間、To Survivor空間。

Java堆可以處於物理上不連續的內存空間中,只要邏輯上是連續的即可。在實現時,既可以實現成固定大小,也可以是可擴展的。當前主流的虛擬機都是按照可擴展實現的(通過-Xmx和-Xms控制)。

如果在堆中沒有內存完成實例分配,並且堆也無法再擴展時,將會拋出OutOfMemoryError異常。

2.2.5.方法區

方法區(Method Area)與堆一樣,是各個線程共享的內存區域,用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。

JDK8的虛擬機HotSpot針對方法區的實現由“永久代(Permanent Generation)”改爲“元空間(Metaspace)”,使用元空間實現的方法區,只要內存沒有觸碰到進程可用的內存上限,就不存在內存溢出的問題。

Java虛擬機規範對方法區的限制非常寬鬆,除了和Java堆一樣不需要連續的內存,還可以選擇不實現垃圾回收。不過這部分區域的回收還是有必要的,主要針對常量池的回收和對類型的卸載。

2.2.6.運行時常量池

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

運行時常量池相對於Class文件常量池的另外一個重要特徵時具備動態性,也就是運行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的便是String類的intern()方法。

2.2.7.直接內存

不屬於虛擬機內存中的一部分,在JDK1.4以後加入了NIO(New Input/Output)類,引入基於通道(Channel)與緩存區(Buffer)的I/O方式,它可以使用Native函數庫直接分配對外內存,然後通過一個存儲在Java堆中的DirectByteBuffer對象作爲這塊內存的引用進行操作。這樣能在一些場景中顯著提高性能,因爲避免了在Java堆和Native堆中來回複製數據。

2.3.HotSpot虛擬機對象密探

2.3.1.對象的創建

以下爲普通Java對象的創建過程,也就是當虛擬機遇到new指令時的操作:

  1. 檢查這個指令的參數是否能在常量池中定位到一個類的符號引用
  2. 如果有,則檢查這個符號引用代表的類是否被加載,解析和初始化(執行方法)過
  3. 如果沒有,則必須先執行相應的類加載過程
  4. 在類加載檢查通過後,需要爲新生對象分配內存。需要考慮內存劃分方法和內存分配動作
  • 內存劃分方法

對象所需內存的大小在類加載完成後便可完全確定,爲對象分配空間的任務等同於把一塊確定大小的內存從Java堆中劃分出來。內存劃分的方式有兩種:指針碰撞和空閒列表。選擇哪種分配方法由Java堆是否規整決定的,而Java堆是否規整由採用的垃圾收集器是否帶有壓縮整理功能決定。

指針碰撞(Bump the Pointer):假設Java堆中內存是絕對規整的,已使用的內存一邊,空閒的內存在另一邊,中間放着一個指針作爲分界點的指示器,分配內存就是把指針向空閒空間那邊挪動一段與對象大小相等的距離。

空閒列表(Free List):假設Java堆中內存並不規整,已使用的內存和空閒的內存是相互交錯的,虛擬機就必須維護一個列表,記錄哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,並更新列表上的記錄。

  • 內存分配動作

對象創建在虛擬機中是非常頻繁的行爲,即使是修改一個指針所指向的位置,在併發情況下也並不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況。解決方案有兩種:CAS加失敗重試和本地線程分配緩衝。虛擬機是否使用本地線程分配緩衝,可以通過-XX:+/-UseTLAB參數來設定。

CAS加失敗重試方案:CAS(Compare and Swap),即比較交換,一種無鎖算法。CAS有3個操作數,內存值V,舊的預期值A,要修改的新值B,當且僅當A和V相同時,將V修改爲B,否則認爲是失敗,失敗之後再重試保證更新操作的原子性。

本地線程分配緩衝(Thread Local Allocation Buffer, 簡稱TLAB):把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊內存,稱爲本地線程分配緩衝,哪個線程要分配內存,就在哪個線程的TLAB上分配,只有TLAB用完並分配新的TLAB時,才需要同步鎖定。

  1. 內存分配完之後,虛擬機需要將分配到內存空間都初始化零值(不包含對象頭)。該步驟保證類變量可以不賦值就直接使用。
  2. 虛擬機要對對象進行必要的設置。在對象的對象頭(Object Header)中存放對象是哪個類的實例,如何找到類的元數據信息,對象的哈希碼,對象的GC分代年齡等信息
  3. 執行方法

方法和方法的區別:是instance實例構造器,對非靜態變量解析初始化,而是class類構造器對靜態變量,靜態代碼塊進行初始化。Stack Overflow上的解釋:init is the (or one of the) constructor(s) for the instance, and non-static field initialization. clinit are the static initialization blocks for the class, and static field initialization.

// 引用HankingHu的CSDN博客-深入理解jvm--Java中init和clinit區別完全解析

class X {

   static Log log = LogFactory.getLog(); // <clinit>

   private int x = 1;   // <init>

   X(){
      // <init>
   }

   static {
      // <clinit>
   }

}

2.3.2.對象的內存佈局

在HotSpot虛擬機中,對象內存佈局分爲對象頭(Header),實例數據(Instance Data)和對齊填充(Padding)。

  • 對象頭

包含兩部分信息,一部分爲對象自身的運行時數據,如HashCode,GC分代年齡,鎖狀態標識,線程持有的鎖,偏向線程ID,偏向時間戳等,這些數據的長度爲32bit和64bit(32位系統爲32bit,64位系統爲64bit),官方稱爲Mark Word。

Mark Word會根據對象處於不同狀態來存儲不同的數據,以下以64bit介紹存放的內容

Mark Word (64bit,其中的數字指長度) 鎖狀態
unused:25 + hashcode:31 + unused:1 + age:4 + biased_lock:1 + lock:2 正常無鎖(01)
thread:54 + epoch:2 + unused:1 + age:4 + biased_lock:1 + lock:2 偏向鎖(01)
ptr_to_lock_record:62 + lock:2 輕量級鎖(00)
ptr_to_heavyweight_monitor:62 + lock:2 重量級鎖(10)
lock:2 GC標記(11)

其中:hashcode爲對象哈希碼,age爲對象年齡,lock爲鎖狀態標記位,biased_lock爲是否啓用偏向鎖標記(1爲啓用,0爲未啓用),thread爲線程ID,epoch爲偏向時間戳,ptr_to_lock_record爲輕量級鎖狀態下指向棧中鎖記錄的指針,ptr_to_heavyweight_monitor爲重量級鎖狀態下指向對象監視器Monitor的指針。

以下內容來自CSDN:阿珍愛上了阿強?-Java對象結構與鎖實現原理及MarkWord詳解


我們通常說的通過synchronized實現的同步鎖,真實名稱叫做重量級鎖。但是重量級鎖會造成線程排隊(串行執行),且會使CPU在用戶態和核心態之間頻繁切換,所以代價高、效率低。爲了提高效率,不會一開始就使用重量級鎖,JVM在內部會根據需要,按如下步驟進行鎖的升級:

  1. 初期鎖對象剛創建時,還沒有任何線程來競爭,對象處於無鎖狀態(無線程競爭它)
  2. 當有一個線程來競爭鎖時,先用偏向鎖,表示鎖對象偏愛這個線程,這個線程要執行這個鎖關聯的任何代碼,不需要再做任何檢查和切換,這種競爭不激烈的情況下,效率非常高。
  3. 當有兩個線程開始競爭這個鎖對象,情況發生變化了,不再是偏向(獨佔)鎖了,鎖會升級爲輕量級鎖,兩個線程公平競爭,哪個線程先佔有鎖對象並執行代碼,鎖對象的Mark Word就執行哪個線程的棧幀中的鎖記錄。
  4. 如果競爭的這個鎖對象的線程更多,導致了更多的切換和等待,JVM會把該鎖對象的鎖升級爲重量級鎖,這個就叫做同步鎖,這個鎖對象Mark Word再次發生變化,會指向一個監視器對象,這個監視器對象用集合的形式,來登記和管理排隊的線程。

另一部分爲類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。如果對象是一個Java數組,需要有一塊用於記錄數組長度的數據,不然無法確定數組的大小。

並不是所有的虛擬機實現都必須在對象數據上保留類型指針。

  • 實例數據

程序代碼中所定義的各種類型的字段內容。無論是父類繼承下來,還是子類中定義的,都需要記錄起來。

這部分的存儲順序會受虛擬機分配策略參數(FieldsAllocationStyle)和字段在Java源碼中定義順序影響。HotSpot虛擬機默認分配策略:longs/doubles,ints,short/chars,bytes/booleans,oop(Ordinary-Object-Pointers),也就是相同寬度的字段被分配到一起。

在滿足這個前提下,父類的變量會出現在子類之前。CompactFields參數值如果爲true(默認爲true),子類中較窄的變量也可能會插入父類變量的空隙之中。

  • 對齊填充

不是必然存在,僅僅起佔位符的作用。因爲HotSpot VM的內存管理系統要求對象起始地址必須是8字節的整數倍,也就是對象的大小必須是8字節的整數倍。因此當對象實例數據部分沒有對齊時,需要通過對齊填充來補全。

2.3.3.對象的訪問定位

Java程序需要通過棧(虛擬機棧)上的reference類型來操作堆上的具體對象。Java虛擬機規範中只規定了reference類型是一個指向對象的引用,並沒有定義這個引用應該通過何種方式去定位、訪問堆中的對象的具體位置。目前主流的方式有使用句柄和直接指針兩種。

  • 使用句柄

在Java堆中劃分出一塊內存來作爲句柄池,reference中存儲的就是對象的句柄地址,句柄中包含了對象實例數據與類型數據各自的具體信息。

通過句柄訪問對象

圖片拍攝於周志明老師的《深入理解Java虛擬機 第2版》

  • 直接指針

reference中存儲的是Java堆中的對象實例地址,Java堆中的對象實例必須存放類型數據的相關信息(一般是存放在對象頭中,參考2.3.2章節)。

通過直接指針訪問對象

圖片拍攝於周志明老師的《深入理解Java虛擬機 第2版》

2.4.實戰:OutOfMemoryError異常

本節主要是模擬各種內存溢出的情況。下列代碼內容中涉及VM參數,都通過IDEA設置,如下圖:

IDEA設置VM參數

2.4.1.Java堆溢出

內存泄漏(Memory-Leak)和內存溢出(Memory-Overflow)都會導致堆溢出。

  • 內存泄露

程序在申請內存後,無法釋放已申請的內存空間,長期佔用內存最終內存泄露導致堆溢出。

  • 內存溢出

程序在申請內存時,沒有足夠的內存空間供其使用,導致堆溢出。

public class HeapOOM {

    static class OObject {
    }

    public static void main(String[] args) {
        // VM Args:-Xmx20m -Xms20m -XX:HeapDumpOnOutOfMemoryError
        // 參數說明:-Xmx爲堆最大內存,-Xms爲堆最小內存
        // -XX:HeapDumpOnOutOfMemoryError當內存溢出時dump出當前的內存堆轉儲快照,便以分析
        List<OObject> list = new ArrayList<>();
        while (true) {
            list.add(new OObject());
        }
    }
}

// 執行結果如下

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid19368.hprof ...
Heap dump file created [28126471 bytes in 0.093 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 HeapOOM.main(HeapOOM.java:19)

將導出的快照文件通過JDK自帶工具jvisualvm來分析,可以知道是因爲太多的實例導致的內存溢出。如果快照文件太大,可以嘗試使用JProfile(專業分析工具)來分析。

jvisualvm分析快照文件

2.4.2.虛擬機棧和本地方法棧溢出

HotSpot虛擬機不區分虛擬機棧和本地方法棧,所以棧容量由-Xss參數設置。在Java虛擬機規範中描述了兩種異常:

  • StackOverflowError

線程請求的棧深度大於虛擬機所允許的最大深度。

  • OutOfMemoryError

虛擬機在擴展棧時無法申請到足夠的內存空間

public class StackOF {

    private int stackLength = 1;

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

    public static void main(String[] args) {
        // VM Args:-Xss128k
        StackOF stackOF = new StackOF();
        try {
            stackOF.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length: " + stackOF.stackLength);
            throw e;
        }
    }
}

// 異常如下:

stack length: 994
Exception in thread "main" java.lang.StackOverflowError
...... 後續異常信息省略

在單線程下,無論是由於棧幀太大還是虛擬機棧容量太小,當內存無法分配時,拋出的都是StackOverflowError異常。如果測試時不限於單線程,可以通過不斷地建立線程來讓其內存溢出,拋出OutOfMemoryError異常。

例如32位的windows系統給一個進程的內存是2G,減去最大堆容量(Xmx),程序計數器因爲很小可以忽略,剩下的內存由虛擬機棧和本地方法棧“瓜分”。每個線程分配的棧容量越大(可以通過-Xss來控制棧容量的分配),可以建立的線程數就越少。

我的是Window10x64位系統,貌似對進程分配的內存就是系統內存,所以沒有具體模擬OutOfMemoryError異常。

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

運行時常量池是方法區的一部分。JDK8版本的HotSpot虛擬機關於方法區的實現方式爲Metaspace。

關於元空間的JVM參數有兩個:-XX:MetaspaceSize=N和-XX:MaxMetaspaceSize=N,MetaspaceSize是指初次Full GC的內存大小,MaxMetaspaceSize是元空間允許最大的內存大小。下列案例會分析


import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

import java.lang.reflect.Method;

/**
 * @author guoyu.huang
 * @version 1.0.0
 */
public class JavaMethodAreaOOM {

    static class OOMObject{}

    public static void main(String[] args) throws InterruptedException {
        // 藉助CGLib在運行時產生大量的類去填滿方法區,直到溢出
        // VM args :-Xmx20m -Xms20m -XX:MetaspaceSize=12M -XX:MaxMetaspaceSize=20M -XX:+PrintGCDetails
        while(true){
            Thread.sleep(10);
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, args);
                }
            });
            enhancer.create();
        }
    }
}


// 異常如下:
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

// 打印GC日誌如下:
......

[GC (Metadata GC Threshold) [PSYoungGen: 2992K->224K(6144K)] 8427K->5766K(19968K), 0.0007869 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Metadata GC Threshold) [PSYoungGen: 224K->0K(6144K)] [ParOldGen: 5542K->3235K(13824K)] 5766K->3235K(19968K), [Metaspace: 11069K->11069K(1060864K)], 0.0261145 secs] [Times: user=0.11 sys=0.00, real=0.03 secs] 
[GC (Allocation Failure) [PSYoungGen: 5632K->224K(6144K)] 8867K->3459K(19968K), 0.0009194 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

......

Heap
 PSYoungGen      total 6144K, used 114K [0x00000000ff980000, 0x0000000100000000, 0x0000000100000000)
  eden space 5632K, 2% used [0x00000000ff980000,0x00000000ff99cbf0,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 13824K, used 3010K [0x00000000fec00000, 0x00000000ff980000, 0x00000000ff980000)
  object space 13824K, 21% used [0x00000000fec00000,0x00000000feef0ad8,0x00000000ff980000)
 Metaspace       used 19266K, capacity 20374K, committed 20480K, reserved 1069056K
  class space    used 1545K, capacity 1609K, committed 1664K, reserved 1048576K

通過GC日誌可以得出以下結論:

  1. 設置堆最大內存20M,元空間最大內存20M,GC日誌顯示堆內存分配了20M,元空間內存使用了20M,可知堆內存和元空間內存分開
  2. VM參數MetaspaceSize設置的值是第一次Full GC時的內存值,也就是當元空間內存使用達到這值時會發生Full GC
  3. VM參數MaxMetaspaceSize設置的值是元空間的最大內存值

2.4.4.本機內存直接溢出

DirectMemory容量可以通過-XX:MaxDirectMemorySize指定,如果沒有指定,則默認與Java堆最大值(-Xmx)一樣。

由DirectMemory導致的內存溢出,一個明顯的特徵是在Heap Dump文件中不會看見明顯的異常,如果讀者發現OOM之後Dump文件很小,而程序中又直接或間接使用了NIO,那就可以考慮檢查是不是這方面的原因。

2.5.本章小結

  1. Java虛擬機在執行Java程序時,會把內存分爲程序計算器,虛擬機棧,本地方法棧,堆,方法區,運行時常量池,直接內存。
  2. HotSpot虛擬機實現探祕,對象的創建,對象的內存佈局,對象的訪問定位。
  3. 實戰演練各種內存溢出。

關注我

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