JVM學習-執行時資料區域

傑哥很忙發表於2021-01-26


JVM學習-執行時資料區域

前言

本系列文章梳理了對《深入理解Java虛擬機器》和《Java虛擬機器規範(Java SE 8版)》兩本書的學習內容。

其中本文對JAVA執行時的資料區的基礎知識知識進行整理。我們如果要對程式記憶體佔用高的問題進行分析,首先我們需要了解具體是什麼資料導致記憶體佔用高,然後對具體的問題再具體分析。

執行時資料區

Java虛擬機器在執行Java程式的過程中,會把它所管理的記憶體劃分以下幾個區域:程式計數器、Java堆、方法區、虛擬機器棧 、本地方法棧。另外還有不在Java虛擬機器直接管理的堆外記憶體,也被稱為直接(Native)記憶體。

Java執行環境是單程式多執行緒的,多個執行緒通過執行緒切換輪流分配處理器執行時間的方式來實現的,而實際的執行緒排程是由作業系統控制的。使用者執行緒通過程式計數器和虛擬機器棧用來儲存執行緒執行所必須的上下文資訊。每個執行緒都有自己的程式計數器和虛擬機器棧。

20210119201727.png

程式計數器

程式計數器在JAVA虛擬機器規範中稱為Program Counter Register,即為PC暫存器,它可以看作當前執行緒所執行的位元組碼行號指示器,位元組碼直譯器工作時通過改變這個計數器的值來選取下一條需要執行的位元組碼指令。

需要注意,只有執行的是非本地(Native)方法,程式暫存器才會記錄JAVA虛擬機器正在執行的位元組碼指令地址,若當前執行方法是本地方法,則程式計數器的值為空(Undefined)。

Java虛擬機器棧

和程式計數器一樣,每一個JAVA虛擬機器執行緒都有自己私有的JAVA虛擬機器棧。Java虛擬機器規範允許Java虛擬機器棧被實現為固定大小,也允許動態擴充套件和收縮。

當執行緒請求的棧深度大於虛擬機器允許的棧深度,則會丟擲StackOverflowError異常。當棧動態擴充套件無法申請到足夠的記憶體時,則會排除OutOfMemoryError異常。

20210110204312.png

每個方法執行的時候當前執行執行緒會在Java虛擬機器棧中分配當前方法的棧幀,用於儲存區域性變數表、運算元棧、動態連結。當方法執行完後,棧幀就會被丟棄,繼續執行下一個棧幀。

區域性變數表

區域性變數表用於儲存基礎資料型別、物件引用和returnAddress型別。

區域性變數表實際上就是一個陣列,陣列的一個元素被稱為區域性變數槽(Slot),一個槽大小為32位。區域性變數表所需的記憶體空間是在編譯時分配,執行時區域性變數所佔用的空間是確定的,也就是陣列的槽數。基礎資料型別佔用1個或2個槽,物件引用和returnAddress型別佔用1個槽。

基礎資料型別

JAVA有8個基礎資料型別:boolean、byte、char、short、int、float、long、double。其中long和double佔用2個槽,其他基礎資料型別都佔用1個槽。

區域性變數使用索引進行定位訪問。區域性變數的索引值從0開始。呼叫例項方法時,第0個區域性變數用於儲存當前物件例項(即this關鍵字)。區域性變數從第1個開始;而呼叫靜態方法時,區域性變數從第0個開始。

物件引用

物件引用包含指向物件的起始地址的引用指標或指向代表物件的控制程式碼。
其中指向物件起始地址的引用可能是物件、陣列或介面。

returnAddress

returnAddress是一個指標,指向一條虛擬機器指令的操作碼。這些操作碼包括jsrretjsr_w。在JDK 7之前,這些操作碼用於實現finally語句塊的跳轉和返回。從JDK 7開始,虛擬機器已不允許這幾個操作碼了,改為冗餘finally塊程式碼(在每個catch塊後生成冗餘的finally程式碼)實現,因此returnAddress型別基本就沒用了。

運算元棧

每個棧幀內部都包含一個後進先出(LIFO)的運算元棧。運算元棧的最大深度由編譯期決定。運算元棧中儲存了區域性變數表或物件例項中的常量或變數值。在呼叫方法時,也儲存呼叫方法的引數和返回值。

若區域性變數是long或double型別,則需要佔用2個單位的棧深度。

舉個例子,當執行以下程式碼。右邊註釋的[]表示運算元棧,左邊時棧底,右邊是棧頂。

