Java JVM——9.方法區

前言

  方法區是運行時數據區的最後一個部分:

  從執行緒共享與否的角度來看:

  大家可能在這裡有些疑惑,方法區和元空間的關係到底是怎樣的?請往下看,下面會為大家解惑。

 


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

  下面就涉及了對象的訪問定位:

  • Person:存放在元空間,也可以說方法區;

  • person:存放在Java棧的局部變數表中;

  • new Person():存放在Java堆中。

 


方法區的理解

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

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

方法區主要存放的是『Class』,而堆中主要存放的是『實例化的對象』

  • 方法區(Method Area)與Java堆一樣,是各個執行緒共享的記憶體區域。

  • 方法區在JVM啟動的時候被創建,並且它的實際的物理記憶體空間中和Java堆區一樣都可以是不連續的。

  • 方法區的大小,跟堆空間一樣,可以選擇固定大小或者可擴展。

  • 方法區的大小決定了系統可以保存多少個類,如果系統定義了太多的類,導致方法區溢出,虛擬機同樣會拋出記憶體溢出錯誤:java.lang.OutofMemoryError:PermGen space 或者java.lang.OutOfMemoryError:Metaspace

    • 載入大量的第三方的jar包

    • Tomcat部署的工程過多(30 — 50個)

    • 大量動態的生成反射類

  • 關閉JVM就會釋放這個區域的記憶體。

HotSpot中方法區的演進

  在jdk7及以前,習慣上把方法區,稱為永久代。jdk8開始,使用元空間取代了永久代。

  JDK 1.8後,元空間存放在堆外記憶體中。

  本質上,方法區和永久代並不等價。僅是對hotspot而言的。《Java虛擬機規範》對如何實現方法區,不做統一要求。例如:BEAJRockit / IBM J9 中不存在永久代的概念。

  現在來看,當年使用永久代,不是好的idea。導致Java程式更容易oom(超過-XX:MaxPermsize上限)

  而到了JDK8,終於完全廢棄了永久代的概念,改用與JRockit、J9一樣在本地記憶體中實現的元空間(Metaspace)來代替:

  元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代最大的區別在於:元空間不在虛擬機設置的記憶體中,而是使用本地記憶體。

  永久代、元空間二者並不只是名字變了,內部結構也調整了。

  根據《Java虛擬機規範》的規定,如果方法區無法滿足新的記憶體分配需求時,將拋出OOM異常。

 


設置方法區大小與OOM

  方法區的大小不必是固定的,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。這就是初始的高水位線,一旦觸及這個水位線,Ful1GC將會被觸發並卸載沒用的類(即這些類對應的類載入器不再存活)然後這個高水位線將會重置。新的高水位線的值取決於GC後釋放了多少元空間。如果釋放的空間不足,那麼在不超過MaxMetaspaceSize時,適當提高該值。如果釋放空間過多,則適當降低該值。

  ➷ 如果初始化的高水位線設置過低,上述高水位線調整情況會發生很多次。通過垃圾回收器的日誌可以觀察到Ful1GC多次調用。為了避免頻繁地GC,建議將-XX:MetaspaceSize設置為一個相對較高的值。

如何解決這些OOM

  • 要解決OOM異常或heap space的異常,一般的手段是首先通過記憶體映像分析工具(如Ec1ipse Memory Analyzer)對dump出來的堆轉儲快照進行分析,重點是確認記憶體中的對象是否是必要的,也就是要先分清楚到底是出現了記憶體泄漏(Memory Leak)還是記憶體溢出(Memory Overflow)。

    • 記憶體泄漏:就是有大量的引用指向某些對象,但是這些對象以後不會使用了,但是因為它們還和GC ROOT有關聯,所以導致以後這些對象也不會被回收,這就是記憶體泄漏的問題。

  • 如果是記憶體泄漏,可進一步通過工具查看泄漏對象到GC Roots的引用鏈。於是就能找到泄漏對象是通過怎樣的路徑與GCRoots相關聯並導致垃圾收集器無法自動回收它們的。掌握了泄漏對象的類型資訊,以及GCRoots引用鏈的資訊,就可以比較準確地定位出泄漏程式碼的位置。

  • 如果不存在記憶體泄漏,換句話說就是記憶體中的對象確實都還必須存活著,那就應當檢查虛擬機的堆參數(-Xmx與-Xms),與機器物理記憶體對比看是否還可以調大,從程式碼上檢查是否存在某些對象生命周期過長、持有狀態時間過長的情況,嘗試減少程式運行期的記憶體消耗。

 


方法區的內部結構

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

類型資訊

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

  • 這個類型的完整有效名稱(全名=包名.類名)。

  • 這個類型直接父類的完整有效名(對於interface或是java.lang.object,都沒有父類)。

  • 這個類型的修飾符(public,abstract,final的某個子集)。

  • 這個類型直接介面的一個有序列表。

域資訊

  JVM必須在方法區中保存類型的所有域的相關資訊以及域的聲明順序。

  域的相關資訊包括:域名稱、域類型、域修飾符(public,private,protected,static,final,volatile,transient的某個子集)。

