跟我一起分析學習 JVM 內存模型

當執行 main 方法的時候, 其實內部過程是這樣的.


而常說的JVM 內存模型, 即是 JVM 中的運行時數據區. 下面會詳細對齊進行分析, 大部分都是理論的, 可能會有點枯燥.

運行時數據區的構成

Java 虛擬機在執行 Java 程序的過程中會把它所管理的內存劃分爲若干個不同的數據區域. 這些數據區域被統稱爲運行時數據區. 看上圖得知會分爲兩個區域, 線程隔離/私有與共享的.那麼接下來對它們逐個進行分析學習. (其實還會有另外一個區域叫做直接內存區域. 這裏先不說這個. )

在 JDK1.4 中心加入了 NIO 類, 它可以使用 Native 函數庫直接分配堆外內存 (Native) 堆, 然後通過一個存儲在 Java 堆裏的 DirectByteBuffer 對象作爲這塊內存的引用進行操作. 這樣能在一些場景中顯著的提高性能, 因爲避免了在 Java 堆與 Native 堆中來回複製數據.

先從線程隔離區域開始:

一. 線程隔離區


 

1. 虛擬機棧

也可稱爲 Java 棧, 是一種先進後出的數據結構.
每個 java 線程都對應一個虛擬機棧, 棧內是一個個線程需要調用的方法. 每個方法又對應一個叫棧幀的數據結構. 方法調用到執行的過程, 就對應一個棧幀在虛擬機棧中入棧和出棧的過程. 虛擬機棧的生命週期是和線程也是相同的, 是在JVM運行時創建的. 如果將虛擬機棧看過是彈夾, 那麼棧幀就是彈夾內的子彈.

1.1 棧幀的構成
  • 局部變量表
    • 用來存儲方法內定義的的局部變量與方法參數.(但是隻能存儲基本數據類型的變量與引用類型的)
    • 局部變量表的容量計量單位爲 slot, 一個 slot 可以存放一個 32 位以內的數據.
    • 虛擬機通過索引的方式使用局部變量表, 索引值從 0 開始. 方法執行時, 索引爲 0 的 slot 默認用於傳遞方法所屬對象的引用, 方法中可以使用this 關鍵字來訪問這個隱含的參數.
    • 爲了節省空間, 表中的 slot 是可以重用的.
    • 隨着方法調用結束後, 會隨着棧幀的銷燬也隨之銷燬, 釋放空間.
  • 操作數棧
    • 操作數棧也常被稱爲操作棧, 是一個也是一個先進後出的棧. 方法剛開始執行的時候, 操作數棧是空的, 在方法執行的過程中, 會有各種字節碼指令往操作數棧中存取數據.
    • 操作數棧的每一個元素可以是任意的 java 數據類型.
    • 操作數棧中元素的數據類型必須與字節碼指令的序列嚴格匹配.
  • 動態連接
    • Class 文件的常量池中存在有大量的符號引用, 字節碼中的方法調用指令就以常量池中指向方法的符號引用作爲參數.
    • 這些符號引用一部分會在類加載階段或者第一次使用的時候就轉化爲直接引用, 這種轉化成爲靜態解析.
    • 另外一部分將在每一次運行期間轉化爲直接引用, 這部分成爲動態連接.
  • 方法返回地址
    • 當一個方法被執行後有兩種方式退出這個方法, 正常完成與異常完成.
    • 正常完成: 執行引擎遇到任意一個方法返回的字節碼指令.
    • 異常完成: 遇到異常, 並且這個異常沒有在方法體內得到處理.
    • 無論哪種方式退出, 在方法退出後, 都需要返回方法被調用的位置, 程序才能繼續執行. 方法返回時可能需要在棧幀中保存一些信息, 用於恢復它的上層方法的執行狀態.
    • 方法退出的過程實際上等同於將當前棧幀出棧. 因此退出時的操作可能有: 恢復上層方法的局部變量表與操作數棧, 如果有返回值則把它壓入調用者棧幀的操作數棧中. 調用 PC 計數器的值以指向方法調用指令的最後一條指令.