//          //[]
int a = 1;  //[1]   
int b = 2;  //[1,2]
int c = a+b;//[3]->[]

注意:c=a+b,通過iadd讀取棧頂的2個數相加後重新入到運算元棧,因此運算元棧中的內容為3,然後從運算元棧中出棧儲存到c變數中,運算元棧就空了。

動態連結

每個棧幀內部都包含當前方法所在型別的執行時常量池的引用,以便對當前方法的程式碼實現動態連結。

在編譯時,會將呼叫的方法或成員變數通過符號引用的方式儲存。動態連結的作用就是將以符號引用所表示的方法轉換為方法的直接引用。

符號引用也被稱為描述符(Descriptor),是通過特定的語法來表示的。呼叫的方法的符號引用稱為方法描述符(Method Descriptor),成員變數稱為欄位描述符(Parameter Descriptor)。

方法返回地址

當通過動態連結呼叫其他類方法時,棧幀中需要儲存被呼叫的位置,以便方法呼叫完成後可以返回到被呼叫時的位置。

當方法正常呼叫完成後,則棧幀正常恢復區域性變數表、運算元棧和呼叫者的程式計數器正確的位置,若有返回值,則將返回值壓入到呼叫者的棧幀的運算元棧中。

當方法異常呼叫完成後,則會導致Java虛擬機器丟擲異常,若當前方法沒有任何可以處理該異常的異常處理器,則當前方法的運算元棧和區域性變數表都會被丟棄,隨後恢復到呼叫者的棧幀,此時不會有任何返回值壓入到呼叫者的運算元棧中。同時將異常交易給呼叫者的異常處理器處理。

Java堆

Java虛擬機器中,Java堆用於儲存各種物件例項,是Java虛擬機器所管理的記憶體中最大的一塊,並且該記憶體被所有執行緒所共享。
Java棧由執行緒自動建立和銷燬,棧幀由方法的建立和銷燬自動管理。而Java堆則由垃圾收集器進行自動收集並回收。垃圾收集器在不同場景下通過最優的垃圾收集演算法對垃圾繼續收集。

20210117221122.png

為了提高垃圾收集效能,Java堆將空間分為新生代、老年代。新生代又被分為Eden區和Survivor區。

通常情況下物件都被建立在新生代中的Eden區,隨後隨著垃圾回收的進行,未被回收的物件則被逐步從新生代轉移到老年代,具體垃圾回收相關細節不在這裡討論。

若新生代的空間不足以建立物件,則可能直接被建立到老年代

方法區

方法區用於儲存被虛擬機器載入的類資訊、靜態變數、JIT後的程式碼位元組碼快取、執行池常量。虛擬機器規範把方法區列為堆的一部分,但是虛擬機器實現可以不實現方法區的自動垃圾回收,而是依賴於對常量池和型別的解除安裝來完成。

20210123142339.png

型別資訊

型別資訊包括程式碼中的類名、修飾符、欄位描述符和方法描述符。在class檔案中,型別資訊並不是我們程式碼中直接使用的字串,而是由內部的表現形式的字串。

欄位描述符

