JVM執行時資料區概述

又壞又迷人發表於2020-09-02

執行時資料區概述

程式計數器(Program Counter Register)

是一塊較小的記憶體空間,可以看作是當前執行緒所執行位元組碼的行號指示器,指向下一個將要執行的指令程式碼,由執行引擎來讀取下一條指令。

虛擬機器棧 (Stack Area)

棧是執行緒私有,棧幀是棧的元素。每個方法在執行時都會建立一個棧幀。棧幀中儲存了區域性變數表、運算元棧、動態連線和方法出口等資訊。每個方法從呼叫到執行結束的過程,就對應著一個棧幀在棧中壓棧到出棧的過程。

本地方法棧 (Native Method Area)

JVM 中的棧包括 Java 虛擬機器棧和本地方法棧,兩者的區別就是,Java 虛擬機器棧為 JVM 執行 Java 方法服務,本地方法棧則為 JVM 使用到的 Native 方法服務。

堆 (Heap Area)

堆是Java虛擬機器所管理的記憶體中最大的一塊儲存區域。堆記憶體被所有執行緒共享。主要存放使用new關鍵字建立的物件。所有物件例項以及陣列都要在堆上分配。垃圾收集器就是根據GC演算法,收集堆上物件所佔用的記憶體空間。

Java堆分為年輕代(Young Generation)和老年代(Old Generation);年輕代又分為伊甸園(Eden)和倖存區(Survivor區);倖存區又分為From Survivor空間和 To Survivor空間。

方法區(Method Area)、 元空間區(MetaSpace)

方法區同 Java 堆一樣是被所有執行緒共享的區間,用於儲存已被虛擬機器載入的類資訊、常量、靜態變數、即時編譯器編譯後的程式碼。更具體的說,靜態變數+常量+類資訊(版本、方法、欄位等)+執行時常量池存在方法區中。常量池是方法區的一部分。

JDK 8 使用元空間 MetaSpace 代替方法區,元空間並不在JVM中,而是在本地記憶體中

在執行時資料區中包括那幾個區域?

1、執行緒私有區域:1. 程式計數器 2. 虛擬機器棧 3. 本地方方法棧

2、執行緒共享區域:4. 方法區(元空間) 5. 堆

JVM中的執行緒說明

執行緒是一個程式中的執行單元,JVM允許一個應用有多個執行緒並行的執行任務。

在Hotspot JVM中,每個執行緒都與作業系統的本地執行緒之間對映,當一個Java執行緒準備好執行後,此時一個作業系統的本地執行緒也會同時建立,Java執行緒執行終止後,本地執行緒也會回收。

作業系統負責所有執行緒的安排排程到任何一個可用的CPU上。一旦本地執行緒初始化成功,它就會呼叫Java執行緒的run()方法。

JVM執行緒的主要幾類:

  • 虛擬機器執行緒: 這種執行緒的操作是需要JVM到達安全點才會出現,這些操作必須在不同的執行緒中發生的原因是它們都要到達安全點,這樣堆才不會發生變化。這種執行緒的執行型別包括"stop-the-world"的垃圾收集,執行緒棧收集,執行緒掛起以及偏向鎖的撤銷。
  • 週期任務執行緒: 這種執行緒是時間週期事件的體現(比如中斷),它們一般用於週期性操作的排程執行。
  • GC執行緒: 這種執行緒對在JVM中不同類的垃圾收集行為提供了支援。
  • 編譯執行緒: 這種執行緒在執行時會將位元組碼編譯成原生程式碼。
  • 訊號排程執行緒: 這種執行緒接收訊號併傳送給JVM,在它內部通過呼叫適當的方法進行處理。

PC暫存器(PC Register)

PC暫存器介紹

JVM中的程式計數器(Program Counter Register)中,Register的命名源於CPU的暫存器,暫存器儲存指令相關的現場資訊。CPU只有把資料裝載到暫存器才能執行。

這裡,並非廣義上所指的物理暫存器,或許將其翻譯為PC暫存器(或指令計數器)會更加貼切(也稱為程式鉤子),並且也不容易引起一些不必要的誤會。JVM中的PC暫存器是對物理PC暫存器的一種抽象模擬。

PC暫存器用來儲存指向下一條指令的地址,也就是即將要執行的指令程式碼,由執行引擎讀取下一條指令。

  • 它是一塊很小的記憶體空間,也是執行速度最快的儲存區域。
  • 在JVM規範中,每個執行緒都有它自己的PC暫存器,是執行緒私有的,生命週期與執行緒的生命週期保持一致。
  • 任何時間一個執行緒都只有一個方法執行,也就是所謂的當前方法。PC暫存器會儲存當前執行緒正在執行的Java方法的JVM指令地址,如果執行的native方法,則是undefined。
  • 它是程式控制流中的指示器、分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能都需要PC暫存器來完成。
  • 位元組碼直譯器工作時就是通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。
  • 它是唯一一個在Java虛擬機器規範中,沒有規定任何OutOfMemoryError的區域。

使用舉例

public class PCRegister {

    public static void main(String[] args) {
        int i = 20;
        int j = 30;
        int k = i + j;
        String str = "hello";
        System.out.println(str);
    }

}

我們使用jclasslib看一下編譯後:

左側是數字其實就是偏移地址,PC暫存器就是儲存的這個,而右側就是指令。

前面操作比較簡單,其實就是將常量值20壓入棧然後存入索引1的位置,然後將常量值30壓入棧然後存入索引2,然後取出1,2,相加之後存入索引3。

我們重點說一下後面的操作,偏移地址10的位置。

ldc:將int, float或String型常量值從常量池中推送至棧頂。
而後面的#2的位置從下圖常量池中,我們可以看到對應的是String,它又關聯了#27#27對應的UTF-8 字串為:hello。存入索引4的位置。但是我們發現偏移地址從10跳到了12,就是因為我們在ldc中執行了兩步操作。

getstatic:獲取靜態變數引用,並將其引用推到運算元棧中。
我們可以看到它對應的常量池#3對應的屬性 #28.#29 兩個,
#28對應的是Class 找到#34 我們可以看到是java.lang.System,#29對應了#35#36,也就是out和printStream。

