Java運行時內存區域—閲讀深入理解Java虛擬機整理筆記

語言: CN / TW / HK

本文已參與「新人創作禮」活動,一起開啟掘金創作之路。

一、走進Java (快速瀏覽,先略過)

1.1 概述

Java不僅僅是一門編程語言,它還是一個由一系列計算機軟件和規範組成的技術體系,這個技術體系提供了完整的用於軟件開發和跨平台部署的支持環境,並廣泛應用於嵌入式系統。

1.2 Java優點

  • 擁有一門結構嚴謹、面向對象的編程語言。
  • 擺脱了硬件平台的束縛,實現了一次編寫,到處運行的理想。
  • 提供了相對安全的內存管理和訪問機制,避免了大部分的內存泄露和指針越界問題。
  • 實現了熱點代碼檢測和運行時編譯及優化,使得Java應用能隨着運行時間的增長而獲得更高的性能。
  • 擁有一套完善的應用程序接口,和無數來自商業機構和開源社區的第三方類庫來幫助用户實現各種各樣的功能
  • ...

1.3 Java技術體系

  • Java程序設計語言
  • 各種硬件平台上的Java虛擬機實現
  • Class文件格式
  • Java類庫API
  • 來自商業機構和開源社區的第三方Java類庫

JDK:

是Java程序設計語言、Java虛擬機、Java類庫的統稱,是用於支持Java程序開發的最小環境。

JRE:

是Java類庫API中JavaSE API子集和Java虛擬機這兩部分的統稱,JRE是支持Java程序運行的標準環境。

JVM:

Java虛擬機,Java能跨平台運行的重要支持。

Java按照技術關注的重點業務來劃分,可分為以下4條主要的產品線

Java Card

Java ME

Java SE

Java EE

1.4 Java發展史

1.5 Java虛擬機家族

  • 虛擬機始祖:Sun Classic/Exact VM,世界上第一款商用Java虛擬機
  • 武林盟主:HotSpot VM,Sun/OracleJDK 和OpenJDK中默認的Java虛擬機,也是目前使用最廣的Java虛擬機。
  • 小家碧玉:Mobile/Embedded VM,Sun/Oracle研發的專門面對移動和嵌入式市場的Java虛擬機
  • 天下第二:BEAJRockit/ IBM J9 VM
  • 軟硬聯合: BEA Liquid VM / Azul VM
  • 挑戰者:Apache Harmony/ Google Androd Dalvik VM
  • ...

1.6 展望Java技術的未來

  • 無語言傾向:Graal VM
  • 新一代即時編譯器
  • 向Native邁進
  • 靈活的胖子:模塊化
  • 語言語法持續增強

二、Java運行時內存區域

概述

對於Java程序員來説,在JVM自動內存管理機制的幫助下,不需要再為每一個new操作去寫配對的delete/free代碼,不容易出現內存泄漏和內存溢出的問題,不過,也正是因為Java程序員把控制內存的權力交給了JVM,一旦出現內存泄漏和溢出方面的問題,如果不瞭解虛擬機是怎樣使用內存的,那排查錯誤、修正問題就會成為一項異常艱難的工作。

在這裏插入圖片描述

Java 虛擬機在執行 Java 程序的過程中會把它管理的內存劃分成若干個不同的數據區域。JDK 1.8 和之前的版本略有不同,下面會介紹到。

JDK 1.8 之前:

在這裏插入圖片描述

JDK 1.8 :

在這裏插入圖片描述

2.1 程序計數器

概述:

是一塊較小的內存空間,可以看作當前線程所執行的字節碼的一個行號指示器,在Java虛擬機的概念模型裏面,字節碼解釋器工作的時候,就是通過改變程序計數器的值來選取下一條需要執行的字節碼指令,即字程序計數器裏面存的值是當前線程所需要執行的下一條指令的地址。

程序計數器是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等功能都需要依賴程序計數器來完成。

在JVM中多多線程的執行機制是通過線程輪流切換、分配處理器執行時間等方式即時間片輪轉的方式來實現的,在任何一個確定的時間,一個處理器都只執行一個線程中的指令,因此,為了線程切換後每個被中斷的線程能夠恢復到被中斷前的執行位置,每個線程都需要一個獨立的程序計數器來記錄線程信息,各條線程之間的計數器互不影響、獨立存儲。

作用:

1.是記住下一條jvm指令的執行地址,是程序控制流的指示器,分支、循環、跳轉、異常處理、線程恢復等功能都需要依賴程序計數器來完成。

2.在多線程的情況下,程序計數器用於記錄當前線程執行的位置,從而當線程被切換回來的時候能夠知道該線程上次運行到哪兒了。

特點:

1.線程私有:每個線程都有對應的程序計數器,互不影響。

2.是唯一一個在《Java虛擬機規範》中沒有規定任何OutOfMemoryError情況的區域,即不會有內存溢出的情況。它的生命週期隨着線程的創建而創建,隨着線程的死亡而死亡。

2.2 Java虛擬機棧

概述

  1. Java虛擬機棧,線程私有,生命週期與線程相同。
  2. 描述的是Java方法執行的線程內存模型:每個方法被執行的時候,Java虛擬機都會同步創建一個棧幀 (Stack Frame)用於存儲局部變量表、操作數棧、動態連接、方法出口等信息。
  3. 每一個方法被調用直至執行完畢,就對應着一個棧幀在虛擬機棧中的出棧入棧的過程。 在這裏插入圖片描述

  4. 局部變量表主要存放了編譯期可知的各種數據類型(boolean、byte、char、short、int、float、long、double)、對象引用(reference 類型,它不同於對象本身,可能是一個指向對象起始地址的引用指針,也可能是指向一個代表對象的句柄或其他與此對象相關的位置)。 在這裏插入圖片描述

  5. Java虛擬機棧會出現兩種錯誤: StackOverFlowErrorOutOfMemoryError

StackOverFlowError:如果線程請求的棧深度大於虛擬機所允許的棧深度,就拋出此異常。

OutOfMemoryError: 如果Java虛擬機棧可以動態擴展,當擴展的時候無法申請到足夠的內存就會報該異常。

在這裏插入圖片描述

特點

1.線程私有,生命週期與線程相同,隨着線程的創建而創建,隨着線程的死亡而死亡。

2.存在這TtackOverflowError 和OutOfMemoryError兩種異常。

3.局部變量表隨着棧幀的創建而創建,它的大小在編譯時確定,創建時只需分配事先規定的大小即可。在方法運行過程中,局部變量表的大小不會發生改變。

4.一個棧有多個棧幀。活動棧幀只有一個,對應着當前正在執行的方法。

IDEA演示棧幀

```java package jvm.t1;

/* * 演示棧幀 / public class Demo1_1 { public static void main(String[] args) { method1(); }

private static void method1(){
    method2(1,2);
}

private static int method2(int a,int b){
    int c = a+b;
    return c;
}

} ```

使用Debug運行上述代碼,可以在IDEA中模擬棧幀,如下圖

在這裏插入圖片描述

擴展

  1. 那麼方法/函數如何調用?

Java 棧可以類比數據結構中棧,Java 棧中保存的主要內容是棧幀,每一次函數調用都會有一個對應的棧幀被壓入 Java 棧,每一個函數調用結束後,都會有一個棧幀被彈出。

Java 方法有兩種返回方式:

1.return 語句。

2.拋出異常。

不管哪種返回方式都會導致棧幀被彈出。

  1. 垃圾回收是否涉及棧內存?

不涉及,堆是線程運行需要的空間,隨線程的產生而產生,隨線程消亡而消亡,是線程私有的內存,不需要垃圾回收機制來回收,垃圾回收機制只回收堆中的內存。

  1. 棧內存越大越好嗎?

並不是越大越好,棧是線程私有的,物理內存大小固定,如果給棧劃分內存過大,導致線程數量變少。

給棧內存指定大小: -Xss size -Xss 1m -Xss 1024k -Xss 1048576

  1. 方法內部的局部變量是否線程安全?

線程安全,因為棧是線程私有的,當一個線程執行一個方法時,會創建一個獨立的棧幀,局部變量存儲在棧幀的局部變量表中,每個線程之間的棧幀互不影響。

  • 如果方法內局部變量的作用範圍沒有逃離方法的作用範圍,那麼就是線程安全的

  • 如果是局部變量引用了對象,並逃離了方法的作用範圍,就需要考慮線程安全。

棧內存溢出演示

1.棧幀過多導致棧內存溢出,比如沒有適當結束條件的遞歸調用就是因為棧幀過多產生棧內存溢出

```java package jvm.t1;

/* * 演示棧內存溢出 java.lang.StackOverflowError * -Xss 256k / public class Demo1_2 {

private static int count;

public static void main(String[] args) {
    try {
        method1();
    }catch (Throwable e){
        e.printStackTrace();
        System.out.println(count);
    }

}

private static void method1(){
    count++;
    method1();
}

}

```

分析:

如上代碼,由於遞歸方法沒有退出,導致內存溢出。

2.棧幀過大導致棧內存溢出。

2.3 本地方法棧

概述

和虛擬機棧所發揮的作用非常相似,區別是: 虛擬機棧為虛擬機執行 Java 方法 (也就是字節碼)服務,而本地方法棧則為虛擬機使用到的 Native 方法服務。 在 HotSpot 虛擬機中和 Java 虛擬機棧合二為一。

