5.java記憶體模型詳細解析

盛開的太陽 發表於 2021-10-08
Java

一. java結構體系

Description of Java Conceptual Diagram(java結構)

5.java記憶體模型詳細解析

我們經常說到JVM調優,JVM和JDK到底什麼關係,大家知道麼?這是java基礎。

這幅圖很重要,一定要了解其結構。這是jdk的結構圖。從結構上可以看出java結構體系, JDK主要包含兩部分:

第一部分:是java 工具(Tools&Tool APIs)

​ 比如java, javac, javap等命令. 我們常用的命令都在這裡

第二部分: JRE(全稱:Java Runtime Enveriment), jre是Java的核心,。

​ jre裡面定義了java執行時需要的核心類庫, 比如:我們常用的lang包, util包, Math包, Collection包等等.這裡還有一個很重要的部分JVM(最後一部分青色的) java 虛擬機器, 這部分也是屬於jre, 是java執行時環境的一部分. 下面來詳細看看:

  • 最底層的是Java Virtual Machine: java虛擬機器
  • 常用的基礎類庫:lang and util。在這裡定義了我們常用的Math、Collections、Regular Expressions(正規表示式),Logging日誌,Reflection反射等等。
  • 其他的擴充套件類庫:Beans,Security,Serialization序列化,Networking網路,JNI,Date and Time,Input/Output等。
  • 整合一體化類庫:JDBC資料庫連線,jndi,scripting等。
  • 使用者介面工具:User Interface Toolkits。
  • 部署工具:Deployment等。

從上面就可看出,jvm是整個jdk的最底層。jvm是jdk的一部分。

二. java語言的跨平臺特性

1. java語言是如何實現跨平臺的?

5.java記憶體模型詳細解析

跨平臺指的是, 程式設計師開發出的一套程式碼, 在windows平臺上能執行, 在linux上也能執行, 在mac上也能執行. 我們知道, 機器最終執行的指令都是二進位制指令. 同樣的程式碼, 在windows上生成的二進位制指令可能是0101, 但是在linux上是1001, 而在mac上是1011。這樣同樣的程式碼, 如果要想在不同的平臺執行, 放到相應的平臺, 就要修改程式碼, 而java卻不用, 那麼java這種跨平臺特性是怎麼做到的呢?

原因在於jdk, 我們最終是將程式編譯成二進位制碼,把他丟在jvm上執行的, 而jvm是jre的一部分. 我們在不同的平臺下載的jdk是不同的. windows平臺要選擇下載適用於windows的jdk, linux要選擇適用於linux的jdk, mac要選擇適用於mac的jdk. 不同平臺的jvm針對該平臺有一個特定的實現, 正是這種特點的實現, 讓java實現了跨平臺。

2. 延伸思考

通過上面的分析,我們知道能夠實現跨平臺是因為jvm封裝了變化。我們經常說進行jvm調優,那麼在不同平臺的調優引數可以通用麼?顯然是不可以的。不同平臺的jvm尤其個性化差異。

封裝變化的部分是JDK中的jvm,JVM的整體結構是怎樣的呢?來看下面一個部分。

三. JVM整體結構和記憶體模型

1.JVM由三部分組成:

  • 類裝載子系統
  • 執行時資料區(記憶體模型)
  • 位元組碼執行引擎
5.java記憶體模型詳細解析

其中類裝載子系統是C++實現的, 他把類載入進來, 放入到虛擬機器中. 這一塊就是之前分析過的類載入器載入類,採用雙親委派機制,把類載入進來放入到jvm虛擬機器中。

然後, 位元組碼執行引擎去虛擬機器中讀取資料. 位元組碼執行引擎也是c++實現的. 我們重點研究執行時資料區。

2.執行時資料區的構成

執行時資料區主要由5個部分構成: 堆,棧,本地方法棧,方法區,程式計數器。

3.JVM三部分密切配合工作

下面我們來看看一個程式執行的時候, 類裝載子系統, 執行時資料區, 位元組碼執行引擎是如何密切配合工作的?

我們舉個例子來說一下:

package com.lxl.jvm;

public class Math {
    public static int initData = 666;
    public static User user = new User();

    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
    }
}