然後讀取aload 4 也就是str的值進行輸入,最後return結束。

區域性變數表,運算元棧都是由執行引擎來操作的,再翻譯成機器指令來操作cpu。

問題:使用PC暫存器儲存位元組碼指令地址有什麼用?為什麼使用PC暫存器儲存?

因為CPU需要不停的切換各個執行緒,這個時候切換回來後,需要知道從哪裡接著繼續執行。

JVM的位元組碼直譯器就需要通過改變PC暫存器的值來明確下一條應該執行什麼樣的位元組碼指令。

問題:為什麼是執行緒私有?

多執行緒在一個特定的時間段內指揮執行其中某一個執行緒的方法,CPU會不停地做任務切換,這必然會導致經常中斷或者恢復。

簡單來說就是方便各個執行緒之間可以獨立計算,不會出現相互干擾的問題。

虛擬機器棧

概述

每個執行緒都會有一個虛擬機器棧,多執行緒就會有多個虛擬機器棧。是執行緒私有,虛擬機器棧裡面是一個一個的棧幀(Stack Frame),每一個棧幀都是在方法執行的同時建立的,描述的是Java方法執行的記憶體模型。每一個方法從呼叫開始至執行完成的過程,都對應著一個棧幀在虛擬機器裡面從入棧到出棧的過程。棧是先進後出的。

作用是主管Java程式的執行,它儲存方法的區域性變數、部分結果、並參與方法的呼叫與返回。

在活動執行緒中,只有一個棧幀是處於活躍狀態的,也就是說只有位於棧頂的棧幀才是有效的,稱為當前棧幀,與這個棧幀相關聯的方法稱為當前方法。

執行引擎執行的所有位元組碼指令都只針對當前棧幀進行操作。

優點:跨平臺,指令集小,編譯器容易實現。

缺點:效能下降,實現同樣的功能需要更多的指令。

虛擬機器棧可能丟擲的異常

  • 若是固定大小的JAVA虛擬機器棧,那每一個執行緒的JAVA虛擬機器棧容量可以線上程建立的時候獨立選定,如果執行緒請求分配的棧容量超過JAVA虛擬機器棧允許的最大容量,JAVA虛擬機器將會丟擲一個StackOverflowError錯誤。
  • 若是JAVA虛擬機器棧可以動態擴充套件,並且在嘗試擴充套件時的時候無法申請到足夠的記憶體,或者在建立新的執行緒時沒有足夠的記憶體去建立相應的虛擬機器棧,那JAVA虛擬機器將會丟擲一個OutofMemroyError錯誤。

解決方案:

使用引數 -Xss 選項來設定執行緒最大棧空間,棧的大小直接決定了函式呼叫的最大可達深度。

在啟動引數加入 -Xss256k 或者隨意大小。

棧的儲存單位

  • 每個執行緒都有自己的棧,棧中的資料都是以棧幀(Stack Frame)的格式存在。
  • 在這個執行緒上正在執行的每個方法都各自對應一個棧幀(Stack Frame)。
  • 棧幀是一個記憶體區塊,是一個資料集,維繫著方法執行過程中的各種資料資訊。

棧執行原理

  • 虛擬機器棧的操作只有兩個就是壓棧出棧,遵循後進先出原則。
  • 在一條活動執行緒中,一個時間點上,只會有一個活動的棧幀,即只有當前正在執行的方法的棧幀(位於棧頂)是有效的,這個棧幀被稱為當前棧幀(Current Frame),定義這個方法的類就是當前類(Current Class)
  • 執行引擎執行的所有位元組碼指令只針對當前棧幀進行操作。
  • 如果在該方法呼叫了其它方法,對應新的棧幀就會被建立出來,壓棧後成為新的當前棧幀。
  • 不同執行緒中所包含的棧幀是不允許存在相互引用的,即不可能在一個棧幀中引用另一個執行緒的棧幀。
  • 如果當前方法呼叫了其它方法,方法返回之際,當前棧幀會傳回方法的執行結果給前一個棧幀,然後JVM丟棄掉當前棧幀,之後前一個棧幀變為棧頂的棧幀。
  • Java有兩種返回函式的方式,一種是正常函式返回,一種是丟擲異常返回,不管哪一種都會導致棧幀彈出。

棧幀的內部結構

分為五大類:

  • 區域性變數表(Local Variables)
  • 運算元棧(Operand Stack)
  • 動態連結(Dynamic Linking)
  • 方法返回地址(Return Address)
  • 一些附加資訊

區域性變數表(Local Variables)

  1. 區域性變數表也被稱之為區域性變數陣列或本地變數表。
  2. 定義為一個數字陣列,主要用於儲存方法引數和定義在方體內的區域性變數,這些資料包含基本資料型別,物件引用,以及returnAddress型別。
  3. 由於區域性變數表是建立線上程的棧上,是執行緒的私有資料,因此不存在資料的安全問題
  4. 區域性變數表所需的容量大小是在編譯期間確定下來的,並儲存在方法的Code屬性的maximum local variables資料項中。在方法執行期間是不會改變區域性變數表大小的。
  5. 方法巢狀呼叫的次數由棧的大小來決定。一般來說,棧越大,方法巢狀呼叫次數越多。對一個函式而言,它的引數和區域性變數越多,使得區域性變數表膨脹,它的棧幀就越大,以滿足方法呼叫所需傳遞的資訊增加的需求。進而函式呼叫就會佔用更多的棧空間,導致其巢狀的次數就會減少。
  6. 區域性變數表中的變數只在當前方法呼叫中有效。在方法執行時,虛擬機器通過使用區域性變數表完成引數值到引數變數列表的傳遞過程。當方法呼叫結束後,隨著方法棧幀的銷燬,區域性變數表也會隨之銷燬。
  7. 區域性變數表中最基本的儲存單元是Slot(變數槽)

