1. 程式人生 > >JVM 記憶體區域 (執行時資料區域)

JVM 記憶體區域 (執行時資料區域)

JVM 記憶體區域 (執行時資料區域)

連結:https://www.jianshu.com/p/ec479baf4d06

 

執行時資料區域

Java 虛擬機器在執行 Java 程式的過程中會把它所管理的記憶體劃分為若干個不同的資料區域。這些區域都各有各自的用途,以及建立和銷燬的時間,有的區域隨著虛擬機器程序的啟動而存在,有些區域則依賴使用者執行緒的啟動和結束而建立和銷燬。根據《Java 虛擬機器規範(Java SE 8版)》的規定,Java 虛擬機器所管理的記憶體將會包括以下幾個執行時資料區域。如圖:

 

參考

  • 深入理解Java虛擬機器
  • Java虛擬機器規範(Java SE 8版)
   

程式計數器(PC暫存器)

來源  https://www.jianshu.com/p/77c882f47b29  

特點

  • 程式計數器是一個以執行緒私有的一塊較小的記憶體空間,用於記錄所屬執行緒所執行的位元組碼的行號指示器;位元組碼直譯器工作時,通過改變程式計數器的值來選取下一條需要執行的位元組碼指令,分支、迴圈、跳準、異常處理、執行緒恢復等基礎功能都需要依賴程式計數器來完成。

  • 在多執行緒中,就會存線上程上下文切換(CPU 時間片[1])執行,為了執行緒切換後能恢復正確的執行位置,所以需要從程式計數器中獲取該執行緒需要執行的位元組碼的偏移地址(簡單來說,可以先理解為執行的程式碼行號,但實際並不是所看到的程式碼行號,後續學習了位元組碼指令即明白了)。程式計數器是具備執行緒隔離性,每個執行緒工作時都有屬於自己的獨立程式計數器。

  • 如果執行緒執行 Java 方法,程式計數器記錄的是正在執行的虛擬機器位元組碼指令的地址。如果執行 Navtive 方法,程式計數器值則為空(Undefined)。因為 Navtive 方法是 Java 通過 JNI 直接呼叫本地 C/C++ 庫,可以認為是 Native 方法相當於 C/C++ 暴露給 Java 的一個介面,Java 通過呼叫這個介面從而呼叫到 C/C++ 方法。由於該方法是通過 C/C++ 而不是 Java 進行實現。那麼自然無法產生相應的位元組碼,並且 C/C++ 執行時的記憶體分配是由自己語言決定的,而不是由 JVM 決定的。


      Java 方法呼叫
  • 由於是執行緒私有的,生命週期隨著執行緒,執行緒啟動而產生,執行緒結束而消亡。

  • Java 虛擬機器規範裡面, 唯一 一個沒有規定任何 OutOfMemoryError 情況的區域,由於儲存的是執行緒需要執行的位元組碼的偏移地址,當執行下一條指令的時候,改變的只是程式計數器中儲存的地址,並不需要申請新的記憶體來儲存新的指令地址,因此,不會產生記憶體溢位。

答疑

可能有人對位元組碼的偏移地址有所困惑,因為這個屬於位元組碼指令的知識範疇,這裡就簡單舉例讓大家先了解一下:

public int test() { int x = 0; int y = 1; return x + y; } 

這段程式碼轉化成位元組碼指令又是這樣子的呢?可以使用 javap -v 命令執行該類,生成出來的位元組碼指令如下:

public int test(); descriptor: ()I flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: iconst_0 1: istore_1 2: iconst_1 3: istore_2 4: iload_1 5: iload_2 6: iadd 7: ireturn LineNumberTable: line 7: 0 line 8: 2 line 9: 4 LocalVariableTable: Start Length Slot Name Signature 0 8 0 this Lcom/alibaba/uc/TestClass; 2 6 1 x I 4 4 2 y I 

以上只是這個方法的位元組碼指令,但是,我們重點所看的程式計數器所記錄的值是:如 7: ireturn 操作指令中的 7 即為偏移地址。

偏移地址: 操作指令
0: iconst_0
1: istore_1
2: iconst_1
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: ireturn

  1. CPU 時間片
    CPU 時間片即CPU 分配給各個程式的時間,每個執行緒被分配一個時間段,稱作它的時間片,即該程序允許執行的時間,使各個程式從表面上看是同時進行的。如果在時間片結束時程序還在執行,則 CPU 將被剝奪並分配給另一個程序。如果程序在時間片結束前阻塞或結束,則 CPU 當即進行切換。而不會造成 CPU 資源浪費。在巨集觀上:我們可以同時開啟多個應用程式,每個程式並行不悖,同時執行。但在微觀上:由於只有一個 CPU,一次只能處理程式要求的一部分,如何處理公平,一種方法就是引入時間片,每個程式輪流執行。

虛擬機器棧

連結:https://www.jianshu.com/p/ecfcc9fb1de7

 