欄位描述符用於表示類、例項和區域性變數。比如用L表示物件,用[表示陣列等。

欄位描述符內部解釋表如下圖所示。

欄位描述符 型別 含義
B byte 有符號的位元組型數
C char unicode字元碼點,UFT-16編碼
D double 雙精度浮點數
F float 單精度浮點數
I int 整型數
J long 長整數
L className reference className的類的例項
S short 有符號短整數
Z boolean 布林值true/false
[ referebce 一維陣列
方法描述符

方法描述符表示0個或多個引數描述符以及1個返回值描述符,用於表示方法的簽名資訊。若返回值為void則用V表示。

方法描述符的格式: (引數描述符) + 返回值描述符
比如Object m(int i, double d, Thread t)(){}方法可以表示為(IDLjava/lang/Thread;)Ljava/lang/Object;

  • Iint型別的欄位描述符
  • Ddouble型別的欄位描述符
  • Ljava/lang/ThreadThread型別的內部描述符
  • Ljava/lang/Object是方法的返回值為object型別

方法描述符分割各識別符號的符號不用.,而用/表示。

public class SymbolTest{
    private final static String staticParameter = "1245";
    public static void main(String[] args) {
        String name = "jake";
        int age = 54;
        System.out.println(name);
        System.out.println(age);
    }
} 

上面一個簡單的例子,編譯通過後,可以通過javap -s xxx.class命令檢視內部簽名。

 D:\study\java\symbolreference\out\production\symbolreference>javap -s com.company.SymbolTest
Compiled from "SymbolTest.java"
public class com.company.SymbolTest {
  public com.company.SymbolTest();
    descriptor: ()V

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
}

可以看出無參建構函式的方法描述符為()V,main方法的方法描述符為([Ljava/lang/String;)V

執行時常量池

執行時常量池儲存了編譯期常量和執行期常量。編譯期常量是在編譯時編譯器生成的字面量和符號引用。字面量指的是程式碼中直接寫的字串或數值等常量或宣告為final的常量值。比如string str="abc"int value = 1這裡的abc1都屬於字面量。執行期常量值的是執行期產生的新的常量,比如String.intern()方法產生的字串常量會被儲存到執行時常量池快取起來複用。
執行時常量在方法區中分配,在載入類和介面到虛擬機器後就會建立對應的執行時常量。若建立執行時常量所需的記憶體空間超過了方法區所能提供的最大值,則會丟擲OutOfMemoryError異常。

還是上面的程式碼示例,通過javap -v可以輸出包括執行時常量的附加資訊。下面列出了了部分常量輸出內容。

D:\study\java\symbolreference\out\production\symbolreference>javap -v com.company.SymbolTest
...
Constant pool:
   #1 = Methodref          #7.#28         // java/lang/Object."<init>":()V
   #2 = String             #29            // jake
   #3 = Fieldref           #30.#31        // java/lang/System.out:Ljava/io/PrintStream;
  ...
   #7 = Class              #36            // java/lang/Object
   #8 = Utf8               staticParameter
   #9 = Utf8               Ljava/lang/String;
  #10 = Utf8               ConstantValue
  #11 = String             #37            // 1245
  #12 = Utf8               <init>
  #13 = Utf8               ()V
  ...
  #18 = Utf8               Lcom/company/SymbolTest;
  #19 = Utf8               main
  #20 = Utf8               ([Ljava/lang/String;)V
  #21 = Utf8               args
  #22 = Utf8               [Ljava/lang/String;
  #23 = Utf8               name
  #24 = Utf8               age
  #25 = Utf8               I
  #26 = Utf8               SourceFile
  #27 = Utf8               SymbolTest.java
  #28 = NameAndType        #12:#13        // "<init>":()V
  #29 = Utf8               jake
  ...
  #35 = Utf8               com/company/SymbolTest
  #36 = Utf8               java/lang/Object
  #37 = Utf8               1245
  ...

通過輸出的靜態常量資訊可以很清楚的看出JVM編譯時對字面量和符號引用的處理,包括型別名、變數名、方法等都用符號來代替了。比如第一個常量為物件類構造方法java/lang/Object."<init>":()V。去除其他不相關的常量,最終的符號引用和字面量關係如下表。

索引 型別
0 Methodref #7.#28(java/lang/Object."<init>":()V)
...
7 Class #36
...
12 Utf8 <init>
13 Utf8 ()V
...
28 NameAndType #12:#13("<init>":()V)
...
36 Utf8 java/lang/Object

實現方式

在JDK1.7之前,HotSpot是使用GC的永久代來實現方法區,省去了專門編寫方法區的記憶體管理程式碼。
從JDK1.8開始,使用元空間替代永久代來存放方法區的資料。元空間屬於本地記憶體。簡而言之使用了本地記憶體替換堆記憶體來存放方法區的資料。

若方法區記憶體空間不滿足記憶體分配的請求時,將丟擲OutOfMemoryError異常。

本地方法棧

若虛擬機器支援本地方法,則需要提供本地方法棧,本地方法棧線上程建立的時候按執行緒分配。HotSpot虛擬機器將本地方法棧和虛擬機器棧合二為一。

本地方法棧和虛擬機器棧一樣也會丟擲StackOverflowErrorOutOfMemoryError異常。

參考文件

  1. JVM jsr和ret指令始終理解不了?returnAddress又怎麼理解呢?
  2. 如何理解ByteCode、IL、彙編等底層語言與上層語言的對應關係?
  3. The Java Virtual Machine Instruction Set
  4. 《深入理解Java虛擬機器》
  5. 《Java虛擬機器規範(Java SE 8版)》

本文地址:https://www.cnblogs.com/Jack-Blog/p/14332247.html
作者部落格:傑哥很忙
歡迎轉載,請在明顯位置給出出處及連結

相關文章