關於Slot的理解

  1. 在區域性變數表中,32位以內的型別佔一個Slot,64位的型別佔用兩個Slot。
  2. JVM會為區域性變數表中的每一個Slot都分配一個訪問索引,通過這個索引即可成功訪問到區域性變數表中指定的區域性變數值。
  3. 當一個例項方法被呼叫的時候,它的方法引數和方法體內部定義的區域性變數將會按照順序被複制到區域性變數表中的每一個Slot上。
  4. 如果需要訪問區域性變數表中的一個64位的區域性變數值時,只需要使用前一個索引即可。
  5. 如果當前幀是由構造方法或者例項方法建立的,那麼該物件的引用this將會存放在index為0的Slot處,其餘引數按照順序排列。

程式碼小例子:

public String test(Date dateP,String name2){
        dateP = null;
        name2 = "Jack";
        double weight = 1.1;
        char gender = '男';
        return dateP + name2;
}

我們使用jclasslib來看的話可以看到Index也就是Slot,我們發現3也就是double是佔了兩個Slot的。

運算元棧

  • 每一個獨立的棧幀中除了包含區域性變數表以外,還包含一個後進先出的運算元棧,也稱之為表示式棧
  • 運算元棧,在方法執行的過程中,根據位元組碼指令、往棧中寫入或取出資料,即入棧/出棧
  • 某些位元組碼指令將值壓入運算元棧,其餘的位元組碼指令將運算元取出棧,進行操作之後再將結果壓入棧。
  • 比如:複製、交換、求和等操作。

  • 如果被呼叫的方法帶有返回值的話,其返回值將會被壓入當前棧幀的運算元棧中,並更新PC暫存器中下一條需要執行的位元組碼指令。
  • 運算元棧中元素的資料型別必須與位元組碼指令的序列嚴格匹配,這由編譯器在編譯器期間進行驗證,同時在類載入過程中的類檢驗階段的資料流分析階段要再次驗證。
  • 運算元棧,主要用於儲存計算過程的中間結果,同時作為計算過程中變數臨時的儲存空間。
  • 運算元棧是JVM執行引擎的一個工作區,當一個方法開始執行的時候,一個新的棧幀就會隨之建立出來,這個時候方法的運算元棧是空的。
  • 每一個運算元棧都會有一個明確的棧深度用於儲存數值,其所需的最大深度在編譯期間就已經定義好了,儲存在方法的Code屬性中,為max_stack的值。
  • 棧中任意一個元素都可以是任意的Java資料型別。

    • 32bit佔用一個棧單位深度
    • 64bit佔用二個棧單位深度

運算元棧的位元組碼指令分析

首先我們建立如下簡單的程式碼:

public class OperandStackTest {

    public void testAddOperand(){
        byte i = 15;
        int j = 8;
        int k = i + j;

    }

}

使用jclasslib反編譯以後我們看到如下指令:

 Code:
    stack=2, locals=4, args_size=1
        0 bipush 15
        2 istore_1
        3 bipush 8
        5 istore_2
        6 iload_1
        7 iload_2
        8 iadd
        9 istore_3
        10 return

在標註灰色的地方,我們看一看到指令地址是0,所以右側的PC暫存器就是0,bipush操作就是將常量值15存入我們的運算元棧的棧頂,現在區域性變數表中還是初始化的狀態。

當指令執行到了2的位置,PC暫存器裡存放的就是2,執行的istore指令,將運算元棧中資料取出存入對應的區域性變數表中。

當指令執行到了3的位置,PC暫存器裡存放的就是3,bipush操作就是將常量值8存入我們的運算元棧的棧頂,現在區域性變數表中只有對應下標為i的值為15。

當指令執行到了5的位置,PC暫存器裡存放的就是5,執行的istore指令,將運算元棧中資料取出存入對應的區域性變數表中。

當指令執行到了6的位置,PC暫存器裡存放的就是6,執行的iload指令,將區域性變數表中的資料取出存入運算元棧的棧頂。(指令地址7也同理)

當指令執行到了8的位置,PC暫存器裡存放的就是8,執行的iadd指令,將棧頂的兩個資料取出進行相加,將結果存入運算元棧棧頂。(相加操作由執行引擎將位元組碼指令來翻譯成機器指令來操作cpu。)

當指令執行到了9的位置,PC暫存器裡存放的就是9,執行的istore指令,將棧頂的元素取出存入對應的區域性變數表中。

stack=2, locals=4, args_size=1

stack對應的就是我們的運算元棧的深度。

locals對應的就是我們的區域性變數表的長度。

args_size對應的就是引數的長度,靜態程式碼塊為0。

動態連結(或指向執行時常量池的方法引用)

  • 每一個棧幀內部都包含一個指向執行時常量池中棧幀所屬方法的引用。包含這個引用的目的就是為了支援當前方法的程式碼能夠實現動態連結。比如:invokedynamic指令
  • 在Java原始檔中被編譯到位元組碼檔案中時,所有的變數和方法引用都作為符號引用(Symbolic Reference)儲存在class檔案的常量池中指向方法的符號引用來表示的,那麼動態連結的作用就是為了將這些符號引用轉換為呼叫方法的直接飲用
public class DynamicLinkingTest {

    int num = 10;

    public void methodA() {
        System.out.println("methodA...");
    }

    public void method() {
        System.out.println("methodB...");
        methodA();
        num++;
    }
}

使用jclasslib反編譯以後我們看到如下指令:

 Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokevirtual #2                  // Method a:()V
         4: return

invokevirtual 後面的#2符號引用對應的就是Constant pool中的直接引用。
#2對應了方法引用,#3#17……最終對應到方法A()

Constant pool:
   #1 = Methodref          #4.#16         // java/lang/Object."<init>":()V
   #2 = Methodref          #3.#17         // com/suanfa/jvm/OperandStackTest.a:()V
   #3 = Class              #18            // com/suanfa/jvm/OperandStackTest
   #4 = Class              #19            // java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               Lcom/suanfa/jvm/OperandStackTest;
  #12 = Utf8               a
  #13 = Utf8               b
  #14 = Utf8               SourceFile
  #15 = Utf8               OperandStackTest.java
  #16 = NameAndType        #5:#6          // "<init>":()V
  #17 = NameAndType        #12:#6         // a:()V
  #18 = Utf8               com/suanfa/jvm/OperandStackTest
  #19 = Utf8               java/lang/Object

方法呼叫

在JVM中,將符號引用轉換為呼叫方法的直接引用與方法的繫結機制有關。

靜態連結