特點

  • Java 虛擬機器棧(Java Virtual Machine Stacks)是執行緒私有的,生命週期隨著執行緒,執行緒啟動而產生,執行緒結束而消亡。
  • Java 虛擬機器棧描述的是 Java 方法執行的記憶體模型,用於儲存棧幀。執行緒啟動時會建立虛擬機器棧,每個方法在執行時會在虛擬機器棧中建立一個棧幀,用於儲存區域性變量表、運算元棧、動態連線、方法返回地址、附加資訊等資訊。每個方法從呼叫到執行完成的過程,就對應著一個棧幀在虛擬機器棧中的入棧(壓棧)到出棧(彈棧)的過程。
  • Java 虛擬機器棧使用的記憶體不需要保證是連續的。
  • Java 虛擬機器規範即允許 Java 虛擬機器棧被實現成固定大小(-Xss),也允許通過計算結果動態來擴容和收縮大小。如果採用固定大小的 Java 虛擬機器棧,那每個執行緒的 Java 虛擬機器棧容量可以線上程建立的時候就已經確定。

Java 虛擬機器棧會出現的異常

  • 如果執行緒請求分配的棧容量超過了 Java 虛擬機器棧允許的最大容量,Java 虛擬機器將會丟擲 StackOverflowError 異常。
  • 如果 Java 虛擬機器棧可以動態擴充套件,並且在嘗試擴充套件的時候無法申請到足夠的記憶體,或者在建立新的執行緒時沒有足夠的記憶體去建立對應的虛擬機器棧,那 Java 虛擬機器將丟擲一個 OutOfMemoryError 異常。

Java 虛擬機器棧執行過程

可以參考一下這篇文章:https://blog.csdn.net/azhegps/article/details/54092466


棧幀(Stack Frame)

  • 棧幀存在於 Java 虛擬機器棧中,是 Java 虛擬機器棧中的單位元素,每個執行緒中呼叫同一個方法或者不同的方法,都會建立不同的棧幀(可以簡單理解為,一個執行緒呼叫一個方法建立一個棧幀),所以,呼叫的方法鏈越多,建立的棧幀越多(代表作:遞迴)。在 Running 的執行緒,只有當前棧幀有效(Java 虛擬機器棧中棧頂的棧幀),與當前棧幀相關聯的方法稱為當前方法。每呼叫一個新的方法,被呼叫方法對應的棧幀就會被放到棧頂(入棧),也就是成為新的當前棧幀。當一個方法執行完成退出的時候,此方法對應的棧幀也相應銷燬(出棧)。
    棧幀結構如圖:
      棧幀結構

區域性變量表(Local Variable Table)

  • 每個棧幀中都包含一組稱為區域性變量表的變數列表,用於存放方法引數和方法內部定義的區域性變數。在 Java 程式編譯成 Class 檔案時,在 Class 檔案格式屬性表中 Code 屬性的 max_locals(區域性變量表所需的儲存空間,單位是 Slot) 資料項中確定了需要分配的區域性變量表的最大容量。
  • 區域性變量表的容量以變數槽(Variable Slot)為最小單位,不過 Java 虛擬機器規範中並沒有明確規定每個 Slot 所佔據的記憶體空間大小,只是有導向性地說明每個 Slot 都應該存放的8種類型: byte、short、int、float、char、boolean、reference(物件引用就是存到這個棧幀中的區域性變量表裡的,這裡的引用指的是區域性變數的物件引用,而不是成員變數的引用。成員變數的物件引用是儲存在 Java 堆(Heap)中)、returnAddress(虛擬機器資料型別,Sun JDK 1.4.2版本之前使用 jsr/ret 指令用於進行異常處理,後續版本已廢棄這種實現方式,目前使用異常處理器表代替)型別的資料,這8種類型的資料,都可以使用32位或者更小的空間去儲存。Java 虛擬機器規範允許 Slot 的長度可以隨著處理器、作業系統或者虛擬機器的不同而發生變化。對於64位的資料型別,虛擬機器會以高位在前的方式為其分配兩個連續的 Slot 空間。即 long 和 double 兩種型別。做法是將 long 和 double 型別速寫分割為32位讀寫的做法。不過由於區域性變量表建立線上程的堆疊上,是執行緒的私有資料,無論讀寫兩個連續的 Slot 是否是原子操作,都不會引起資料安全問題。
  • Java 虛擬機器通過索引定位的方式使用區域性變量表,索引值的範圍是從0開始到區域性變量表最大的 Slot 數量。如果是32位資料型別的資料,索引 n 就表示使用第 n 個 Slot,如果是64位資料型別的變數,則說明要使用第 n 和第 n+1 兩個 Slot。
  • 在方法執行過程中,Java 虛擬機器是使用區域性變量表完成引數值到引數變數列表的傳遞過程。如果是例項方法(非 static 方法),那麼區域性變量表中的第0位索引的 Slot 預設是用來傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字 this 來訪問這個隱含的引數。其餘引數按照引數表的順序來排列,佔用從1開始的區域性變數 Slot,引數表分配完畢後,再根據方法體內部定義的變數順序和作用域分配其餘的 Slot。
  • 區域性變量表中的 Slot 是可重用的,方法體中定義的變數,其作用域並不一定會覆蓋整個方法體,如果當前位元組碼程式計數器的值已經超過了某個變數的作用域,那麼這個變數相應的 Slot 就可以交給其他變數去使用,節省棧空間,但也有可能會影響到系統的垃圾收集行為。
  • 區域性變數無初始值(例項變數和類變數都會被賦予初始值),類變數有兩次賦初始值的過程,一次在準備階段,賦予系統初始值;另外一次在初始化階段,賦予開發者定義的值。因此即使在初始化階段開發者沒有為類變數賦值也沒有關係,類變數仍然具有一個確定的預設值。但區域性變數就不一樣了,如果一個區域性變數定義了但沒有賦初始值是不能使用的。