當我們在執行main方法的時候, 都做了什麼事情呢?

第一步: 類載入子系統載入Math.class類, 然後將其丟到記憶體區域, 這個就是前面部落格研究的部分,類載入的過程, 我們看原始碼也發現,裡面好多程式碼都是native本地的, 是c++實現的

第二步: 在記憶體中處理位元組碼檔案, 這一部分內容較多, 也是我們研究的重點, 後面會對每一個部分詳細說

第三步: 由位元組碼執行引擎執行java虛擬機器中的記憶體程式碼, 而位元組碼執行引擎也是由c++實現的

這裡最核心的部分是第二部分執行時資料區(記憶體模型), 我們後面的調優, 都是針對這個區域來進行的.

下面詳細來說記憶體區域

5.java記憶體模型詳細解析

這是java的記憶體區域, 記憶體區域幹什麼呢?記憶體區域其實就是放資料的,各種各樣的資料j放在不同的記憶體區域

四. 棧

棧是用來存放變數的

4.1. 棧空間

還是用Math的例子來說,當程式執行的時候, 會建立一個執行緒, 建立執行緒的時候, 就會在大塊的棧空間中分配一塊小空間, 用來存放當前要執行的執行緒的變數

 public static void main(String[] args) {
        Math math = new Math();
        math.compute();
    }

比如,這段程式碼要執行,首先會在大塊的棧空間中給他分配一塊小空間. 這裡的math這個區域性變數就會被儲存在分配的小空間裡面.

在這裡面我們執行了math.compute()方法, 我們看看compute方法內部實現

public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

這裡面有a, b, c這樣的區域性變數, 這些區域性變數放在那裡呢? 也放在上面分配的棧小空間裡面.

5.java記憶體模型詳細解析

效果如上圖, 在棧空間中, 分配一塊小的區域, 用來存放Math類中的區域性變數

如果再有一個執行緒呢? 我們就會再次在棧空間中分配一塊小的空間, 用來存放新的執行緒內部的變數

5.java記憶體模型詳細解析

同樣是變數, main方法中的變數和compute()方法中的變數放在一起麼?他們是怎麼放得呢?這就涉及到棧幀的概念。

4.2. 棧幀

1.什麼是棧幀呢?

package com.lxl.jvm;

public class Math {
    public static int initData = 666;
    public static User user = new User();

    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
    }
}

還是這段程式碼, 我們來看一下, 當我們啟動一個執行緒執行main方法的時候, 一個新的執行緒啟動,會現在棧空間中分配一塊小的棧空間。然後在棧空間中分配一塊區域給main方法,這塊區域就叫做棧幀空間.

當程式執行到compute()計算方法的時候, 會要去呼叫compute()方法, 這時候會再分配一個棧幀空間, 給compute()方法使用.

2.為什麼要將一個執行緒中的不同方法放在不同的棧幀空間裡面呢?

一方面: 我們不同方法裡的區域性變數是不能相互訪問的. 比如compute的a,b,c在main裡不能被訪問到。使用棧幀做了很好的隔離作用。

另一方面: 方便垃圾回收, 一個方法用完了, 值也返回了, 那他裡面的變數就是垃圾了, 後面直接回收這個棧幀就好了.

5.java記憶體模型詳細解析

如下圖, 在Math中兩個方法, 當執行到main方法的時候, 會將main方法放到一塊棧幀空間, 這裡面僅僅是儲存main方法中的區域性變數, 當執行到compute方法的時候, 這時會開闢一塊compute棧幀空間, 這部分空間僅存放compute()方法的區域性變數.

不同的方法開闢出不同的記憶體空間, 這樣方便我們各個方法的區域性變數進行管理, 同時也方便垃圾回收.

3.java記憶體模型中的棧演算法

我們學過棧演算法, 棧演算法是先進後出的. 那麼我們的記憶體模型裡的棧和演算法裡的棧一樣麼?有關聯麼?

我們java記憶體模型中的棧使用的就是棧演算法, 先進後出.舉個例子, 還是這段程式碼

package com.lxl.jvm;