當一個位元組碼檔案被裝載進JVM內部時,如果被呼叫的目標方法在編譯期可知,且執行期間保持不變時。這種情況下將呼叫方法的符號引用轉換為直接引用的過程稱之為靜態連結。

動態連結

如果被呼叫方法在編譯期間無法被確定下來,只能在程式執行時將呼叫方法的符號引用轉換為直接引用,由於這種引用轉換的過程具備動態性,因此也就被稱為動態連結。

對應的繫結機制為:早期繫結(Early Binding)、晚期繫結(Late Binding)。繫結是一個欄位、方法或者類在符號引用被替換為直接引用,這個過程僅發生一次。

早期繫結

早期繫結就是指被呼叫的目標方法如果在編譯期可知,且執行期保持不變時,即可將這個方法所屬的型別進行繫結,這樣一來,由於明確了被呼叫方法究竟是哪一個,因此也就可以使用靜態連結的方式將符號引用替換為直接引用。

晚期繫結

如果被呼叫的方法在編譯期無法被確定下來,只能夠在程式執行期根據實際的型別繫結相關方法這種繫結就叫做晚期繫結。

方法的呼叫:虛方法與非虛方法

如果方法在編譯期就確定了具體的呼叫版本,這個版本在執行時是不可變的。這樣的方法稱之為非虛方法。

靜態變數、私有方法、final方法、例項構造器、父類方法都是非虛方法。

其它方法稱之為虛方法、

普通呼叫指令:

  1. invokestatic : 靜態方法,解析階段確定唯一方法版本
  2. invokespecial : 呼叫<init>方法、私有方法以及父類方法,解析階段確定唯一方法版本
  3. invokevirtual : 呼叫所有虛方法
  4. invokeinterface : 呼叫介面方法

動態呼叫指令:

  1. invokedynamic : 動態解析所需要呼叫的方法,然後執行
前四條指令固化在虛擬機器的內部,方法的呼叫執行不可人為干預,而invokedynamic指令則支援由使用者確定版本。其中invokevirtual和invokestatic指令呼叫的方法稱為非虛方法,其餘的(final修飾除外)稱為虛方法。
/**
 * 解析呼叫中非虛方法、虛方法的測試
 */
class Father {
    public Father(){
        System.out.println("Father預設構造器");
    }

    public static void showStatic(String s){
        System.out.println("Father show static"+s);
    }

    public final void showFinal(){
        System.out.println("Father show final");
    }

    public void showCommon(){
        System.out.println("Father show common");
    }

}

public class Son extends Father{
    public Son(){
        super();
    }

    public Son(int age){
        this();
    }

    public static void main(String[] args) {
        Son son = new Son();
        son.show();
    }

    //不是重寫的父類方法,因為靜態方法不能被重寫
    public static void showStatic(String s){
        System.out.println("Son show static"+s);
    }

    private void showPrivate(String s){
        System.out.println("Son show private"+s);
    }

    public void show(){
        //invokestatic
        showStatic(" 大頭兒子");
        //invokestatic
        super.showStatic(" 大頭兒子");
        //invokespecial
        showPrivate(" hello!");
        //invokespecial
        super.showCommon();
        //invokevirtual 因為此方法宣告有final 不能被子類重寫,所以也認為該方法是非虛方法
        showFinal();
        //虛方法如下
        //invokevirtual
        showCommon();//沒有顯式加super,被認為是虛方法,因為子類可能重寫showCommon
        info();

        MethodInterface in = null;
        //invokeinterface  不確定介面實現類是哪一個 需要重寫
        in.methodA();

    }

    public void info(){

    }

}

interface MethodInterface {
    void methodA();
}

invokedynamic指令

  • invokedynamic指令是在JDK7中增加的,為了實現動態型別語言支援而做的一種改進。
  • 但是在JDK7中並沒有提供直接生成的invokedynamic指令的方法,需要藉助ASM這種底層位元組碼工具來產生invokedynamic指令。直到JDK8的Lambda表示式的出現,invokedynamic指令的生成,在Java中才有了直接生成的方式。

方法返回地址(Return Address)

  • 存放呼叫該方法的PC暫存器的值。
  • 一個方法的結束要麼正常執行結束,要麼出現未處理異常,非正常退出。
  • 無論哪種方式退出,在方法退出後都返回到該方法被呼叫的位置。方法正常退出時,呼叫者的PC暫存器的值作為返回地址,即呼叫該方法的下一條指令地址,而通過異常退出的,返回地址是要通過異常表來確定,棧幀中不會儲存這部分資訊。

區別在於,通過異常完成的出口不會給它上層呼叫者產生任何的返回值

棧的相關面試題

  • 舉例棧溢位的情況?

    棧幀存放空間不足導致出現StackOverflowError異常。通過 -Xss設定棧的大小。

  • 調整棧的大小,就能保證不出現溢位嗎?

    不能保證。

  • 分配的棧記憶體越大越好嗎?

    不是。在同一臺機器上,如果jvm設定的記憶體過大,就會導致其它程式所佔用的記憶體小。比如elasticsearch、kafka,雖然它們都是基於jvm執行的程式(java和scala都是依賴於jvm),但是它們的資料不是放到jvm記憶體中,而是放到os cache中(作業系統管理的記憶體區域),避免了jvm垃圾回收的影響。

  • 垃圾回收是否會涉及到虛擬機器棧?

    不會

    執行時資料區ErrorGC
    程式計數器××
    虛擬機器棧×
    本地方法棧×
    方法區
  • 方法中定義的區域性變數是否執行緒安全?

    不一定,可能會發生方法逃逸。

public StringBuilder escapeDemo1() {
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.append("a");
    stringBuilder.append("b");
    return stringBuilder;
}

方法逃逸:在一個方法體內,定義一個區域性變數,而它可能被外部方法引用,比如作為呼叫引數傳遞給方法,或作為物件直接返回。或者,可以理解成物件跳出了方法。

本地方法棧

什麼是本地方法?

一個Native Method是這樣的Java方法:該方法的實現由非Java語言實現,比如C。

本地方法的作用就是為了融合不同程式語言為Java所用。

使用native關鍵字修飾的方法就是本地方法。