本地方法被執行的時候,在本地方法棧也會創建一個棧幀,用於存放該本地方法的局部變量表、操作數棧、動態鏈接、出口信息。

方法執行完畢後相應的棧幀也會出棧並釋放內存空間,也會出現 StackOverFlowErrorOutOfMemoryError 兩種錯誤。

2.4 Java堆

概述

Java 堆(Java Heap) 是虛擬機所管理的最大的一塊內存,被所有的線程共享,在虛擬機啟動時創建。此內存的唯一目的就是存放對象實例,Java世界幾乎所有的對象實例都是在這裏分配內存。

Java 堆是垃圾收集器管理的主要區域,因此也被稱作GC 堆(Garbage Collected Heap)。從垃圾回收的角度,由於現在收集器基本都採用分代垃圾收集算法,所以 Java 堆還可以細分為:新生代和老年代;再細緻一點有:Eden 空間、From Survivor、To Survivor 空間等。進一步劃分的目的是更好地回收內存,或者更快地分配內存。

在 JDK 7 版本及 JDK 7 版本之前,堆內存被通常分為下面三部分:

  1. 新生代內存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation) 在這裏插入圖片描述

JDK 8 版本之後方法區(HotSpot 的永久代)被徹底移除了(JDK1.7 就已經開始了),取而代之是元空間,元空間使用的是直接內存。

在這裏插入圖片描述

上圖所示的 Eden 區、兩個 Survivor 區都屬於新生代(為了區分,這兩個 Survivor 區域按照順序被命名為 from 和 to),中間一層屬於老年代。

大部分情況,對象都會首先在 Eden 區域分配,在一次新生代垃圾回收後,如果對象還存活,則會進入 s0 或者 s1,並且對象的年齡還會加 1(Eden 區->Survivor 區後對象的初始年齡變為 1),當它的年齡增加到一定程度(默認為 15 歲),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 來設置。

從分配內存的角度看 ,所有線程共享的Java堆可以劃分出多個線程私有的分配緩衝區(Thread Local Allocation Buffer,TLAB),以提升對象分配時的效率。

根據《Java虛擬機規範》的規定,Java堆可以處於物理上不連續的內存空間,但是在邏輯上應該被視為連續的

Java 堆既可以被實現為大小固定的,也是可以擴展的,當下主流的Java虛擬機中的堆都是可以擴展的,擴展命令:

-Xmx和-Xms來設定