public class Math {
    public static int initData = 666;
    public static User user = new User();

    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public int add() {
        int a = 1;
        int b = 2;
        int c = a + b;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
        math.add();   // 注意這裡呼叫了兩次compute()方法
    }
}

這時候載入的記憶體模型是什麼樣呢?

5.java記憶體模型詳細解析
  1. 最先進入棧的是main方法, 會首先線上程棧中分配一塊棧幀空間給main方法。
  2. main方法裡面呼叫了compute方法, 然後會在建立一個compute方法的棧幀空間, 我們知道compute方法後載入,但是他卻會先執行, 執行完以後, compute中的區域性變數就會被回收, 那麼也就是出棧.
  3. 然後在執行add方法,給add方法分配一塊棧幀空間。add執行完以後出棧。
  4. 最後執行完main方法, main方法最後出棧. 這個演算法剛好驗證了先進後出. 後載入的方法會被先執行. 也符合程式執行的邏輯。

4.3 棧幀的內部構成

我們上面說了, 每個方法在執行的時候都會有一塊對應的棧幀空間, 那麼棧幀空間內部的結構是怎樣的呢?

棧幀內部有很多部分, 我們主要關注下面這四個部分:

1. 區域性變數表
2. 運算元棧
3. 動態連結
4. 方法出口

4.2.1 區域性變數表: 存放區域性變數

區域性變數表,顧名思義,用來存放區域性變數的。

4.2.2 運算元棧

那麼運算元棧,動態連結, 方法出口他們是幹什麼的呢? 我們用例子來說明運算元棧

5.java記憶體模型詳細解析

那麼這四個部分是如何工作的呢?

我們用程式碼的執行過程來對照分析.

我們要看的是jvm反編譯後的位元組碼檔案, 使用javap命令生成反編譯位元組碼檔案.

javap命令是幹什麼用的呢? 我們可以檢視javap的幫助文件

5.java記憶體模型詳細解析

主要使用javap -c和javap -v

javap -c: 對程式碼進行反編譯
javap -v: 輸出附加資訊, 他比javap -c會輸出更多的內容

下面使用命令生成一個Math.class的位元組碼檔案. 我們將其生成到檔案

javap -c Math.class > Math.txt

開啟Math.txt檔案, 如下. 這就是對java位元組碼反編譯成jvm組合語言.

Compiled from "Math.java"
public class com.lxl.jvm.Math {
  public static int initData;

  public static com.lxl.jvm.User user;

  public com.lxl.jvm.Math();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public int compute();
    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 com/lxl/jvm/Math
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: aload_1
       9: invokevirtual #4                  // Method compute:()I
      12: pop
      13: return

  static {};
    Code:
       0: sipush        666
       3: putstatic     #5                  // Field initData:I
       6: new           #6                  // class com/lxl/jvm/User
       9: dup
      10: invokespecial #7                  // Method com/lxl/jvm/User."<init>":()V
      13: putstatic     #8                  // Field user:Lcom/lxl/jvm/User;
      16: return
}

這就是jvm生成的反編譯位元組碼檔案.