本地方法棧簡介

  • Java虛擬機器棧用於管理Java方法的呼叫,而本地方法棧用於管理本地方法的呼叫。
  • 本地方法棧也是執行緒私有的。
  • 允許被實現成固定或者可動態擴充套件的記憶體大小。
  • 本地方法是用C語言實現的。
  • 具體做法就是Native Method Stack中登記native方法,在Execution Engine 執行時載入本地方法庫。

  • 當某個執行緒呼叫一個本地方法時,它就進入了一個全新的並且不再受虛擬機器限制的世界。它和虛擬機器擁有同樣的許可權。
  1. 本地方法可以通過本地方法介面來訪問虛擬機器內部的執行時資料區。2. 它甚至可以直接使用本地處理器中的暫存器。3. 直接從本地記憶體的堆中分配任意數量的記憶體。

概述

  • 一個JVM例項只存在一個堆記憶體,堆也是Java記憶體管理的核心區域。
  • Java堆區在JVM啟動的時候即被建立,其空間大小也就確定了。是JVM管理的最大一塊記憶體空間。
  • 堆記憶體的大小是可以調節的。

《Java虛擬機器規範》規定,堆可以處於物理上不連續的記憶體空間中,但在邏輯上它應該被視為連續的。

  • 所有的執行緒共享Java堆,在這裡還可以劃分執行緒私有的緩衝區( ThreadLocal Allocation Buffer, TLAB) 。
  • 《Java虛擬機器規範》中對Java堆的描述是:所有的物件例項以及陣列都應當在執行時分配在堆上。(The heap is the run-time data area fromwhich memory for all class instances and arrays is allocated )
  • 我要說的是:“幾乎”所有的物件例項都在這裡分配記憶體。從實際.使用角度看的,陣列和物件可能永遠不會儲存在棧上,因為棧幀中儲存引用,這個引用指向物件或者陣列在堆中的位置。
  • 在方法結束後,堆中的物件不會馬上被移除,僅僅在垃圾收集的時候才會被移除。
  • 堆是GC ( Garbage Collection,垃圾收集器)執行垃圾回收的重點區域。

堆的核心概述

堆空間大小的設定

Java堆區用於儲存Java物件例項,那麼堆的大小在JVM啟動時就已經設定好了,可以通過選項"-Xmx"和"-Xms"來進行設定。

  • "-Xms"用 於表示堆區的起始記憶體,等價於-XX: InitialHeapSize
  • "-Xmx" 則用於表示堆區的最大記憶體,等價於-XX :MaxHeapSize
  • 一旦堆區中的記憶體大小超過“-Xmx"所指定的最大記憶體時,將會丟擲OutOfMemoryError異常。
  • 通常會將-Xms和-Xmx兩個引數配置相同的值,其目的是為了能夠在java垃圾回收機制清理完堆區後不需要重新分隔計算堆區的大小,從而提高效能。
  • 預設情況下,初始記憶體大小:物理電腦記憶體大小 / 64,最大記憶體大小:物理電腦記憶體大小 / 4。

年輕代和老年代

儲存在JVM中的Java物件可以分為兩類:

  • 一類是生命週期較短的瞬時物件,這類物件的建立和消亡都非常迅速。
  • 另外一類物件的生命週期較長,在某些極端的情況下還能夠與JVM的生命週期保持一致。

Java堆區進一步細分的話,可以劃分為年輕代(YoungGen)和老年代(OldGen)

其中年輕代又劃分為Eden空間、Survivor0和Survivor1空間(也叫做from區、to區)。

在Hotspot中,Eden空間和Survivor0和Survivor1空間預設比例是8:1:1。

也可以使用"-XX:SurvivorRatio" 調整這個空間比例。比如-XX:SruvivorRatio=8。

幾乎所有的Java物件都在Eden區被new出來的。

物件的分配過程

為新物件分配記憶體是一件非常嚴謹和複雜的任務,JVM的設計者們不僅需要考慮記憶體如何分配、在哪裡分配等問題,並且由於記憶體分配演算法與記憶體回收演算法密切相關,所以還需要考慮GC執行完記憶體回收後是否會在記憶體空間中產生記憶體碎片。

1、new的物件先放在Eden區,此區域有大小限制。

2、當Eden的空間填滿時,程式又需要建立新的物件,JVM的垃圾回收器將對Eden區不再被其它物件所引用的物件進行銷燬。再載入新的物件放到Eden。

3、 然後將Eden區剩餘的物件移動到Survivor0區。

4、如果再次觸發垃圾回收,此時上次倖存下來的放在Survivor0區,如果沒有回收,就會放到Survivor1區。

5、 如果再次經歷垃圾回收,此時會重新放回Survivor0區,接著再去Survivor1區。

6、 當"年齡"到達15之後就會被放到old區。可以設定引數:-XX:MaxTenuringThreshold=<N>

7、 當old區記憶體不足時,再次觸發 GC:Major GC,進行old區記憶體清理。

8、 如果old區在進行了GC後依然無法進行物件的儲存,就會產生OOM異常。

關於垃圾回收:頻繁在新生區收集,很少在養老區收集,幾乎不在永久區/元空間收集。

分配記憶體的特殊情況

如果物件一開始就過大,如果Eden區放不下的話會直接放入old區。

如果old區也放不下,則會發生Full GC 。如果GC後還是放不下則會報錯OOM。

Minor GC、Major GC、 Full GC對比

JVM在進行GC時,並非每次都對上面三個記憶體(新生代、老年代;方法區/元空間)區域一起回收的,大部分時候回收的手是指新生代。

針對Hotspot VM的實現,它裡面的GC按照回收區域又分為兩大類,一種是部分收集(Partial GC),一種是完整收集(Full GC)。

  • 部分收集

    • 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集。
    • 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
    • 混淆收集(Mixed GC):收集整個新生代以及部分老年代的垃圾收集。

目前只有CMS GC會有單獨收集老年代的行為。

注意,很多時候Major GC會和Full GC混淆使用,需要具體分辨是老年代回收還是整堆回收。

目前只有G1 GC會有這種行為。

  • 整堆收集(Full GC):收集整個Java堆和方法區的垃圾收集。