不同操作系統的虛擬機棧的大小是受到限制的, 默認大小爲 1M.

這些理論看起來很彎彎繞繞, 彆着急, 後面會有具體的分析. 現在只是先了解一下它們大概功能, 存什麼, 做什麼.


 

2. 本地方法棧

這個與虛擬機棧相似, 它們之間的區別只不過是本地方法棧爲本地方法服務.

當一個創建的線程調用 Native 方法後, JVM 不再爲其在虛擬機棧中創建棧幀, JVM 只是簡單的動態鏈接並直接調用 Native 方法. (虛擬機規範無強制規定, 各版本的虛擬機自由實現), 但是 HotSpot 版本直接將本地方法棧與虛擬機棧合二爲一了.


 

3. 程序計數器

  • 程序計數器負責記錄當前線程正在執行的字節碼指令的地址,
  • 線程私有, 在多線程情況下, 爲了讓線程切換後依然能恢復到原位, 每條線程都需要有各自獨立的程序計數器.
  • 不會 OOM, 程序計數器存儲的是字節碼文件的行號, 而這個範圍是可以知道的, 在一開始分配內容時就可以分配一個絕對不會溢出的內存.
  • 如果正在執行的是 Native 方法. 這個計數器值則爲空. 因爲 Native 方法大多是通過 C 實現並未編譯成需要執行的字節碼指令, 也就不需要去存儲字節碼文件的行號.

 
現在說完了運行時數據區中線程隔離類型的. 下面根據一個例子來看一下他們的執行過程對內存區域的影響.
現有代碼如下

public class Person {
    public int work() {
        int x = 1;
        int y = 2;
        int z = (x + y) * 10;
        return z;
    }
    public static void main(String[] args) {
        Person person = new Person();
        person.work();
    }
}

將編譯的 class 文件 通過 javap -c 反編譯後如下所示

public class org.study.Person {
  public org.study.Person();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int work();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class org/study/Person
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method work:()I
      12: pop
      13: return
}

看到 Code 下面每行前面都有一個數字, 0,1,2,3,4,5 這樣的數字, 這個數字是針對方法體的偏移量, 大體上可以理解這個爲程序計數器, 記錄字節碼的地址,

  1. 根據上面說的那些, 首先是 main 方法執行, 那麼將 main 方法的棧幀壓入虛擬機棧.
  2. 然後將調用的 work 方法棧幀也也壓入虛擬機棧.

上面兩步執行完後 , 虛擬機棧結構如下


  1. 開始分析反編譯出來的 work() 方法
  public int work();
    Code:
       0: iconst_1
       1: istore_1
       2: iconst_2
       3: istore_2
       4: iload_1
       5: iload_2
       6: iadd
       7: bipush        10
       9: imul
      10: istore_3
      11: iload_3
      12: ireturn

0: iconst_1 : 將 int 類型的 1入操作數棧. 爲什麼將下標爲 0 的指向 this, 看上面 1.1 中局部變量表的說明


1: istore_1: 將操作數棧棧頂的 int 型數值存入局部變量表下標爲 1 的位置.

這兩個指令執行完後, 就相當於執行完了 java 代碼中的 int x = 1. 那麼後面的 int y = 2 也是同理.

2: iconst_2 : 將 int 型 2 入操作數棧.
3: istore_2 : 將操作數棧中棧頂 int 型數值存入局部變量表. 下標爲 2 的位置.


好了,現在 int y = 2 也執行完了. 接着執行 int z = (x + y ) * 10

4: iload_1: 將局部變量表中下標爲 1 的 int 型數據入操作數棧
5: iload_2: 將局部變量表中下標爲 2 的 int 型數據入操作數棧

把這兩個數據放入操作數棧的目的就是爲了對它們進行操作..