要想看懂這裡面的內容, 我們需要知道jvm文件手冊. 現在我們不會沒關係, 參考文章(https://www.cnblogs.com/ITPower/p/13228166.html)最後面的內容, 遇到了就去後面查就行了

我們以compute()方法為例來說說這個方法是如何在在棧中處理的

原始碼
public int compute() {
  int a = 1;
  int b = 2;
  int c = (a + b) * 10;
  return c;
}


反編譯後的jvm指令
public int compute();
    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

jvm的反編譯程式碼是什麼意思呢? 我們對照著查詢手冊

0: iconst_1 將int型別常量1壓入運算元棧, 這句話的意思就是先把int a=1;中的1先壓入運算元棧

5.java記憶體模型詳細解析

1: istore_1 將int型別值存入區域性變數1-->意思是將int a=1; 中的a變數存入區域性變數表

注意: 這裡的1不是變數的值, 他指的是區域性變數的一個下標. 我們看手冊上有區域性變數0,1,2,3

5.java記憶體模型詳細解析

0表示的是this, 1表示將變數放入區域性變數的第二個位置, 2表示放入第三個位置.

對應到compute()方法,0表示的是this, 1表示的區域性變數a, 2表示區域性變數b,3表示區域性變數c

1: istore_1 將int型別值存入區域性變數1-->意思是將int a=1; 中的a放入區域性變數表的第二個位置, 然後讓運算元棧中的1出棧, 賦值給a

5.java記憶體模型詳細解析 5.java記憶體模型詳細解析

2: iconst_2 將int型別常量2壓入棧-->意思是將int b=2;中的常量2 壓入運算元棧

5.java記憶體模型詳細解析

3: istore_2 將int型別值存入區域性變數2 -->意思是將int b=2;中的變數b存入區域性變數表中第三個位置, 然後讓運算元棧中的數字2出棧, 給區域性變數表中的b賦值為2

5.java記憶體模型詳細解析

4: iload_1 從區域性變數1中裝載int型別值--->這句話的意思是, 將運算元1從運算元棧取出, 轉入區域性變數表中的a, 現在區域性變數表中a=1

要想更好的理解iload_1,我們要先來研究程式計數器。

程式計數器

在JVM虛擬機器中,程式計數器是其中的一個組成部分。

5.java記憶體模型詳細解析

程式計數器是每一個執行緒獨有的, 他用來存放馬上要執行的那行程式碼的記憶體位置, 也可以叫行號. 我們看到jvm反編譯程式碼裡,都會有0 1 2 3這樣的位置(如下圖), 我們可以將其認為是一個標識.而程式計數器可以簡單理解為是記錄這些數字的. 而實際上這些數字對應的是記憶體裡的地址

5.java記憶體模型詳細解析

當位元組碼執行引擎執行到第4行的時候,將執行到4: iload_1, 我們可以簡單理解為程式計數器記錄的程式碼位置是4. 我們的方法Math.class是放在方法區的, 由位元組碼執行引擎執行, 每次執行完一行程式碼, 位元組碼執行引擎都會修改程式計數器的位置, 讓其向下移動一位

5.java記憶體模型詳細解析

java虛擬機器為什麼要設計程式計數器呢?

因為多執行緒。當一個執行緒正在執行, 被另一個執行緒搶佔了cpu, 這時之前的執行緒就要掛起, 當執行緒2執行完以後, 再執行執行緒1. 那麼執行緒1之前執行到哪裡了呢? 程式計數器幫我們記錄了.

下面執行這句話

4: iload_1 從區域性變數1中裝載int型別值--> 意思是從區域性變數表的第二個位置取出int型別的變數值, 將其放入到運算元棧中.此時程式計數器指向的是4

5.java記憶體模型詳細解析

5: iload_2 從區域性變數2中裝載int型別值-->意思是將區域性變數中的第三個int型別的元素b的值取出來, 放到運算元棧, 此時程式計數器指向的是5

5.java記憶體模型詳細解析

6: iadd 執行int型別的加法 ---> 將兩個區域性變數表中的數取出, 進行加法操作, 此操作是在cpu中完成的, 將執行後的結果3在放入到運算元棧 ,此時程式計數器指向的是6

5.java記憶體模型詳細解析 5.java記憶體模型詳細解析

7: bipush 10 :將一個8位帶符號整數壓入棧 --> 這句話的意思是將10壓入運算元棧

5.java記憶體模型詳細解析

我們發現這裡的位置是7, 但是下一個就變成了9, 那8哪裡去了呢? 其實這裡的0 1 2 3 ...都是對應的記憶體地址, 我們的乘數10也會佔用記憶體空間, 所以, 8的位置存的是乘數10

9: imul 執行int型別的乘法 --> 這個和iadd加法一樣, 首先將運算元棧中的3和10取出來, 在cpu裡面進行計算, 將計算的結果30在放回運算元棧

乘法操作是在cpu的暫存器中進行計算的. 我們這裡說的都是儲存在記憶體中.

5.java記憶體模型詳細解析

10: istore_3 將int型別值存入區域性變數表中 ---> 意思是是將c這個變數放入區域性變數表, 然後讓運算元棧中的30出棧, 賦值給變數c

5.java記憶體模型詳細解析

11: iload_3 從區域性變數3中裝載int型別值 --> 將區域性變數表中取出第4個位置的值30, 裝進區域性變數表

5.java記憶體模型詳細解析

12: ireturn 從方法中返回int型別的資料 --> 最後將得到的結果c返回.

這個方法中的變數是如何在運算元棧和區域性變數表中轉換的, 我們就知道了. 現在應該可以理解運算元棧和區域性變數表了吧~~~

總結:什麼是運算元棧?**

在運算的過程中, 常數1, 2, 10, 也需要有記憶體空間存放, 那麼它存在哪裡呢? 就儲存在運算元棧裡面

運算元棧就是在執行的過程中, 一塊臨時的記憶體中轉空間

4.3.3 動態連結

在之前說過什麼是動態連結: 參考文章: https://www.cnblogs.com/ITPower/p/13197220.html 搜尋:動態連結

靜態連結是在程式載入的時候一同被載入進來的. 通常用靜態常量, 靜態方法等, 因為他們在記憶體地址中只有一份, 所以, 為了效能, 就直接被載入進來了

而動態連結, 是使用的時候才會被載入進來的連結, 比如compute方法. 只要在執行到math.compute()方法的時候才會真的進行載入.

4.3.4 方法出口

當我們執行完compute()方法以後, 還要返回到main方法的math.comput()方法的位置, 那麼他怎麼返回回來呢?返回回來以後該執行哪一句程式碼了呢?在進入compute()方法之前,就在方法出口裡記錄好了, 我應該如何返回,返回到哪裡. 方法出口就是記錄一些方法的資訊的.

五. 堆和棧的關係

上面研究了compute()方法的棧幀空間,再來看一下main方法的棧幀空間。整體來說,都是一樣的,但有一塊需要說明一下,那就是區域性變數表。來看看下面的程式碼

public static void main(String[] args) {
  Math math = new Math();
  math.compute();
}

main方法的區域性變數和compute()有什麼區別呢? main方法中的math是一個物件. 我們知道通常物件是被建立在堆裡面的. 而math是在區域性變數表中, 記錄的是堆中new Math物件的地址。

說的明白一些,math裡存放的不是具體的內容,而是例項物件的地址。

5.java記憶體模型詳細解析

那麼棧和堆的關係就出來了, 如果棧中有很多new物件, 這些物件是建立在堆裡面的. 棧裡面存的是這些堆中建立的物件的記憶體地址。

六. 方法區

我們可以通過javap -v Math.class > Math.txt命令, 列印更詳細的jvm反編譯後的程式碼

5.java記憶體模型詳細解析

這次生成的程式碼,和使用javap -c生成的程式碼的區別是多了Constant pool常量池。這些常量池是放在哪裡的呢?放在方法區。這裡看到的常量池叫做執行時常量池。還有很多其他的常量池,比如:八大資料型別的物件常量池,字串常量池等。

這裡主要理解執行時常量池。執行時常量池放在方法區裡。

方法區主要有哪些元素呢?

常量 + 靜態變數 + 類元資訊(就是類的程式碼資訊)

在Math.class類中, 就有常量和靜態常量

public static int initData = 666;
public static User user = new User();

他們就放在方法區裡面. 這裡面 new User()是放在堆裡面的, 在堆中分配了一個記憶體地址,而user物件是放在方法區裡面的. 方法區中user物件指向了在堆中分配的記憶體空間。

img

堆和方法區的關係是: 方法區中物件引用的是堆中new出來的物件的地址

類元資訊: Math.class整個類中定義的內容就是類元資訊, 也放在方法區。

七. 本地方法棧

本地方法棧是有c++程式碼實現的方法. 方法名帶有native的程式碼.

比如:

new Thread().start();

這裡的start()呼叫的就是本地方法

5.java記憶體模型詳細解析

這就是本地方法

本地方法棧: 執行的時候也需要有記憶體空間去儲存, 這些記憶體空間就是本地方法棧提供的

5.java記憶體模型詳細解析

每一個執行緒都會分配一個棧空間,本地方法棧和程式計數器。如上圖main執行緒:包含執行緒棧,本地方法棧,程式計數器。