使用一段程式碼說明一下區域性變量表:

// java 程式碼
public int test() { int x = 0; int y = 1; return x + y; } // javac 編譯後的位元組碼,使用 javap -v 檢視 public int test(); descriptor: ()I flags: ACC_PUBLIC Code: stack=2, locals=3, args_size=1 0: iconst_0 1: istore_1 2: iconst_1 3: istore_2 4: iload_1 5: iload_2 6: iadd 7: ireturn LineNumberTable: line 7: 0 line 8: 2 line 9: 4 LocalVariableTable: Start Length Slot Name Signature 0 8 0 this Lcom/alibaba/uc/TestClass; 2 6 1 x I 4 4 2 y I 

對應上面的解釋說明,通過 LocalVariableTable 也可以看出來:
Code 屬性:
stack(int x(1個棧深度)+ int y(1個棧深度))=2, locals(this(1 Slot)+ int x(1 Slot)+ int y(1 Slot))=3, args_size(非 static 方法,this 隱含引數)=1

驗證 Slot 複用,執行以下程式碼時,在 VM 引數中新增 -verbose:gc

public void test() { { byte[] placeholder = new byte[64 * 1024 * 1024]; } int a = 0; // 當這段程式碼註釋掉時,System.gc() 執行後,也並不會回收這64MB記憶體。當這段程式碼執行時,記憶體被回收了 System.gc(); } 

區域性變量表中的 Slot 是否還存在關於 placeholder 陣列物件的引用。當 int a = 0; 不執行時,程式碼雖然已經離開了 placeholder 的作用域,但是後續並沒有任何對區域性變量表的讀寫操作,placeholder 原本所佔用的 Slot 還沒有被其他變數所複用,所以 placeholder 作為 GC Roots(所有 Java 執行緒當前活躍的棧幀裡指向 Java 堆裡的物件的引用) 仍然是可達物件。當 int a = 0; 執行時,placeholder 的 Slot 被變數 a 複用,所以 GC 觸發時,placeholder 變成了不可達物件,即可被 GC 回收。

運算元棧(Operand Stack)

  • 運算元棧是一個後入先出(Last In First Out)棧,方法的執行操作在運算元棧中完成,每一個位元組碼指令往運算元棧進行寫入和提取的過程,就是入棧和出棧的過程。
  • 同區域性變量表一樣,運算元棧的最大深度也是Java 程式編譯成 Class 檔案時被寫入到 Class 檔案格式屬性表的 Code 屬性的 max_stacks 資料項中。
  • 運算元棧的每一個元素可以是任意的 Java 資料型別,32位資料型別所佔的棧容量為1,64位資料型別所佔的棧容量為2,在方法執行的任何時候,運算元棧的深度都不會超過在 max_stacks 資料項中設定的最大值(指的是進入運算元棧的 “同一批操作” 的資料型別的棧容量的和)。
  • 當一個方法剛剛執行的時候,這個方法的運算元棧是空的,在方法執行的過程中,通過一些位元組碼指令從區域性變量表或者物件例項欄位中複製常量或者變數值到運算元棧中,也提供一些指令向運算元棧中寫入和提取值,及結果入棧,也用於存放呼叫方法需要的引數及接受方法返回的結果。例如,整數加法的位元組碼指令 iadd(使用 iadd 指令時,相加的兩個元素也必須是 int 型) 在執行的時候將運算元棧中最接近棧頂的兩個 int 數值元素出棧相加,然後將相加結果入棧。
    以下程式碼會以什麼形式進入運算元棧?
// java 程式碼
public void test() { byte a = 1; short b = 1; int c = 1; long d = 1L; float e = 1F; double f = 1D; char g = 'a'; boolean h = true; } // 位元組碼指令 0: iconst_1 // 把 a 壓入運算元棧棧頂 1: istore_1 // 將棧頂的 a 存入區域性變量表索引為1的 Slot 2: iconst_1 // 把 b 壓入運算元棧棧頂 3: istore_2 // 將棧頂的 b 存入區域性變量表索引為2的 Slot 4: iconst_1 // 把 c 壓入運算元棧棧頂 5: istore_3 // 將棧頂的 c 存入區域性變量表索引為3的 Slot 6: lconst_1 // 把 d 壓入運算元棧棧頂 7: lstore 4 // 將棧頂的 d 存入區域性變量表索引為4的 Slot,由於 long 是64位,所以佔2個 Slot 9: fconst_1 // 把 e 壓入運算元棧棧頂 10: fstore 6 // 將棧頂的 e 存入區域性變量表索引為6的 Slot 12: dconst_1 // 把 f 壓入運算元棧棧頂 13: dstore 7 // 將棧頂的 f 存入區域性變量表索引為4的 Slot,由於 double 是64位,所以佔2個 Slot 15: bipush 97 // 把 g 壓入運算元棧棧頂 17: istore 9 // 將棧頂的 g 存入區域性變量表索引為9的 Slot 19: iconst_1 // 把 h 壓入運算元棧棧頂 20: istore 10 // 將棧頂的 h 存入區域性變量表索引為10的 Slot 

從上面位元組碼指令可以看出來,除了 long、double、float 型別使用的位元組碼指令不是 iconstistore,其他型別都是使用這兩個位元組碼指令操作,說明 byte、short、char、boolean 進入運算元棧時,都會被轉化成 int 型。

  • 在概念模型中,兩個棧幀作為虛擬機器棧的元素,是完全相互獨立的。但在大多虛擬機器實現會做一些優化,令兩個棧幀出現一部分重疊。讓下面的棧幀的部分運算元棧與上面棧幀的部分區域性變量表重疊在一起,這樣在進行方法呼叫時就可以共用一部分資料,無需進行額外的引數複製傳遞。


      棧幀共享
  • Java 虛擬機器的解釋執行引擎稱為 “基於棧的執行引擎”,其中所指的 “棧” 就是運算元棧。

動態連線(Dynamic Linking)

  • 每個棧幀都包含一個指向執行時常量池(JVM 執行時資料區域)中該棧幀所屬性方法的引用,持有這個引用是為了支援方法呼叫過程中的動態連線。
  • 在 Class 檔案格式的常量池(儲存字面量和符號引用)中存有大量的符號引用(1.類的全限定名,2.欄位名和屬性,3.方法名和屬性),位元組碼中的方法呼叫指令就以常量池中指向方法的符號引用為引數。這些符號引用一部分會在類載入過程的解析階段的時候轉化為直接引用(指向目標的指標、相對偏移量或者是一個能夠直接定位到目標的控制代碼),這種轉化稱為靜態解析。另外一部分將在每一次的執行期期間轉化為直接引用,這部分稱為動態連線。
    看看以下程式碼的 Class 檔案格式的常量池:
// java 程式碼
 public Test test() {
    return new Test();
 }

// 位元組碼指令
Constant pool:
   #1 = Methodref          #4.#19         // java/lang/Object."<init>":()V #2 = Fieldref #3.#20 // com/alibaba/uc/Test.i:I #3 = Class #21 // com/alibaba/uc/Test #4 = Class #22 // java/lang/Object #5 = Utf8 i #6 = Utf8 I #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Lcom/alibaba/uc/Test; #14 = Utf8 test #15 = Utf8 ()I #16 = Utf8 <clinit> #17 = Utf8 SourceFile #18 = Utf8 Test.java #19 = NameAndType #7:#8 // "<init>":()V #20 = NameAndType #5:#6 // i:I #21 = Utf8 com/alibaba/uc/Test #22 = Utf8 java/lang/Object public int test(); descriptor: ()I flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: getstatic #2 // Field i:I 3: ireturn LineNumberTable: line 8: 0 LocalVariableTable: Start Length Slot Name Signature 0 4 0 this Lcom/alibaba/uc/Test; 

從上面位元組碼指令看出 0: getstatic #2 // Field i:I 這行位元組碼指令指向 Constant pool 中的 #2,而 #2 中指向了 #3 和 #20 為符號引用,在類載入過程的解析階段會被轉化為直接引用(指向方法區的指標)。

方法返回地址

  • 當一個方法開始執行後,只有兩種方式可以退出這個方法。第一種方式是執行引擎遇到任意一個方法返回的位元組碼指令(例如:ireturn),這時候可能會有返回值傳遞給上層的方法呼叫者(呼叫當前方法的方法稱為呼叫者),是否有返回值和返回值的型別將根據遇到何種方法返回指令來決定,這種退出方法的方式稱為正常完成出口(Normal Method Invocation Completion)
  • 另外一種退出方式是,在方法執行過程中遇到了異常,並且這個異常沒有在方法體內得到處理,無論是Java虛擬機器內部產生的異常,還是程式碼中使用 athrow 位元組碼指令產生的異常,只要在本方法的異常處理器表中沒有搜尋到匹配的異常處理器,就會導致方法退出,這種退出方法的方式稱為異常完成出口(Abrupt Method Invocation Completion)。一個方法使用異常完成出口的方式退出,是不會給它的上層呼叫者產生任何返回值的。
  • 無論採用何種退出方式,在方法退出之後,都需要返回到方法被呼叫的位置,程式才能繼續執行,方法返回時可能需要在棧幀中儲存一些資訊,用來幫助恢復它的上層方法的執行狀態。一般來說,方法正常退出時,呼叫者的程式計數器的值可以作為返回地址,棧幀中很可能會儲存這個計數器值。而方法異常退出時,返回地址是要通過異常處理器表來確定的,棧幀中一般不會儲存這部分資訊。
  • 方法退出的過程實際上就等同於把當前棧幀出棧,因此退出時可能執行的操作有:恢復上層方法的區域性變量表和運算元棧,把返回值(如果有的話)壓入呼叫者棧幀的運算元棧中,調整程式計數器的值以指向方法呼叫指令後面的一條指令等。

簡述:

虛擬機器會使用針對每種返回型別的操作來返回,返回值將從運算元棧出棧並且入棧到呼叫方法的方法棧幀中,當前棧幀出棧,被呼叫方法的棧幀變成當前棧幀,程式計數器將重置為呼叫這個方法的指令的下一條指令。

附加資訊

虛擬機器規範允許具體的虛擬機器實現增加一些規範裡沒有描述的資訊到棧幀中,例如與除錯相關的資訊,這部分資訊完全取決於具體的虛擬機器實現。在實際開發中,一般會把動態連線,方法返回地址與其它附加資訊全部歸為一類,稱為棧幀資訊。



 

本地方法棧

來源 https://www.jianshu.com/p/8a775d747c47  

特點

  • 本地方法棧(Native Method Stacks)與 Java 虛擬機器棧所發揮的作用是非常相似的,其區別不過是虛擬機器棧為虛擬機器執行 Java 方法(也就是位元組碼)服務,而本地方法棧則是為虛擬機器使用到的 Native 方法服務。虛擬機器規範中對本地方法棧中的方法使用的語言、使用方式與資料結構並沒有強制規定,因此具體的虛擬機器可以自由實現它。
  • Navtive 方法是 Java 通過 JNI 直接呼叫本地 C/C++ 庫,可以認為是 Native 方法相當於 C/C++ 暴露給 Java 的一個介面,Java 通過呼叫這個介面從而呼叫到 C/C++ 方法。當執行緒呼叫 Java 方法時,虛擬機器會建立一個棧幀並壓入 Java 虛擬機器棧。然而當它呼叫的是 native 方法時,虛擬機器會保持 Java 虛擬機器棧不變,也不會向 Java 虛擬機器棧中壓入新的棧幀,虛擬機器只是簡單地動態連線並直接呼叫指定的 native 方法。
      Java 方法呼叫
  • 本地方法棧是一個後入先出(Last In First Out)棧。
  • 由於是執行緒私有的,生命週期隨著執行緒,執行緒啟動而產生,執行緒結束而消亡。
  • 本地方法棧會丟擲 StackOverflowErrorOutOfMemoryError 異常。

Java 堆

來源  https://www.jianshu.com/p/702eddcac053  

特點

  • Java 堆(Java Heap)是 Java 虛擬機器所管理的記憶體中最大的一塊,也被稱為 “GC堆”,是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時被建立
  • 唯一目的就是儲存物件例項和陣列(JDK7 已把字串常量池和類靜態變數移動到 Java 堆),幾乎所有的物件例項都會儲存在堆中分配。隨著 JIT 編譯器發展,逃逸分析、棧上分配、標量替換等優化技術導致並不是所有物件都會在堆上分配。
  • Java 堆是垃圾收集器管理的主要區域。堆記憶體分為新生代( Young ) 和老年代( Old) ,新生代 ( Young ) 又被劃分為三個區域:Eden、From Survivor、To Survivor。
      堆預設記憶體劃分
  • 從記憶體分配的角度看,執行緒共享的 Java 堆中可能劃分出多個執行緒私有的執行緒本地分配快取區(Thread Local Allocation Buffer,TLAB)
  • 根據Java 虛擬機器規範的規定,Java 堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可,就像我們的磁碟空間一樣。在實現時,既可以實現成固定大小的,也可以是可擴充套件的,不過當前主流的虛擬機器都是按照可擴充套件來實現的(通過 -Xmx-Xms 控制)。

Java 堆會出現的異常

  • 如果 Java 堆可以動態擴充套件,並且在嘗試擴充套件的時候無法申請到足夠的記憶體,那 Java 虛擬機器將丟擲一個 OutOfMemoryError 異常。

執行時資料區

  執行時資料區

JIT 編譯器

即時編譯器(Just-in-time Compilation,JIT)

  • Java 程式最初是通過直譯器來解釋執行的,當虛擬器發現某個方法或程式碼塊的執行特別頻繁時,就會把這些程式碼認定為“熱點程式碼”,為了提高熱點程式碼的執行效率,在執行時,虛擬機器會把這些程式碼編譯為機器碼,並進行各種層次的優化,完成這個任務的編譯器成為即使編譯器(JIT)。
  • 在 HotSpot 實現中有多種選擇:C1、C2 和 C1 + C2,分別對應 client、server 和分層編譯。
    1、C1 編譯速度快,優化方式比較保守;
    2、C2 編譯速度慢,優化方式比較激進;
    3、C1 + C2 在開始階段採用 C1 編譯,當代碼執行到一定熱度之後採用 G2 重新編譯;
    在 JDK8 之前,分層編譯預設是關閉的,可以新增 -server -XX:+TieredCompilation 引數進行開啟。
      JIT 工作原理圖

什麼是熱點程式碼

  • 被多次呼叫的方法:方法呼叫的多了,程式碼執行次數也多,成為熱點程式碼很正常。
  • 被多次執行的迴圈體:假如一個方法被呼叫的次數少,只有一次或兩次,但方法內有個迴圈,一旦涉及到迴圈,部分程式碼執行的次數肯定多,這些多次執行的迴圈體內程式碼也被認為“熱點程式碼”。

如何檢測熱點程式碼

  • 基於取樣的熱點探測(Sample Based Hot Spot Detection):虛擬機器會週期的對各個執行緒棧頂進行檢查,如果某些方法經常出現在棧頂,這個方法就是“熱點方法”。
    缺點:不夠精確,容易受到執行緒阻塞或外界因素的影響
    優點:實現簡單、高效,很容易獲取方法呼叫關係
  • 基於計數器的熱點探測(Counter Based Hot Spot Detection):為每個方法(甚至是程式碼塊)建立計數器,執行次數超過閾值就認為是“熱點方法”。
    缺點:實現麻煩,不能直接獲取方法的呼叫關係
    優點:統計結果精確

HotSpot 虛擬器為每個方法準備了兩類計數器:方法呼叫計數器和回邊計數器,兩個計數器都有一定的閾值,超過閾值就會觸發JIT 編譯。
-XX:CompileThreshold 可以設定閾值大小,Client 編譯器模式下,閾值預設的值1500,而 Server 編譯器模式下,閾值預設的值則是10000。

  方法呼叫計數器
  回邊計數器

 

逃逸分析

  • 逃逸分析的基本行為就是分析物件動態作用域,當一個物件在方法中被定義後,它可能被外部方法所引用,稱為方法逃逸。
  • 可能被外部執行緒訪問到,例如賦值給類變數或可以在其他執行緒中訪問的例項變數,稱為執行緒逃逸。

物件的三種逃逸狀態

  • GlobalEscape(全域性逃逸) 一個物件的引用逃出了方法或者執行緒。例如,一個物件的引用是複製給了一個類變數,或者儲存在在一個已經逃逸的物件當中,或者這個物件的引用作為方法的返回值返回給了呼叫方法。
  • ArgEscape(引數逃逸) 在方法呼叫過程中傳遞物件的引用給呼叫方法,這種狀態可以通過分析被調方法的二進位制程式碼確定。
  • NoEscape(沒有逃逸) 一個可以進行標量替換的物件,可以不將這種物件分配在堆上。
private Object o;

/**
 * 給全域性變數賦值,發生逃逸(GlobalEscape)
 */
public void globalVariablePointerEscape() { o = new Object(); } /** * 方法返回值,發生逃逸(GlobalEscape) */ public Object methodPointerEscape() { return new Object(); } /** * 例項引用傳遞,發生逃逸(ArgEscape) */ public void instancePassPointerEscape() { Object o = methodPointerEscape(); } /** * 沒有發生逃逸(NoEscape) */ public void noEscape() { Object o = new Object(); } 

配置逃逸分析

  • 開啟逃逸分析,物件沒有分配在堆上,沒有進行GC,而是把物件分配在棧上。 (-XX:+DoEscapeAnalysis 開啟逃逸分析(JDK8 預設開啟,其它版本未測試) )
  • 關閉逃逸分析,物件全部分配在堆上,當堆中物件存滿後,進行多次GC,導致執行時間大大延長。堆上分配比棧上分配慢上百倍。(-XX:-DoEscapeAnalysis 關閉逃逸分析)
  • 可以通過 -XX:+PrintEscapeAnalysis 檢視逃逸分析的篩選結果。

標量替換

  • 標量和聚合量
    標量即不可被進一步分解的量,而 Java 的基本資料型別就是標量(如:int,long 等基本資料型別以及 reference 型別等),標量的對立就是可以被進一步分解的量,而這種量稱之為聚合量。而在 Java 中物件就是可以被進一步分解的聚合量。
  • 替換過程
    通過逃逸分析確定該物件不會被外部訪問,並且物件可以被進一步分解時,JVM 不會建立該物件,而會將該物件成員變數分解若干個被這個方法使用的成員變數所代替,這些代替的成員變數在棧幀或 CPU 暫存器上分配空間。

棧上分配

棧上分配的技術基礎是逃逸分析和標量替換。使用逃逸分析確認方法內區域性變數物件(未發生逃逸,執行緒私有的物件,指的是不可能被其他執行緒訪問的物件)不會被外部訪問,通過標量替換將該物件分解在棧上分配記憶體,不用在堆中分配,分配完成後,繼續在呼叫棧內執行,最後執行緒結束,棧空間被回收,區域性變數物件也被回收。方法執行完後自動銷燬,而不需要垃圾回收的介入,減輕 GC 壓力,從而提高系統性能。

使用場景:對於大量的零散小物件,棧上分配提供了一種很好的物件分配策略,棧上分配的速度快,並且可以有效地避免垃圾回收帶來的負面的影響,但由於和堆空間相比,棧空間比較小,因此對於大物件無法也不適合在棧上進行分配。

測試棧上分配:

public static void alloc() { byte[] b = new byte[2]; b[0] = 1; } public static void main(String[] args) { long timeMillis = System.currentTimeMillis(); for (int i = 0; i < 100000000; i++) { alloc(); } // 開啟使用棧上分配執行時間 6 ms左右 // 關閉使用棧上分配執行時間 900 ms左右 System.out.println(System.currentTimeMillis() - timeMillis); } 
  • 開啟使用棧上分配(JDK8 預設開啟,其它版本未測試),-XX:+DoEscapeAnalysis 表示啟用逃逸分析,棧上分配依賴於 JVM 逃逸分析結果。
  • 禁止使用棧上分配,-XX:-DoEscapeAnalysis 表示禁用逃逸分析。
    注意:如果使用 idea 等工具測試,需使用 Run 執行

同步消除

執行緒同步本身比較耗,如果確定一個物件不會逃逸出執行緒,無法被其它執行緒訪問到,那該物件的讀寫就不會存在競爭,對這個變數的同步措施就可以消除掉。單執行緒中是沒有鎖競爭。(鎖和鎖塊內的物件不會逃逸出執行緒就可以把這個同步塊取消)

測試同步消除:

public static void alloc() { byte[] b = new byte[2]; synchronized (b) { b[0] = 1; } } public static void main(String[] args) { long timeMillis = System.currentTimeMillis(); for (int i = 0; i < 100000000; i++) { alloc(); } // 開啟使用同步消除執行時間 10 ms左右 // 關閉使用同步消除執行時間 3870 ms左右 System.out.println(System.currentTimeMillis() - timeMillis); } 
  • 開啟同步消除 (-XX:+EliminateLocks (JDK8 預設開啟,其它版本未測試) )
  • 關閉同步消除(-XX:-EliminateLocks
    注意:如果使用 idea 等工具測試,需使用 Run 執行

TLAB

  • TLAB 的全稱是 Thread Local Allocation Buffer,即執行緒本地分配快取區。這是執行緒私有的,在新生代 Eden 區分配記憶體區域,預設是開啟的,也可以通過 -XX:+UseTLAB 開啟。TLAB 的記憶體非常小,預設設定為佔用新生代的1%,可以通過 -XX:TLABWasteTargetPercent 設定 TLAB 佔用 Eden Space 空間大小。
  • 由於物件一般會分配在堆上,而堆是全域性共享的。同一時間,可能會有多個執行緒在堆上申請空間。因此,每次物件分配都必須要進行同步,而在競爭激烈的場合記憶體分配的效率又會進一步下降。JVM 使用 TLAB 來避免多執行緒衝突,每個執行緒使用自己的 TLAB,這樣就保證了不使用同步,提高了物件分配的效率。
  • 由於 TLAB 空間一般不會很大,因此大物件無法在 TLAB 上進行分配,總是會直接分配在堆上。TLAB 空間由於比較小,因此很容易裝滿。比如,一個100K的空間,已經使用了80KB,當需要再分配一個30KB的物件時,肯定就無能為力了。這時虛擬機器會有兩種選擇,第一,廢棄當前 TLAB,這樣就會浪費20KB空間;第二,將這30KB的物件直接分配在堆上,保留當前的 TLAB,這樣可以希望將來有小於20KB的物件分配請求可以直接使用這塊空間。實際上虛擬機器內部會維護一個叫作 refill_waste 的值,當請求物件大於 refill_waste 時,會選擇在堆中分配,若小於該值,則會廢棄當前 TLAB,新建 TLAB 來分配物件。這個閾值可以使用 -XX:TLABRefillWasteFraction 來調整,它表示 TLAB 中允許產生這種浪費的比例。預設值為64,即表示使用約為1/64的 TLAB 空間作為 refill_waste。預設情況下,TLAB 和 refill_waste 都會在執行時不斷調整的,使系統的執行狀態達到最優。如果想要禁用自動調整 TLAB 的大小,可以使用 -XX:-ResizeTLAB 禁用,並使用 -XX:TLABSize 手工指定一個 TLAB 的大小。-XX:+PrintTLAB 可以跟蹤TLAB的使用情況。一般不建議手工修改TLAB相關引數,推薦使用虛擬機器預設行為。

擴充套件

虛擬機器物件分配流程:首先如果開啟棧上分配,JVM 會先進行棧上分配,如果沒有開啟棧上分配或則不符合條件的則會進行 TLAB 分配,如果 TLAB 分配不成功,再嘗試在 Eden 區分配,如果物件滿足了直接進入老年代的條件,那就直接分配在老年代。


  虛擬機器物件分配流程

 

方法區

來源 https://www.jianshu.com/p/59f98076b382  

特點

  • 方法區(Method Area)與 Java 堆一樣,是所有執行緒共享的記憶體區域。
  • JDK7 之前(永久代)用於儲存已被虛擬機器載入的類資訊、常量、字串常量、類靜態變數、即時編譯器編譯後的程式碼等資料。
  • Java 虛擬機器規範把方法區描述為堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。
  • 執行時常量池(Runtime Constant Pool)是方法區的一部分。Class 檔案中除了有類的版本/欄位/方法/介面等描述資訊外,還有一項資訊是常量池(Constant Pool Table),用於存放編譯期生成的各種字面量和符號引用,這部分內容將類在載入後進入方法區的執行時常量池中存放。執行期間也可能將新的常量放入池中,這種特性被開發人員利用得比較多的是 String.intern() 方法。受方法區記憶體的限制,當常量池無法再申請到記憶體時會丟擲 OutOfMemoryError 異常。

Java 中基本型別的包裝類的大部分都實現了常量池技術,這些類是Byte、Short、Integer、Long、Character、Boolean,另外Float 和 Double型別的包裝類則沒有實現。另外Byte、Short、Integer、Long、Character這5種整型的包裝類也只是在對應值在-128到127之間時才可使用物件池。

  執行時常量池
  • 實現區域
    永久代:儲存包括類資訊、常量、字串常量、類靜態變數、即時編譯器編譯後的程式碼等資料。可以通過 -XX:PermSize-XX:MaxPermSize 來進行調節。當記憶體不足時,會導致 OutOfMemoryError 異常。JDK8 徹底將永久代移除出 HotSpot JVM,將其原有的資料遷移至 Java Heap 或 Native Heap(Metaspace),取代它的是另一個記憶體區域被稱為元空間(Metaspace)。
    元空間(Metaspace):元空間是方法區的在 HotSpot JVM 中的實現,方法區主要用於儲存類資訊、常量池、方法資料、方法程式碼、符號引用等。元空間的本質和永久代類似,都是對 JVM 規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體。,理論上取決於32位/64位系統記憶體大小,可以通過 -XX:MetaspaceSize-XX:MaxMetaspaceSize 配置記憶體大小。

隨JDK版本變遷的方法區

JDK6

  • Klass 元資料資訊
  • 每個類的執行時常量池(欄位、方法、類、介面等符號引用)、編譯後的程式碼
  • 靜態欄位(無論是否有final)在 instanceKlass 末尾(位於 PermGen 內)
  • oop(Ordinary Object Pointer(普通物件指標)) 其實就是 Class 物件例項
  • 全域性字串常量池 StringTable,本質上就是個 Hashtable
  • 符號引用(型別指標是 SymbolKlass)

JDK7

  • Klass 元資料資訊
  • 每個類的執行時常量池(欄位、方法、類、介面等符號引用)、編譯後的程式碼
  • 靜態欄位從 instanceKlass 末尾移動到了 java.lang.Class 物件(oop)的末尾(位於 Java Heap 內)
  • oop 與全域性字串常量池移到 Java Heap 上
  • 符號引用被移動到 Native Heap 中

JDK8

  • 移除永久代
  • Klass 元資料資訊
  • 每個類的執行時常量池、編譯後的程式碼移到了另一塊與堆不相連的本地記憶體 -- 元空間(Metaspace)

關於 Open JDK 移除永久代的相關資訊:http://openjdk.java.net/jeps/122

類資訊

方法區 詳細資訊
型別資訊 1. 型別的全限定名 2. 超類的全限定名 3. 直接超介面的全限定名 4. 型別標誌(該類是類型別還是介面型別) 5. 類的訪問描述符(public、private、default、abstract、final、static)
型別的常量池 存放該型別所用到的常量的有序集合,包括直接常量(如字串、整數、浮點數的常量)和對其他型別、欄位、方法的符號引用。常量池中每一個儲存的常量都有一個索引,就像陣列中的欄位一樣。因為常量池中儲存中所有型別使用到的型別、欄位、方法的符號引用,所以它也是動態連線(棧中對應的方法指向這個引用)的主要物件(在動態連結中起到核心作用)。
欄位資訊 1. 欄位修飾符(public、protect、private、default) 2. 欄位的型別 3. 欄位名稱
方法資訊 1. 方法名 2.方法的返回型別(包括void)3. 方法引數的型別、數目以及順序 4. 方法修飾符(public、private、protected、static、final、synchronized、native、abstract) 5. 針對非本地方法,還有些附加方法資訊需要儲存在方法區中(區域性變量表大小和運算元棧大小、方法體位元組碼、異常表)
類變數(靜態變數) 指該類所有物件共享的變數,即使沒有建立該物件例項,也可以訪問的類變數。它們與類進行繫結
指向類載入器的引用 JVM 必須知道一個型別是由啟動載入器載入的還是由使用者類載入器載入的。如果一個型別是由使用者類載入器載入的,那麼 JVM 會將這個類載入器的一個引用作為型別資訊的一部分儲存在方法區中。JVM 在動態連結的時候需要這個資訊。當解析一個型別到另一個型別的引用的時候,JVM 需要保證這兩個型別的類載入器是相同的。這對 JVM 區分名字空間的方式是至關重要的。
指向 Class 例項的引用 JVM 為每個載入的類和介面都建立一個 java.lang.Class 例項(JDK6 儲存在方法區,JDK6 之後儲存在 Java 堆),這個物件儲存了所有這個位元組碼記憶體塊的相關資訊,如平時使用的 this.getClass().getName() this.getClass().getDeclaredMethods() this.getClass().getDeclaredFields(),可以獲取類的各種資訊,都是通過這個 Class 引用獲取。
方法表 為了提高訪問效率,必須仔細的設計儲存在方法區中的資料資訊結構。除了以上討論的結構,JVM 的實現者還可以新增一些其他的資料結構,如方法表。JVM 對每個載入的非虛擬類的型別資訊中都添加了一個方法表,方法表是一組對類例項方法的直接引用(包括從父類繼承的方法)。JVM 可以通過方法錶快速啟用例項方法。(這裡的方法表與 C++ 中的虛擬函式表一樣。正像 Java 宣稱沒有指標了,其實 Java 裡全是指標。更安全只是加了更完備的檢查機制,但這都是以犧牲效率為代價的,Java 的設計者始終是把安全放在效率之上的,所有 Java 才更適合於網路開發)

疑問?

Class 物件儲存在哪個區域?

參考 https://www.cnblogs.com/xy-nb/p/6773051.html

JDK8移除了永久代,轉而使用元空間來實現方法區,建立的Class例項在java heap中


======================= End