6: iadd: 執行相加的指令. 這個指令分爲 3 個步驟.

  • 先將操作數棧中棧頂的兩個數據出棧
  • 執行相加
  • 將相加的結果再壓入操作數棧

出棧操作



相加後入棧操作


那麼執行到這裏的時候, 是不是發現每次操作方法內的變量的時候, 都會先將其由局部變量表放入到操作數棧, 操作完後再將其壓入到操作數棧. 那麼接着向下看

7: bipush 10: 將 10 的值拓展成 int 值後壓入操作數棧

爲什麼不是使用上面的 iconst_10 來讓它入操作數棧呢?
因爲在底層操作的時候, 針對值的大小, 使用的指令不同

9: imul: 相乘的指令, 這個指令與相加的指令相同也是分 3 個步驟

  • 出棧
  • 相乘
  • 入棧

10: istore_3: 將操作數棧棧頂的 int 數值存入到局部變量表中下標爲 3 的位置.

那麼現在 int z = (x + y) * 10 也執行完了 , 還剩下最後一個返回, 那麼返回顯然也屬於是一個操作, 既然是一個操作, 那麼就需要放到操作數棧中進行, 所以還需要將返回的值, 從局部變量表加載到操作數棧中來.

11: iload_3: 將局部變量表中下標爲 3 的數值壓入到操作數棧

12: ireturn: 這個就是方法返回的字節碼指令. 將 work() 操作數棧中棧頂的值壓入到調用者也就是main棧幀中的操作數棧中. 同時 work 棧幀出棧. 這一步就不再用圖來描述了.

那麼通過這個這個例子, 估計對線程隔離區中的, 虛擬機棧, 棧幀, 操作數棧, 局部變量表, 都有一定的認識了, 那麼下面一起看線程共享區的方法區與堆.

二. 線程共享區


 

4. 方法區

  • 《深入理解Java虛擬機》書中對方法區(Method Area)存儲內容描述如下: 它用於存儲已被虛擬機加載的類型信息, 常量, 靜態變量, 即時編譯器編譯後的代碼緩存等.
  • 運行時常量池也是方法區的一部分.
  • 可以動態擴展, 擴展失敗會拋出 OOM 異常.

Java 中的常量池, 實際上分爲兩種形態: 運行時常量池與靜態常量池.

靜態常量池: 即 *.class 文件中的常量池, class 文件中的常量池不僅僅包含字符串(數字), 字面量, 還包含類, 方法的信息, 佔用了 class 文件的大部分空間.

運行時常量池: 是 JVM 虛擬機在完成類裝載操作後, 將 class 文件中的常量池載入到內存中, 並保存在方法區中, 我們常說的常量池, 就是指方法區中的運行時常量池.

運行時常量池在 JDK1.8 後, 將字符串的常量放入了堆中. 常量池只保持引用.
關於常量池可參照這篇文章: JAVA常量池,一篇文章就足夠入門了。(含圖解)

其實在 <<Java 虛擬機規範>> 中只是規定了有 方法區這麼一個概念跟它的作用. HotSpot 在 JDK 1.8 之前使用一個永久代將這個概念實現了.
但是因爲存儲上述多種數據很難確定大小, 導致也無法確定永久代的大小. 並且每次 Full GC 後永久代的大小都會改變, 經常拋出 OOM 異常. 所以爲了更容易的管理方法區, 在 JDK1.8 後就將永久代移除, 將方法區的實現變成了元空間 Metaspace, 它位於本地內存中, 而不是虛擬機的內存中.

元空間與永久代的本質類似, 都是 JVM 規範中方法區的實現. 不過元空間與永久代之間最大的區別在於: 元空間不在虛擬機中, 而是使用本地內存. 因此, 默認情況下, 元空間的大小僅受本地內存限制, 但是可以通過參數來指定元空間的大小.