年輕代GC(Minor GC)觸發機制

當年輕代空間不足時候,就會觸發Minor GC,這裡的年輕代滿指的是Eden區滿,Survivor滿不會觸發GC。(每次Minor GC就會清理Eden區記憶體)

因為Java物件大多都具備朝生夕滅的特性,所以 Minor GC非常頻繁,一般回收速度也比較快。

Minor GC會引發STW,暫停其它使用者執行緒,等垃圾回收結束,使用者執行緒才會恢復執行。

老年代GC(Major GC/Full GC)觸發機制

指發生在老年代的GC,物件從老年代消失時,我們說”Major GC“ 或者 ”Full GC”發生了。

出現了Major GC,經常會伴隨至少一次Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略裡就有直接進行Major GC的策略選擇過程)。

也就是在老年代空間不足時,先嚐試觸發Minor GC,如果之後空間還是不足,則觸發Major GC。

Major GC的速度一般會比Minor GC慢10倍以上,STW的時間更長。

如果Major GC後記憶體還不足就會報OOM了。

Full GC觸發機制

  1. 呼叫System.gc()時,系統建議執行Full GC,但是不必然執行。
  2. 老年代空間不足
  3. 方法區空間不足
  4. 通過Minor GC後進入老年代的平均大小大於老年代的可用記憶體
  5. 由Eden區、Survivor0向Survivor1區複製時,物件大小大於Survivor1可用記憶體,則把物件轉移到老年代,且老年代的可用記憶體小於該物件大小時。

說明:在開發中儘量避免 Full GC,這樣STW時間會更短

TLAB

為什麼要有TLAB(Thread Local Allocation Buffer)

堆區是執行緒共享區域,任何執行緒都可以訪問到堆區中的共享資料。

由於物件例項的建立在JVM中非常頻繁,因此在併發環境下從堆區中劃分記憶體空間是不安全的。

為避免多個執行緒操作統一地址,需要使用加鎖等機制,進而影響分配速度。

什麼是TLAB

從記憶體模型而不是垃圾收集的角度,對Eden區繼續進行劃分,JVM為每個執行緒分配了一個私有快取區域,它包含在Eden區內。

多執行緒同時分配記憶體時,使用TLAB可以避免一系列的非執行緒安全問題,同時還能夠提升記憶體分配的吞吐量,因此我們可以將這種記憶體分配方式稱為快速分配策略

TLAB說明

儘管不是所有的物件例項都能夠在TLAB中成功分配記憶體,但JVM的確是將TLAB作為記憶體分配的首選

在程式中,開發人員可以通過選項"-XX:UseTLAB"設定是否開啟TLAB空間。

預設情況下,TLAB空間的記憶體非常小,僅佔有整個Eden空間的1%,可以通過"-XX:TLABWasteTargetPercent"設定TLAB空間所佔用Eden空間的百分比大小。

一旦物件在TLAB空間分配記憶體失敗時,JVM就會嘗試通過使用加鎖機制確保資料操作的原子性,從而直接在Eden空間中分配記憶體。

堆是分配物件儲存的唯一選擇嗎?

  • 在Java虛擬機器中,物件是在Java堆中分配記憶體的,這是一個普遍的常識。但是,有一種特殊情況,那就是如果經過逃逸分析( Escape Analysis)後發現,一個物件並沒有逃逸出方法的話,那麼就可能被優化成棧上分配。這樣就無需在堆上分配記憶體,也無須進行垃圾回收了。這也是最常見的堆外儲存技術。
  • 此外,基於 OpenJDK深度定製的 TaoBaoVM,其中創新的GCIH(GC invisible heap)技術實現off-heap,將生命週期較長的Java物件從heap中移至heap外,並且GC不能管理GCIH內部的Java物件,以此達到降低Gc的回收頻率和提升GC的回收效率的目的。

逃逸分析手段

  • 如何將堆上的物件分配到棧,需要使用逃逸分析手段
  • 這是一種可以有效減少Java程式中同步負載和記憶體堆分配壓力的跨函式全域性資料流分析演算法。
  • 通過逃逸分析,Java Hotspot編譯器能夠分析出一個新的物件的引用的使用範圍從而決定是否要將這個物件分配到堆上。

逃逸分析的基本行為就是分析物件動態作用域:

  • 當一個物件在方法中被定義後,物件只在方法內部使用,則認為沒有發生逃逸。
  • 當一個物件在方法中被定義後,它被外部方法所引用,則認為發生逃逸。例如作為呼叫引數傳遞到其它地方中。

結論:開發中能用區域性變數的,就不要使用在方法外定義。

方法區

執行時資料區圖解

棧、堆、方法區、的互動關係

方法區基本理解

《Java虛擬機器規範》中明確說明:“儘管所有的方法區在邏輯上是屬於堆的一部分,但一些簡單的實現可能不會選擇去進行垃圾收集或者進行壓縮。” 但對於HotspotJVM而言,方法區還有一個別名叫做Non-Heap,目的就是要和堆分開。

所以方法區看作是一塊獨立於Java堆的記憶體空間。

  • 方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域。
  • 方法區在JVM啟動的時候被建立,並且它的實際的實體記憶體空間中和Java堆區一樣都可以是不連續的。
  • 方法區的大小,和堆空間一樣,可以選擇固定大小和可擴充套件。
  • 方法區的大小決定了系統可以儲存多少個類,如果系統定義了太多的類,導致方法區溢位,虛擬機器就會丟擲記憶體溢位錯誤:java.lang.OutOfMemoryError:PermGen space 或者 java.lang.OutOfMemoryError: Metaspace。
  • 關閉JVM就會釋放這個區域的記憶體。

設定方法區記憶體大小

JDK7及以前(永久代):

  • 通過"-XX:PermSize"設定永久代初始分配空間,預設值20.75M。
  • "-XX:MaxPermSize"來設定永久代最大可分配空間。32位機器預設是64M,64位機器預設是82M。
  • 當JVM載入的類資訊容量超過了這個值,則會報出OutOfMemoryError:Permgen Space。

