1. 程式人生 > >JVM 之(15)區域性變量表

JVM 之(15)區域性變量表

        在《 JVM 之(1)執行時資料區》提到,虛擬機器棧是 描述Java方法執行的記憶體模型:每個方法在執行的同時都會建立一個棧幀(Stack Frame)用於儲存區域性變量表、運算元棧、動態連結、方法出口等資訊。本篇主要分析區域性變量表的原理結構。

        區域性變量表是一組變數值儲存空間,用於存放方法引數和方法內部定義的區域性變數。在Java程式被編譯為Class檔案時,就在方法的Code屬性的max_locals資料項中確定了方法所需要分配的最大區域性變量表的容量。

        區域性變量表的容量以變數槽(Variable Slot)為最小單位,虛擬機器規範中並沒有明確指明一個Slot暫用的記憶體空間大小,只是很有“導向性”地說明每個Slot都應該能存放一個boolean,byte,char,short,int,float,refrence,returnAddress型別的資料,這種描述明確指出 “每個Slot佔用32位長度的記憶體空間” 有一些差別,它允許Slot的長度隨著處理器,作業系統或虛擬機器的不同而發生變化。不過無論如何,即使在64位虛擬機器中使用64位長度的記憶體空間來實現Slot,虛擬機器仍要使用對齊和補白的手段讓Slot在外觀上看起來和32位虛擬機器中得一致。

        既然前面提到了資料型別,在此順便說一下,一個Slot可以存放一個32位以內的資料型別,Java中用32位以內的資料型別有:boolean,byte,char,short,int,float,reference,returnAddress八種類型。reference是物件的引用。虛擬機器規範即沒有說明它的長度,也沒有明確指出這個引用應由怎樣的結構,一般來說,虛擬機器實現至少都應當能從此引用中直接或間接的查詢到物件在Java堆中得起始地址索引和方法區中得物件型別資料。而returnAddress是為位元組碼指令jsr,jsr_w 和 ret服務的。它指向了一條位元組碼指令的地址。

        對於64位的資料型別,虛擬機器會以高位在前的方式為其分配兩個連續的Slot空間。Java語言中明確規定的64位的資料型別只有long和double資料型別分割儲存的做法與"long和double的非原子性協定" 中把一次long 和double 資料型別讀寫分割為兩次32位讀寫的做法類似,在閱讀JAVA記憶體模型時對比下。不過,由於區域性變量表建線上程的堆疊上,是執行緒私有的資料,無論讀寫兩個連續的Slot是否是原子操作,都不會引起資料安全問題。

        虛擬機器通過索引定位的方式使用區域性變量表,索引值的範圍是從0開始到區域性變量表最大的Slot數量。如果32位資料型別的變數,索引N就代表了使用第N個Slot,如果是64位資料型別的變數,則說明要使用第N個和N+1兩個Slot。

        在方法執行時,虛擬機器是使用區域性變量表完成引數值到引數變數列表的傳遞過程。如果是例項方法(非static的方法),那麼區域性變量表中第0位索引的Slot預設是用於傳遞方法所屬物件例項的引用,在方法中可以通過關鍵字this來訪問這個隱含的引數,其餘引數則按照引數表的順序來排列,暫用從1開始的區域性變數Slot,引數表分配完畢後,在根據方法體內部定義的變數順序和作用域分配其餘的Slot。

        區域性變量表中得slot是可重用的,方法體定義的變數,其作用域並不一定會覆蓋整個方法體,如果當前位元組碼PC計數器的值已經超過了某個變數的作用域,那麼這個變數對應的Slot就可以交給其他變數使用。這樣的設計不僅僅是為了節省棧空間,在某些情況下Slot的複用會直接影響到系統的垃圾收集行為。例如如下程式碼:

[java]  view plain  copy
  1. /** 
  2.  * VM args: -verbose:gc 
  3.  *  
  4.  */  
  5. public class GCTest {  
  6.     public static void main(String[] args) {  
  7.         byte[] _64M = new byte[1024 * 1024 * 64];  
  8.         System.gc();  
  9.     }  
  10. }  
執行結果:

[GC 66558K->65952K(129024K), 0.0015650 secs]

[Full GC 65952K->65853K(129024K), 0.0122710 secs]

從執行結果分析,發現System.gc()執行後並沒有回收掉這64M的記憶體。

沒有回收掉"_64M"的記憶體能說的過去,因為在執行System.gc()時,變數_64M還處於作用域之內,虛擬機器自然不敢回收掉該記憶體。我們把程式碼位如下:

[java]  view plain  copy
  1. /** 
  2.  * VM args: -verbose:gc 
  3.  *  
  4.  */  
  5. public class GCTest {  
  6.     public static void main(String[] args) {  
  7.         {  
  8.             byte[] _64M = new byte[1024 * 1024 * 64];  
  9.         }  
  10.         System.gc();  
  11.     }  
  12. }  
從程式碼邏輯上將,在執行System.gc()的時候,變數“_64M”已經不可能在被訪問了,但執行以下這段程式,會發現執行結果如下:

[GC 66558K->65968K(129024K), 0.0014760 secs]

[Full GC 65968K->65853K(129024K), 0.0127180 secs]

這是為什麼呢?

在解釋為什麼之前,我們先對程式碼進行第二次修改。在呼叫 System.gc()之前加入程式碼int x=0,  這個修改看起來莫名其妙,但執行以下程式,卻方法這次記憶體針對被正確回收了。

[java]  view plain  copy
  1. /** 
  2.  * VM args: -verbose:gc 
  3.  *  
  4.  */  
  5. public class GCTest {  
  6.     public static void main(String[] args) {  
  7.         {  
  8.             byte[] _64M = new byte[1024 * 1024 * 64];  
  9.         }  
  10.         int x=0;  
  11.         System.gc();  
  12.     }  
  13. }  