但是元空間也有可能去擠壓堆空間, 假如 機器有 20G 內存, 堆設置最大分配上線爲 10G, 初始 爲 5G. 而設置的元空間大小爲 15G, 那麼堆就無法擴展到 10G, 最大也就是擴展到 5G. 日常肯定不會這樣操作了, 只是說會有這樣的問題.


 

5. 堆

堆區 Heap 是 JVM 中最大的一塊內存區域, 幾乎所有的對象實例以及數組都是在堆上分配空間(爲什麼說是幾乎所有的對象實例? 也就是說還是有一些不是在堆中分配的, 下一章會說到), 是垃圾收集的主要區域.

爲了垃圾收集器進行對象的回收管理, JVM 把堆區進行了分代管理, 細分爲年輕代與老年代, 其中年輕代又分爲 Eden, from ,to 三個部分. 默認比例是 8:1:1 的大小. 其中年輕代佔堆區的三分之一, 剩下的都爲老年代部分.

堆區大小的設置

  • Xms 堆區內存初始內存分配的大小.
  • Xmx 堆區內存可被分配的最大上限.

既然方法區與堆區都是線程共享的, 爲什麼不使用一份呢? 還需要用兩個區來進行區分?
堆中存放的都是對象實例, 數組等, 這些都是需要頻繁進行回收的. 而方法區內存放的內容回收難度大, 屬於一種動靜分離的思想. 將偏靜態的放入到方法區, 將經常需要動態創建和回收的放入到堆區, 以便垃圾回收.


現在運行時數據區的內容基本分析完了, 再通過一個例子來深入的的理解運行時數據區.
代碼如下.

package org.study;

class Student {
    String name;
    String sex;
    int age;
    public void setName(String name) {
        this.name = name;
    }
    public void setSex(String sex) {
        this.sex = sex;
    }
    public void setAge(int age) {
        this.age = age;
    }
}
public class JVMObject {
    public final static String SEX_MAN = "man";
    public static String SEX_WOMAN = "woman";

    public static void main(String[] args) throws InterruptedException { //棧幀
        //new Student 存入堆中
        //student1 引用, 存入棧幀中的局部變量表
        Student student1 =  new Student();
        student1.setName("李雷");
        student1.setAge(12);
        student1.setSex(SEX_MAN);
        for (int i = 0; i < 100; i++) {
            System.gc();
        }
        //new Student 存入堆中
        //student2 引用, 存入棧幀中的局部變量表
        Student student2 =  new Student();
        student2.setName("韓梅梅");
        student2.setAge(13);
        student2.setSex(SEX_WOMAN);

        Thread.sleep(Integer.MAX_VALUE); //讓主線程陷入休眠
    }
}

代碼中有一個靜態變量 與常量, 同時在 main 方法中創建了兩個 Student 對象, 在 student1 創建後, GC了 100 次. 接着創建 student2. 通過這個例子, 現在一起來更深入的理解一下整體的運行時數據區.

在運行之前爲開發工具的 Run/Debug Configurations 中的 VM options設置幾個參數
-XX : -UseCompressedOops 不壓縮是爲了方便我們更容易的看到內容中的一些內容.
-Xms30m -Xmx30m 設置堆區初始值與最大值
-XX : +UseConcMarkSweepGC 設置垃圾回收器.

  • 首先申請內存, 然後分配各個區域的內存大小.

  • 接着是類價值, 將 Student.classJVMObject.class 還有靜態變量與常量放入到方法區. 如下圖所示

  • 虛擬機棧開始入棧

  • Student student1 = new Student();student1 的引用放入局部變量表, 將 student1 實例放入到堆.

  • 執行 Student 內的方法, 就是不斷出棧入棧的過程(操作數棧), 可以參考上面的那個例子, 這裏就直接略過.

  • 執行 100 次 GC 後, 會將 student1 實例移動到老年代內.

  • 執行 student2 的邏輯, 與 student1 類似. 最後內存區域內容如下圖


    接下來, 就需要來驗證上面說的那些了, 這裏使用到了一個工具, HSDB 是一個內存可視化工具.
    使用這個工具可以來查看我們上面代碼執行完後內存區域的內容.