JDK8(元空間):

  • 後設資料區大小可以使用引數-XX:MetaspaceSize和-XX:MaxMetaspaceSize指定,替代上述原有的兩個引數。
  • 預設值依賴於平臺。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1, 即沒有限制。
  • 與永久代不同,如果不指定大小,預設情況下,虛擬機器會耗盡所有的可用系統記憶體。 如果後設資料區發生溢位,虛擬機器一樣會丟擲異常OutOfMemoryError: Metaspace。
  • -XX:MetaspaceSize: 設定初始的元空間大小。對於一個64位的伺服器端JVM來說, 其預設的-XX:MetaspaceSize值為21MB.這就是初始的高水位線,一旦觸及這個水位線,Full GC將會被觸發並解除安裝沒用的類(即這些類對應的類載入器不再存活),然後這個高水位線將會重置。新的高水位線的值取決於GC後釋放了多少元空間。如果釋放的空間不足,那麼在不超過MaxMetaspaceSize時,適當提高該值。如果釋放空間過多,則適當降低該值。
  • 如果初始化的高水位線設定過低,上述高水位線調整情況會發生很多次。通過垃圾回收器的日誌可以觀察到Full GC多次呼叫。為了避免頻繁地GC,建議將-XX:MetaspaceSize設定為一個相對較高的值。
jdk7及以前:
查詢 jps  -> jinfo -flag PermSize [程式id]

-XX:PermSize=100m -XX:MaxPermSize=100m

jdk8及以後:

查詢 jps  -> jinfo -flag MetaspaceSize [程式id]

-XX:MetaspaceSize=100m  -XX:MaxMetaspaceSize=100m

解決報錯OOM:(記憶體洩漏、記憶體溢位)

  1. 要解決00M異常或heap space的異常,一般的手段是首先通過記憶體映像分析工具(如Eclipse Memory Analyzer) 對dump出來的堆轉儲快照進行分析,重點是確認記憶體中的物件是否是必要的,也就是要先分清楚到底是出現了記憶體洩漏(Memory Leak)還是記憶體溢位(Memory 0verflow)。
  2. 如果是記憶體洩漏,可進一步通過工具檢視洩漏物件到GC Roots 的引用鏈(堆當中的閒置物件由於引用鏈的引用關係無法被回收,雖然它已經屬於閒置的資源)。於是就能找到洩漏物件是通過怎樣的路徑與GCRoots相關聯並導致垃圾收集器無法自動回收它們的。掌握了洩漏物件的型別資訊,以及GC Roots引用鏈的資訊,就可以比較準確地定位出洩漏程式碼的位置。
  3. 如果不存在記憶體洩漏,換句話說就是記憶體中的物件確實都還必須存活著,那就應當檢查虛擬機器的堆引數(一Xmx與一Xms) ,與機器實體記憶體對比看是否還可以調大,從程式碼_上檢查是否存在某些物件生命週期過長、持有狀態時間過長的情況,嘗試減少程式執行期的記憶體消耗。

程式碼案例:

/**
 * jdk6/7中:
 * -XX:PermSize=10m -XX:MaxPermSize=10m
 *
 * jdk8中:
 * -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
 *
 */
public class OOMTest extends ClassLoader {

    public static void main(String[] args) {
        int j = 0;
        try {
            OOMTest test = new OOMTest();
            for (int i = 0; i < 10000; i++) {
                //建立ClassWriter物件,用於生成類的二進位制位元組碼
                ClassWriter classWriter = new ClassWriter(0);
                //指明版本號,修飾符,類名,包名,父類,介面
                classWriter.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                //返回byte[]
                byte[] code = classWriter.toByteArray();
                //類的載入
                test.defineClass("Class" + i, code, 0, code.length);//Class物件
                j++;
            }
        } finally {
            System.out.println(j);
        }
    }
}

方法區的內部結構

《深入理解Java虛擬機器》書中對方法區儲存內容描述如下:它用於儲存已被虛擬機器載入的 型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等。

型別資訊

對每個載入的型別( 類class、介面interface、列舉enum、註解annotation),JVM必 .須在方法區中儲存以下型別資訊:

  1. 這個型別的完整有效名稱(全名=包名.類名)。
  2. 這個型別直接父類的完整有效名(對於interface或是java. lang.Object,都沒有父類)。
  3. 這個型別的修飾符(public, abstract, final的某個子集)。
  4. 這個型別直接介面的一個有序列表。

域(Field)資訊

  1. JVM必須在方法區中儲存型別的所有域的相關資訊以及域的宣告順序。
  2. 域的相關資訊包括:域名稱、 域型別、域修飾符(public, private, protected, static, final, volatile, transient的某個子集)。

方法資訊(method)

JVM必須儲存所有方法的以下資訊,同域資訊一樣包括宣告順序:

  1. 方法名稱。
  2. 方法的返回型別(或void)。
  3. 方法引數的數量和型別(按順序)。
  4. 方法的修飾符(public, private, protected, static, final, synchronized, native , abstract的一個子集)。
  5. 方法的位元組碼(bytecodes)、運算元棧、區域性變數表及大小( abstract和native 方法除外)。
  6. 異常表( abstract和native方法除外),每個異常處理的開始位置、結束位置、程式碼處理在程式計數器中的偏移地址、被捕獲的異常類的常量池索引。

non-final的類變數(非宣告為final的static靜態變數)

  1. 靜態變數和類關聯在一起,隨著類的載入而載入,它們成為類資料在邏輯上的一部分。
  2. 類變數被類的所有例項所共享,即使沒有類例項你也可以訪問它。

全域性常量(static final)

被宣告為final的類變數的處理方法則不同,每個全域性常量在編譯的
時候就被分配了。

public static int count = 1;
public static final int number = 2;

反編譯後就可以看到如下程式碼:

public static int count;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC

  public static final int number;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 2

檔案中常量池的理解

一個有效的位元組碼檔案中除了包含類的版本資訊、欄位、方法以及介面等描述資訊外,還包含一項資訊那就是常量池表(Constant Poo1 Table),包括各種字面量和對型別域和方法的符號引用。

一個 java 原始檔中的類、介面,編譯後產生一個位元組碼檔案。而 Java 中的位元組碼需要資料支援,通常這種資料會很大以至於不能直接存到位元組碼裡,換另一種方式,可以存到常量池;而這個位元組碼包含了指向常量池的引用。在動態連結的時候會用到執行時常量池。