[GC 66558K->65936K(129024K), 0.0027120 secs]

[Full GC 65936K->317K(129024K), 0.0129600 secs]

區域性變數"_64M"能否被回收的根本原因就是:區域性變量表中得Slot是否還存有關於_64M陣列物件的引用。第一次修改,程式碼雖然離開了_64的作用域,但在此之後,沒有任何對區域性變量表的讀寫操作,_64M 原本所佔用的Slot還沒有被其他變數所複用,所以作為GC Roots 一部分的區域性變量表讓然保持對它的關聯。這種關聯沒有被及時打斷,在絕大部分情況下都很輕微。但如果遇到一個方法,其後面的程式碼有一些耗時很長的操作,而前面又佔用了大量的記憶體,實際上已經不會在被使用的變數,手工將其設定為NULL值(用來代替int x=0)把變數對應的區域性變量表Slot情況,就不是一個毫無意義的操作,這種操作可以作為 一種在及特殊情形(物件暫用記憶體大,此方法的棧幀長時間不能被回收,方法呼叫次數達不到JIT編譯條件)下得“奇技” 來使用。但不應當對賦null值操作有過多的依賴,也沒有必要把它當做一個普遍的編碼方法來推廣,以恰當的變數作用域來控制變量回收時間才是最優雅的解決方法。

另外,賦null值的操作在經過虛擬機器JIT編譯器優化之後會被消除掉,這時候將變數設定為null實際上是沒有意義的。位元組碼被編譯為bending程式碼後,對GC Roots的列舉也與解釋執行時期有所差別,在經過JIT編譯後,System.gc()執行時就可以正確的回收掉記憶體。

列印GC詳細日誌還可以加上引數:

-XX:+PrintGCDetails -XX:+PrintGCTimeStamps



[java]  view plain  copy
  1. /** 
  2.  * VM args: -verbose:gc 
  3.  *  
  4.  */  
  5. public class GCTest {  
  6.     public static void main(String[] args) {  
  7.         byte[] _64M = new byte[1024 * 1024 * 64];  
  8.         System.gc();  
  9.     }  
  10. }  
執行結果:

[GC 66558K->65952K(129024K), 0.0015650 secs]

[Full GC 65952K->65853K(129024K), 0.0122710 secs]

從執行結果分析,發現System.gc()執行後並沒有回收掉這64M的記憶體。

沒有回收掉"_64M"的記憶體能說的過去,因為在執行System.gc()時,變數_64M還處於作用域之內,虛擬機器自然不敢回收掉該記憶體。我們把程式碼位如下:

[java]  view plain  copy
  1. /** 
  2.  * VM args: -verbose:gc 
  3.  *  
  4.  */  
  5. public class GCTest {  
  6.     public static void main(String[] args) {  
  7.         {  
  8.             byte[] _64M = new byte[1024 * 1024 * 64];  
  9.         }  
  10.         System.gc();  
  11.     }  
  12. }  
從程式碼邏輯上將,在執行System.gc()的時候,變數“_64M”已經不可能在被訪問了,但執行以下這段程式,會發現執行結果如下:

[GC 66558K->65968K(129024K), 0.0014760 secs]

[Full GC 65968K->65853K(129024K), 0.0127180 secs]

這是為什麼呢?

在解釋為什麼之前,我們先對程式碼進行第二次修改。在呼叫 System.gc()之前加入程式碼int x=0,  這個修改看起來莫名其妙,但執行以下程式,卻方法這次記憶體針對被正確回收了。

[java]  view plain  copy
  1. /** 
  2.  * VM args: -verbose:gc 
  3.  *  
  4.  */  
  5. public class GCTest {  
  6.     public static void main(String[] args) {  
  7.         {  
  8.             byte[] _64M = new byte[1024 * 1024 * 64];  
  9.         }  
  10.         int x=0;  
  11.         System.gc();  
  12.     }  
  13. }  

[GC 66558K->65936K(129024K), 0.0027120 secs]

[Full GC 65936K->317K(129024K), 0.0129600 secs]

區域性變數"_64M"能否被回收的根本原因就是:區域性變量表中得Slot是否還存有關於_64M陣列物件的引用。第一次修改,程式碼雖然離開了_64的作用域,但在此之後,沒有任何對區域性變量表的讀寫操作,_64M 原本所佔用的Slot還沒有被其他變數所複用,所以作為GC Roots 一部分的區域性變量表讓然保持對它的關聯。這種關聯沒有被及時打斷,在絕大部分情況下都很輕微。但如果遇到一個方法,其後面的程式碼有一些耗時很長的操作,而前面又佔用了大量的記憶體,實際上已經不會在被使用的變數,手工將其設定為NULL值(用來代替int x=0)把變數對應的區域性變量表Slot情況,就不是一個毫無意義的操作,這種操作可以作為 一種在及特殊情形(物件暫用記憶體大,此方法的棧幀長時間不能被回收,方法呼叫次數達不到JIT編譯條件)下得“奇技” 來使用。但不應當對賦null值操作有過多的依賴,也沒有必要把它當做一個普遍的編碼方法來推廣,以恰當的變數作用域來控制變量回收時間才是最優雅的解決方法。

另外,賦null值的操作在經過虛擬機器JIT編譯器優化之後會被消除掉,這時候將變數設定為null實際上是沒有意義的。位元組碼被編譯為bending程式碼後,對GC Roots的列舉也與解釋執行時期有所差別,在經過JIT編譯後,System.gc()執行時就可以正確的回收掉記憶體。

列印GC詳細日誌還可以加上引數:

-XX:+PrintGCDetails -XX:+PrintGCTimeStamps