HSDB: 在 JDK 1.8 中支持直接調用. cd 值 jdk 的 lib 包下執行命令
sudo java -cp ./sa-jdi.jar sun.jvm.hotspot.HSDB. (我是 MAC 所以用了 sudo)

接着再使用 JPS 命令來查看我們當前 class 文件的進程 ID, (因爲上面代碼中邏輯是執行完後一直在休眠, 所以進程還存在).

yzhangs-MacBook-Pro:study yzhang$ jps
59169 
29523 Jps
29507 Launcher
29508 JVMObject
29335 HSDB

29508 就是進程的 ID 了. 接着在 HSDB 中選擇 File -> Attach to HotSpot prcess, 在彈出的窗口中輸入 29508



附加成功後出現如下界面

查看主線程的虛擬機棧, 選中 main 後點擊上面的 stack memory ... 按鈕

出現如下界面

  • 最左側是棧的內存地址
  • 中間一列是該地址上存的值(大多是別的對象的地址).
  • 最右側是 HotSpot 的說明

通過右側的說明, 我們是不是看到了 JVMObject.main 方法的棧幀


其實說白了, 虛擬機棧幀應該就是對內存中物理地址的一種虛擬化.
在 main 方法棧幀上面還看到了另外一個 Thread.sleep 方法的棧幀, 這是一個本地方法, 同時也驗證了上面在本地方法棧中說的 Hotspot 直接將本地方法棧與虛擬機棧合二爲一.

接下來去看方法區中的 class. 在 HSDB 頂部菜單中選擇 Object Histogram

出現下面界面, 在搜索欄中輸入包名. 就看到了方法區內的 Student.class



雙擊這個 Student 進入到下一個界面.



這就是創建的 韓梅梅與李雷, 那麼怎麼證明這兩個對象實例是在堆中而不是方法區呢?
繼續在頂部菜單 Tools 中找到 Heap Parameters 顯示堆的信息

看出新生代中的內存地址如下

eden : 0x000000011a400000, 0x000000011a53ceb0, 0x000000011ac00000

from : 0x000000011ac00000, 0x000000011ac00000, 0x000000011ad00000

to : 0x000000011ad00000, 0x000000011ad00000, 0x000000011ae00000

老年代的內存地址: 0x000000011ae00000 到 0x000000011c200000

新生代都是有三個地址組成. 分別表示 內存起始地址使用空間結束地址整體空間結束地址

不難看出,在新生代中只有 Eden 區的起始地址和使用空間結束地址不相同(分配有對象), 而 from 區和 to 區的使用空間地址和起始地址相同(空使用區域).

我們將這些起止地址與結束地址放到第二個例子的堆區的圖上, 然後再根據兩個 Student 對象的地址的, 來找一下他們對應的位置.

韓梅梅的地址爲: 0x000000011a400000 剛好對應到新生代中 Eden 中的地址.
李雷的地址爲 : 0x000000011ae6bf98 也在老年代的 0x000000011ae00000 到 0x000000011c200000之間.
證明我們的圖是正確的, 也證明了, 創建的這兩個對象實例確實是在堆內.

最後再來確定一下棧幀中存放的到底是不是兩個堆中對象實例的引用.



這裏也證實了, 在棧幀中的局部變量中如果存放的是對象的話, 存放的是引用. 指向堆中的地址.


 