小結:位元組碼當中的常量池結構(constant pool),可以看做是一張表,虛擬機器指令根據這張常量表找到要執行的類名,方法名,引數型別、字面量等資訊。

執行時常量池

  • 執行時常量池( Runtime Constant Pool)是方法區的一部分。
  • 常量池表(Constant Pool Table)是Class檔案的一部分,用於存放編譯期生成的各種字面量與符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。
  • 執行時常量池,在載入類和介面到虛擬機器後,就會建立對應的執行時常量池。
  • JVM為每個已載入的型別(類或介面)都維護一個常量池。池中的資料項像陣列項一樣,是通過索引訪問的。
  • 執行時常量池中包含多種不同的常量,包括編譯期就已經明確的數值字面量,也包括到執行期解析後才能夠獲得的方法或者欄位引用。此時不再是常量池中的符號地址了,這裡換為真實地址。

執行時常量池,相對於Class檔案常量池的另一重要特徵是:具備動態性。

  • 執行時常量池類似於傳統程式語言中的符號表(symbol table) ,但是它所包含的資料卻比符號表要更加豐富一些。
  • 當建立類或介面的執行時常量池時,如果構造執行時常量池所需的記憶體空間超過了方法區所能提供的最大值,則JVM會拋OutOfMemoryError異常。

方法區的演進細節

首先明確:只有HotSpot才有永久代。 BEA JRockit、IBM J9等來說,是不存在永久代的概念的。原則上如何實現方法區屬於虛擬機器實現細節,不受《Java虛擬機器規範》管束,並不要求統一。

Hotspot中 方法區的變化:

  • jdk1.6及之前:有永久代(permanent generation) ,靜態變數存放在 永久代上。
  • jdk1.7:有永久代,但已經逐步“去永久代”,字串常量池、靜態變數移除,儲存在堆中。
  • jdk1.8及之後: 無永久代,型別資訊、欄位、方法、常量儲存在本地記憶體的元空間,但字串常量池、靜態變數仍留在堆空間。

永久代為什麼要被元空間替換

  • 隨著Java8的到來,HotSpot VM中再也見不到永久代了。但是這並不意味著類.的後設資料資訊也消失了。這些資料被移到了一個與堆不相連的本地記憶體區域,這個區域叫做元空間( Metaspace )。
  • 由於類的後設資料分配在本地記憶體中,元空間的最大可分配空間就是系統可用記憶體空間。

這項改動是很有必要的,原因有:

  1. 為永久代設定空間大小是很難確定的。 在某些場景下,如果動態載入類過多,容易產生Perm區(永久代)的O0M。比如某個實際Web工程中,因為功能點比較多,在執行過程中,要不斷動態載入很多類,經常出現致命錯誤。 "Exception in thread' dubbo client x.x connector’java.lang.OutOfMemoryError:PermGenspace" 而元空間和永久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體。因此,預設情況下,元空間的大小僅受本地記憶體限制。
  2. 對永久代進行調優是很困難的。

StringTable為什麼要調整

  • jdk7中將StringTable放到了堆空間中,正確。
  • 因為永久代的回收頻率很低,在Full GC的時候才會觸發。而Full GC是老年代的空間不足、永久代不足時才會觸發。這就導致了StringTable回收效率不高。而我們開發中會有大量的字串被建立,回收效率低,導致永久代記憶體不足。放到堆裡,能及時回收記憶體.

方法區的垃圾回收

有些人認為方法區(如Hotspot,虛擬機器中的元空間或者永久代)是沒有垃圾收集行為的,其實不然。《Java 虛擬機器規範》對方法區的約束是非常寬鬆的,提到過可以不要求虛擬機器在方法區中實現垃圾收集。事實上也確實有未實現或未能完整實現方法區型別解除安裝的收集器存在(如 JDK11 時期的ZGC 收集器就不支援類解除安裝)。

一般來說這個區域的回收效果比較難令人滿意,尤其是型別的解除安裝,條件相當苛刻。但是這部分割槽域的回收有時又確實是必要的。以前 Sun 公司的 Bug 列表中,曾出現過的若干個嚴重的 Bug 就是由於低版本的 Hotspot 虛擬機器對此區域未完全回收而導致記憶體洩漏。

方法區的垃圾收集主要回收兩部分內容:常量池中廢奔的常量不再使用的型別。

常量池中廢奔的常量

  • 先來說說方法區內常量池之中主要存放的兩大類常量:字面量和符號引用。 字面量比較接近Java語言層次的常量概念,如文字字串、被宣告為final的常量值等。而符號引用則屬於編譯原理方面的概念。

常量池中包括下面三類常量:

  1. 類和介面的全限定名
  2. 欄位的名稱和描述符
  3. 方法的名稱和描述符
  • HotSpot虛擬機器對常量池的回收策略是很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收。回收廢棄常量與回收Java堆中的物件非常類似。

常量池中不再使用的型別

判定一個常量是否“廢棄”還是相對簡單,而要判定一個型別是否屬於“不再被使用的類”的條件就比較苛刻了。需要同時滿足下面三個條件:

  1. 該類所有的例項都已經被回收,也就是Java堆中不存在該類及其任何派生子類的例項。
  2. 載入該類的類載入器已經被回收,這個條件除非是經過精心設計的可替換類載入器的場景,如OSGi、JSP的重載入等,否則通常是很難達成的。
  3. 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

Java虛擬機器被允許對滿足上述三個條件的無用類進行回收,這裡說的僅僅是“被允許”,而並不是和物件一樣,沒有引用了就必然會回收。關於是否要對型別進行回收,HotSpot虛擬機器提供了一Xnoclassgc 引數進行控制,還可以使用一verbose:class以及一XX: +TraceClass一Loading、一XX:+TraceClassUnLoading查 看類載入和解除安裝資訊。

在大量使用反射、動態代理、CGLib等位元組碼框架,動態生成JSP以及oSGi這類頻繁自定義類載入器的場景中,通常都需要Java虛擬機器具備型別解除安裝的能力,以保證不會對方法區造成過大的記憶體壓力。

執行時資料區總結

相關文章