方法(Method)資訊

JVM必須保存所有方法的以下資訊,同域資訊一樣包括聲明順序:

  • 方法名稱

  • 方法的返回類型(或void)

  • 方法參數的數量和類型(按順序)

  • 方法的修飾符(public,private,protected,static,final,synchronized,native,abstract的一個子集)

  • 方法的位元組碼(bytecodes)、操作數棧、局部變數表及大小(abstract和native方法除外)

  • 異常表(abstract和native方法除外),每個異常處理的開始位置、結束位置、程式碼處理在程式計數器中的偏移地址、被捕獲的異常類的常量池索引

non-final的類變數

  靜態變數和類關聯在一起,隨著類的載入而載入,他們成為類數據在邏輯上的一部分。

  類變數被類的所有實例共享,即使沒有類實例時,你也可以訪問它。

public class MethodAreaTest {
    public static void main(String[] args) {
        Order order = new Order();
        order.hello();
        System.out.println(order.count);
    }
}
class Order {
    public static int count = 1;
    public static final int number = 2;
    public static void hello() {
        System.out.println("hello!");
    }
}

  如上程式碼所示,即使我們把order設置為null,也不會出現空指針異常。

全局常量

  全局常量就是使用 static final 進行修飾。

  被聲明為final的類變數的處理方法則不同,每個全局常量在編譯的時候就會被分配了。

運行時常量池

  運行時常量池,就是運行時常量池。

  • 方法區,內部包含了運行時常量池

  • 位元組碼文件,內部包含了常量池

  • 要弄清楚方法區,需要理解清楚C1assFile,因為載入類的資訊都在方法區。

  • 要弄清楚方法區的運行時常量池,需要理解清楚classFile中的常量池。

常量池

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

為什麼需要常量池

  一個java源文件中的類、介面,編譯後產生一個位元組碼文件。而Java中的位元組碼需要數據支援,通常這種數據會很大以至於不能直接存到位元組碼里,換另一種方式,可以存到常量池,這個位元組碼包含了指向常量池的引用。r在動態鏈接的時候會用到運行時常量池,之前有介紹。

  如下的程式碼:

public class SimpleClass {
    public void sayHello() {
        System.out.println("hello");
    }
}

  雖然上述程式碼只有194位元組,但是裡面卻使用了String、System、PrintStream及Object等結構。這裡的程式碼量其實很少了,如果程式碼多的話,引用的結構將會更多,這裡就需要用到常量池了。

常量池中有什麼

  • 數量值

  • 字元串值

  • 類引用

  • 欄位引用

  • 方法引用

例如下面這段程式碼:

public class MethodAreaTest2 {
    public static void main(String args[]) {
        Object obj = new Object();
    }
}

  將會被翻譯成如下位元組碼

new #2  
dup
invokespecial

小結

  常量池、可以看做是一張表,虛擬機指令根據這張常量表找到要執行的類名、方法名、參數類型、字面量等類型。

運行時常量池

  運行時常量池(Runtime Constant Pool)是方法區的一部分。

  常量池表(Constant Pool Table)是Class文件的一部分,用於存放編譯期生成的各種字面量與符號引用,這部分內容將在類載入後存放到方法區的運行時常量池中。

  運行時常量池,在載入類和介面到虛擬機後,就會創建對應的運行時常量池。

  JVM為每個已載入的類型(類或介面)都維護一個常量池。池中的數據項像數組項一樣,是通過索引訪問的。

  運行時常量池中包含多種不同的常量,包括編譯期就已經明確的數值字面量,也包括到運行期解析後才能夠獲得的方法或者欄位引用。此時不再是常量池中的符號地址了,這裡換為真實地址。

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

  運行時常量池類似於傳統程式語言中的符號表(symboltable),但是它所包含的數據卻比符號表要更加豐富一些。

  當創建類或介面的運行時常量池時,如果構造運行時常量池所需的記憶體空間超過了方法區所能提供的最大值,則JVM會拋outofMemoryError異常。

 


方法區使用舉例

如下程式碼:

public class MethodAreaDemo {
    public static void main(String args[]) {
        int x = 500;
        int y = 100;
        int a = x / y;
        int b = 50;
        System.out.println(a+b);
    }
}

  位元組碼執行過程展示:

  首先現將操作數500放入到操作數棧中

  然後存儲到局部變數表中

  然後重複一次,把100放入局部變數表中,最後再將變數表中的500 和 100 取出,進行操作

  將500 和 100 進行一個除法運算,在把結果入棧

  在最後就是輸出流,需要調用運行時常量池的常量

  最後調用invokevirtual(虛方法調用),然後返回

  返回時

  程式計數器始終計算的都是當前程式碼運行的位置,目的是為了方便記錄 方法調用後能夠正常返回,或者是進行了CPU切換後,也能回來到原來的程式碼進行執行。

方法區的演進細節

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

Hotspot中方法區的變化:

JDK1.6及以前 有永久代,靜態變數存儲在永久代上
JDK1.7 有永久代,但已經逐步 「去永久代」,字元串常量池,靜態變數移除,保存在堆中
JDK1.8 無永久代,類型資訊,欄位,方法,常量保存在本地記憶體的元空間,但字元串常量池、靜態變數仍然在堆中。

  JDK6的時候:

  JDK7的時候:

  JDK8的時候,元空間大小隻受物理記憶體影響

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

  JRockit是和HotSpot融合後的結果,因為JRockit沒有永久代,所以他們不需要配置永久代。

  隨著Java8的到來,HotSpot VM中再也見不到永久代了。但是這並不意味著類的元數據資訊也消失了。這些數據被移到了一個與堆不相連的本地記憶體區域,這個區域叫做元空間(Metaspace)。

  由於類的元數據分配在本地記憶體中,元空間的最大可分配空間就是系統可用記憶體空間,這項改動是很有必要的,原因有:

  • 為永久代設置空間大小是很難確定的。

  在某些場景下,如果動態載入類過多,容易產生Perm區的oom。比如某個實際Web工 程中,因為功能點比較多,在運行過程中,要不斷動態載入很多類,經常出現致命錯誤。

  「Exception in thread『dubbo client x.x connector’java.lang.OutOfMemoryError:PermGen space」

  而元空間和永久代之間最大的區別在於:元空間並不在虛擬機中,而是使用本地記憶體。 因此,默認情況下,元空間的大小僅受本地記憶體限制。

  • 對永久代進行調優是很困難的。

  主要是為了降低Full GC。

  有些人認為方法區(如HotSpot虛擬機中的元空間或者永久代)是沒有垃圾收集行為的,其實不然。《Java虛擬機規範》對方法區的約束是非常寬鬆的,提到過可以不要求虛擬機在方法區中實現垃圾收集。事實上也確實有未實現或未能完整實現方法區類型卸載的收集器存在(如JDK11時期的ZGC收集器就不支援類卸載)。一般來說這個區域的回收效果比較難令人滿意,尤其是類型的卸載,條件相當苛刻。但是這部分區域的回收有時又確實是必要的。以前sun公司的Bug列表中,曾出現過的若干個嚴重的Bug就是由於低版本的HotSpot虛擬機對此區域未完全回收而導致記憶體泄漏。

  方法區的垃圾收集主要回收兩部分內容:常量池中廢棄的常量和不在使用的類型。

StringTable為什麼要調整位置

  jdk7中將StringTable放到了堆空間中。因為永久代的回收效率很低,在full gc的時候才會觸發。而full gc是老年代的空間不足、永久代不足時才會觸發。

  這就導致stringTable回收效率不高。而我們開發中會有大量的字元串被創建,回收效率低,導致永久代記憶體不足。放到堆里,能及時回收記憶體。

靜態變數存放在那裡?

  靜態引用對應的對象實體始終都存在堆空間。

  可以使用jhsdb.ext工具進行分析,需要在jdk9的時候才引入的。

  static obj隨著Test的類型資訊存放在方法區,instance obj隨著Test的對象實例存放在Java堆,localobject則是存放在foo()方法棧幀的局部變數表中。

  測試發現:三個對象的數據在記憶體中的地址都落在Eden區範圍內,所以結論:只要是對象實例必然會在Java堆中分配。

  接著,找到了一個引用該staticobj對象的地方,是在一個java.lang.Class的實例里,並且給出了這個實例的地址,通過Inspector查看該對象實例,可以清楚看到這確實是一個java.lang.Class類型的對象實例,裡面有一個名為staticobj的實例欄位:

  從《Java虛擬機規範》所定義的概念模型來看,所有Class相關的資訊都應該存放在方法區之中,但方法區該如何實現,《Java虛擬機規範》並未做出規定,這就成了一件允許不同虛擬機自己靈活把握的事情。JDK7及其以後版本的HotSpot虛擬機選擇把靜態變數與類型在Java語言一端的映射class對象存放在一起,存儲於Java堆之中,從我們的實驗中也明確驗證了這一點。

 


方法區的垃圾回收

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

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

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

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

  • 類和介面的全限定名

  • 欄位的名稱和描述符

  • 方法的名稱和描述符

  HotSpot虛擬機對常量池的回收策略是很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收。

  回收廢棄常量與回收Java堆中的對象非常類似。(關於常量的回收比較簡單,重點是類的回收)

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

  • 該類所有的實例都已經被回收,也就是Java堆中不存在該類及其任何派生子類的實例。 載入該類的類載入器已經被回收,這個條件除非是經過精心設計的可替換類載入器的場景,如osGi、JSP的重載入等,否則通常是很難達成的。

  • 該類對應的java.lang.C1ass對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。I Java虛擬機被允許對滿足上述三個條件的無用類進行回收,這裡說的僅僅是「被允許」,而並不是和對象一樣,沒有引用了就必然會回收。關於是否要對類型進行回收,HotSpot虛擬機提供了-Xnoclassgc參數進行控制,還可以使用-verbose:class 以及 -XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看類載入和卸載資訊

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

 


總結