三. 虛擬機的優化技術

  1. 編譯優化 - 方法內聯

    通過一段代碼來演示

    public class Test{
          public static boolean max(int a, int b){
              return a > b;
          }
          public static void main(string[] args){
                max(1,2);
          }
    }
    

    這段代碼在運行的時候, 會爲 max 方法創建一個棧幀, 然後執行入棧出棧等操作. 其實如果是上面這種在 max 方法內已經是確定了的表達式, 那麼在編譯的時候虛擬機會直接將目標方法原封不動的放到調用方法中來, 那麼編譯後類似下面的僞代碼

    public class Test{
        public static boolean max(int a, int b){
            return a > b;
        }
        public static void main(string[] args){
                //max(1,2);
                boolean result = 1 > 2
        }
    }
    

    避免了方法的調用, 也就避免了創建棧幀, 入棧, 出棧, 等操作. 帶來了性能上的一些提升.
    在開發中避免一個方法中寫大量的代碼, 習慣使用小方法體..
     

  2. 棧的優化 - 棧幀之間的數據共享
    兩個棧幀之間數據共享, 主要體現在方法調用中有參數傳遞的情況, 上一個棧幀的部分局部變量表與下一個棧幀的操作數棧共用一部分空間, 這樣既節約了空間. 也避免了參數的複製傳遞
    還是以一段代碼爲例

    public class TestStackFrame {
      public static void main(String[] args) throws InterruptedException {
          TestStackFrame testStackFrame = new TestStackFrame();
          testStackFrame.add(1);
    
      }
    
      private void add(int a) throws InterruptedException {
          int result = a + 1;
          Thread.sleep(Integer.MAX_VALUE);
      }
    }
    

    這個又需要用到 HSDB 工具來查看棧幀信息了.



    發現其中棧幀 main 的操作數棧與 add 棧幀的局部變量表的區域中有一部分是重複的. 這就表示了重複的那部分內存區域是共用的.

  3. 逃逸分析/指令重排序/鎖消除/鎖優化
    這個在從 Synchronized 到鎖的優化 一文中已經說過. 不再複述.

4.棧上分配 (下一章會分析到)


 

四. 常見的內存溢出

常見的內存溢出分爲以下幾種

  • 棧溢出: 棧溢出分爲兩種, 一種是 StackOverFlowError 一種是 OutOfMemory.
    • StackOverFlowError: 當有一個方法遞歸調用自己, 不斷的虛擬機棧中創建棧幀, 由於棧的深度有有限制的, 這樣就會引發 StackOverFlowError
    • OutOfMemory: 假如有 1000 個線程同時執行, 但是機器內存只有 500M了, 而在 hotspot 上每個棧幀都是固定大小, 假如每個棧幀佔用 1M, 那麼就會出現 OOM.
  • 堆溢出: 這是最常見的情況, 比如我們設置了堆的大小爲 30M,初始也爲 30M, 假如我們在程序中聲明瞭一個 35M 的數組, 那麼也會拋出 OOM.
  • 方法區溢出: 使用 cglib 不斷的編譯代碼, 然後限制方法區的大小, 設置-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m , 就會導致方法區的內存溢出. java.long.outofmemoryError.Metaspace
  • 本質接內存(堆外內存)溢出: 使用 NIO 的時候, 限制直接內存的大小爲 100m, -XX:MaxDirectMemorySize=100m ,然後使用 NIO 中的 ByteBuffer.allocateDirect 分配一個 128M 的數組. 則也會拋出 java.long.outofmemoryError: Direct buffer memory

 

五. 總結

從功能上來對比堆和棧

  • 棧是以棧幀的方式存儲方法調用的過程, 並存儲方法調用過程中基本數據類型的變量以及對象的引用變量, 其內存分配在棧上, 變量出了作用域就會自動釋放
  • 堆內存用來存儲 java 中的對象實例, 無論是成員變量, 局部變量, 還是類變量, 它們指向的對象都存儲在堆中.

從線程獨享還是共享上來對比

  • 棧內存歸屬於單個線程, 每個線程都會有一個棧內存, 其存儲的變量只能其所屬的線程中可見, 即棧內存可以理解成線程的是有內存.
  • 堆內存中的對象對所有線程可見, 同時可以被所有線程訪問.

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