堆這裏最容易出現的就是 OutOfMemoryError 錯誤,並且出現這種錯誤之後的表現形式還會有幾種,比如:

  1. java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 當 JVM 花太多時間執行垃圾回收並且只能回收很少的堆空間時,就會發生此錯誤。

  2. java.lang.OutOfMemoryError: Java heap space :假如在創建新的對象時, 堆內存中的空間不足以存放新創建的對象, 就會引發此錯誤。(和配置的最大堆內存有關,且受制於物理內存大小。最大堆內存可通過-Xmx參數配置,若沒有特別配置,將會使用默認值

2.5 方法區

概述

方法區(Method Area) 與Java堆一樣,是線程共享的內存區域,用於存儲存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等數據。Java虛擬機規範把方法區描述為堆的一個邏輯部分, 但是它卻有一個別名叫做“非堆”,目的是與Java堆區分開來。

方法區和永久代的關係

Java 虛擬機規範》只是規定了有方法區這麼個概念和它的作用,並沒有規定如何去實現它。那麼,在不同的 JVM 上方法區的實現肯定是不同的了。 方法區和永久代的關係很像 Java 中接口和類的關係,類實現了接口,而永久代就是 HotSpot 虛擬機對虛擬機規範中方法區的一種實現方式。 也就是説,永久代是 HotSpot 的概念,方法區是 Java 虛擬機規範中的定義,是一種規範,而永久代是一種實現,一個是標準一個是實現,其他的虛擬機實現並沒有永久代這一説法。

常用參數

JDK 1.8 之前永久代還沒被徹底移除的時候通常通過下面這些參數來調節方法區大小

```java -XX:PermSize=N //方法區 (永久代) 初始大小 -XX:MaxPermSize=N //方法區 (永久代) 最大大小,超過這個值將會拋出 OutOfMemoryError 異常:java.lang.OutOfMemoryError: PermGen

```

相對而言,垃圾收集行為在這個區域是比較少出現的,但並非數據進入方法區後就“永久存在”了。

JDK 1.8 的時候,方法區(HotSpot 的永久代)被徹底移除了(JDK1.7 就已經開始了),取而代之是元空間,元空間使用的是直接內存。

下面是一些常用參數:

java -XX:MetaspaceSize=N //設置 Metaspace 的初始(和最小大小) -XX:MaxMetaspaceSize=N //設置 Metaspace 的最大大小

與永久代很大的不同就是,如果不指定大小的話,隨着更多類的創建,虛擬機會耗盡所有可用的系統內存。

為什麼要將永久代 (PermGen) 替換為元空間 (MetaSpace) 呢?

  1. 使用永久代來實現方法區導致了Java應用容易遇到內存溢出的問題(永久代有-XX:MaxPermSize 的上限,即使不設置也有默認大小,而J9 和JRockit只要沒有觸碰到線程可用內存的上限,例如32位系統中的4GB限制,就不會出現問題),而且有極少數的方法例如String::intern()會因為永久代的原因而導致不同虛擬機下有不同的表現。

  2. 在JDK6的時候,HotSpot開發團隊就有放棄永久代,逐步改為採用本地內存(Native Memory)來實現方法區的計劃了,到了JDK7的HotSpot,已經把原本放在永久代的字符串常量、靜態變量等移除,而到了JDK 8,終於完全廢棄了永久代的概念,改用了JRockit\J9一樣的在本地內存中實現的元空間(Meta-space)來替代,把JDK 7中永久代還剩餘的內容(主要是類型信息)全部移動到元空間中。

  3. 整個永久代有一個 JVM 本身設置的固定大小上限,無法進行調整,而元空間使用的是直接內存,受本機可用內存的限制,雖然元空間仍舊可能溢出,但是比原來出現的機率會更小。

當元空間溢出時會得到如下錯誤: `java.lang.OutOfMemoryError: MetaSpace`

可以使用 -XX:MaxMetaspaceSize 標誌設置最大元空間大小,默認值為 unlimited,這意味着它只受系統內存的限制。-XX:MetaspaceSize 調整標誌定義元空間的初始大小如果未指定此標誌,則 Metaspace 將根據運行時的應用程序需求動態地重新調整大小。 ----- 著作權歸Guide哥所有。

  1. 元空間裏面存放的是類的元數據,這樣加載多少類的元數據就不由 MaxPermSize 控制了, 而由系統的實際可用空間來控制,這樣能加載的類就更多了。

  2. 在 JDK8,合併 HotSpot 和 JRockit 的代碼時, JRockit 從來沒有一個叫永久代的東西, 合併之後就沒有必要額外的設置這麼一個永久代的地方了。

其他

  1. 《Java虛擬機規範》對方法區的約束是非常寬鬆的,除了和Java堆一樣不需要連續的物理內存和可以選額固定的大小或可擴展外,甚至還可以選擇不是先垃圾收集。
  2. 進入方法區的數據並非永久存在的。這個區域的內存回收主要針對的是對常量池的回收和對類型的卸載,條件相當苛刻,但是這部分區域的回收有時又是非常必要的。
  3. 如果方法區無法滿足新的內存分配需求時,將拋出OutOfMemoryError異常。

2.6 運行時常量池

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

運行時常量池具備動態性,Java語言並不要求常量一定只有編譯期才能產生,也就是説,並非預置入Class文件中常量池的內容才能進入方法區運行時常量池,運行期間也能將新的常量放入常量池當中,被利用的比較多的就是String類的intern()方法。

既然運行時常量池是方法區的一部分,自然受到方法區內存的限制,當常量池無法再申請到內存時會拋出 OutOfMemoryError 錯誤。

  1. JDK1.7 之前運行時常量池邏輯包含字符串常量池存放在方法區, 此時 hotspot 虛擬機對方法區的實現為永久代
  2. JDK1.7 字符串常量池被從方法區拿到了堆中, 這裏沒有提到運行時常量池,也就是説字符串常量池被單獨拿到堆,運行時常量池剩下的東西還在方法區, 也就是 hotspot 中的永久代
  3. JDK1.8 hotspot 移除了永久代用元空間(Metaspace)取而代之, 這時候字符串常量池還在堆, 運行時常量池還在方法區, 只不過方法區的實現從永久代變成了元空間(Metaspace)

2.7直接內存

直接內存並不是虛擬機運行時數據區的一部分,也不是虛擬機規範中定義的內存區域,但是這部分內存也被頻繁地使用。而且也可能導致 OutOfMemoryError 錯誤出現。

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

本機直接內存的分配不會受到 Java 堆的限制,但是,既然是內存就會受到本機總內存大小以及處理器尋址空間的限制。動態擴展時可能會出現OutOFMemoryError異常。

參考

周志明老師的《深入理解Java虛擬機:JVM高級特性與最佳實踐(第3版)》