天天看點

深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區

作者:一個即将被退役的碼農

方法區(Method Area) 與Java堆一樣,是各個線程共享的記憶體區域,它用于存儲已被虛拟機加載的類型資訊、常量、靜态變量、即時編譯器編譯後的代碼緩存等資料。雖然《Java虛拟機規範》中把方法區描述為堆的一個邏輯部分,但是它卻有一個别名叫作“非堆”(Non-Heap),目的是與Java堆區分開來。

目錄

  • 棧、堆、方法區的互動關系
  • 方法區的了解
  • 設定方法區大小與 OOM
  • 方法區的内部結構
  • 方法區使用舉例
  • 方法區的演進細節
  • 方法區垃圾回收

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

深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區

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

代碼示範

public class AppMain {                         //運作時,JVM把AppMain的資訊都放入方法區    

    public static void main(String[] args) { //main成員方法本身放入方法區。    
        Sample test1 = new  Sample( " 測試1 " );   //test1是引用,是以放到棧區裡,Sample是自定義對象應該放到堆裡面    
        Sample test2 = new  Sample( " 測試2 " );         
        test1.printName();    
        test2.printName();    
    }
}            
// Sample.java       
public class Sample {   //運作時,JVM把appmain的資訊都放入方法區。            

    private  name;      //new Sample執行個體後,name引用放入棧區裡,name對象放入堆裡。     

    public  Sample(String name) {    
        this .name = name;    
    }          
        
    public   void  printName() {// printName()成員方法本身放入方法區裡。    
        System.out.println(name);    
    }    
}              

JVM執行具體流程

  1. 系統收到了我們發出的指令,啟動了一個Java虛拟機程序,這個程序首先從classpath中找到AppMain.class檔案,讀取這個檔案中的二進制資料,然後把Appmain類的類資訊存放到運作時資料區的方法區中。這一過程稱為AppMain類的加載過程。
  2. 接着,JVM定位到方法區中AppMain類的Main()方法的位元組碼,開始執行它的指令。
  3. 這個main()方法的第一條語句就是:

    Sample test1 = new Sample(“測試1”);

    語句很簡單,就是讓JVM建立一個Sample執行個體,并且呢,使引用變量test1引用這個執行個體 貌似小case一樁哦,就讓我們來跟蹤一下JVM,看看它究竟是怎麼來執行這個任務的:

1)、Java虛拟機一看,不就是建立一個Sample類的執行個體嗎,簡單,于是就直奔方法區(方法區存放已經加載的類的相關資訊,如類、靜态變量和常量)而去,先找到Sample類的類型資訊再說。結果呢,嘿嘿,沒找到@@,這會兒的方法區裡還沒有Sample類呢(即Sample類的類資訊還沒有進入方法區中)。可JVM也不是一根筋的笨蛋,于是,它發揚“自己動手,豐衣足食”的作風,立馬加載了Sample類, 把Sample類的相關資訊存放在了方法區中。
2)、Sample類的相關資訊加載完成後。Java虛拟機做的第一件事情就是在堆中為一個新的Sample類的執行個體配置設定記憶體,這個Sample類的執行個體持有着指向方法區的Sample類的類型資訊的引用(Java中引用就是記憶體位址)。這裡所說的引用,實際上指的是Sample類的類型資訊在方法區中的記憶體位址,其實,就是有點類似于C語言裡的指針啦~~,而這個位址呢,就存放了在Sample類的執行個體的資料區中。
3)、在JVM中的一個程序中,每個線程都會擁有一個方法調用棧,用來跟蹤線程運作中一系列的方法調用過程,棧中的每一個元素被稱為棧幀,每當線程調用一個方法的時候就會向方法棧中壓入一個新棧幀。這裡的幀用來存儲方法的參數、局部變量和運算過程中的臨時資料。OK,原理講完了,就讓我們來繼續我們的跟蹤行動!位于“=”前的test1是一個在main()方法中定義的變量,可見,它是一個局部變量,是以,test1這個局部變量會被JVM添加到執行main()方法的主線程的Java方法調用棧中。而“=”将把這個test1變量指向堆區中的Sample執行個體,也就是說,test1這個局部變量持有指向Sample類的執行個體的引用(即記憶體位址)。
4)、JVM将繼續執行後續指令,在堆區裡繼續建立另一個Sample類的執行個體,然後依次執行它們的printName()方法。當JVM執行test1.printName()方法時,JVM根據局部變量test1持有的引用,定位到堆中的Sample類的執行個體,再根據Sample類的執行個體持有的引用,定位到方法區中Sample類的類型資訊(包括①類,②靜态變量,③靜态方法,④常量和⑤成員方法),進而擷取printName()成員方法的位元組碼,接着執行printName()成員方法包含的指令。

方法區的了解

在《Java虛拟機規範》中明确說明,“盡管所有的方法區在邏輯上屬于堆的一部分,但一些簡單的實作可能不會選擇去進行垃圾收集或者進行壓縮”。但對于 HotSpot 虛拟機而言,方法區還有一個别名叫做 Non-Heap(非堆),目的就是要和堆分開。

是以,方法區可以看做是一塊獨立于 Java 堆的記憶體空間。

深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區

運作時資料區

方法區主要存放的是 Class ,而堆中主要存放的是執行個體化的對象

  • 方法區(Method Area) 與 Java堆 一樣,是各個線程共享的記憶體區域。
  • 方法區在JVM啟動的時候被建立,并且它的實際的實體記憶體空間中和Java堆區一樣都可以是不連續的。
  • 方法區的大小,跟堆空間一樣,可以選擇固定大小或者可擴充。
  • 方法區的大小決定了系統可以儲存多少個類,如果系統定義了太多的類,導緻方法區溢出,虛拟機同樣會抛出記憶體溢出錯誤: Java.lang.OutofMemoryError:PermGen space 或者 java.lang.OutOfMemoryError:Metaspace 加載大量的第三方的 jar 包,Tomcat部署的工程過多(30~50個),大量動态的生成反射類
  • 關閉JVM就會釋放這個區域的記憶體。

設定方法區大小與 OOM

方法區的大小不必是固定的,JVM可以根據應用的需要動态調整。

jdk7及以前

通過-xx:Permsize來設定永久代初始配置設定空間。預設值是20.75M

-XX:MaxPermsize來設定永久代最大可配置設定空間。32位機器預設是64M,64位機器模式是82M

當JVM加載的類資訊容量超過了這個值,會報異常OutofMemoryError:PermGen space。

深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區

JDK8以後

中繼資料區大小可以使用參數 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定,注意等于号,是指派操作。

深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區

預設值依賴于平台。windows下,-XX:MetaspaceSize是21M(約數),-XX:MaxMetaspaceSize的值是-1,即沒有限制。

與永久代不同,如果不指定大小,預設情況下,虛拟機會耗盡所有的可用系統記憶體。如果中繼資料區發生溢出,虛拟機一樣會抛出異常OutOfMemoryError:Metaspace

-XX:MetaspaceSize:設定初始的元空間大小。對于一個64位的伺服器端JVM來說,其預設的 -xx:MetaspaceSize值為21MB。這就是初始的高水位線,一旦觸及這個水位線,Ful1GC将會被觸發并解除安裝沒用的類(即這些類對應的類加載器不再存活)然後這個高水位線将會重置。新的高水位線的值取決于GC後釋放了多少元空間。如果釋放的空間不足,那麼在不超過MaxMetaspaceSize時,适當提高該值。如果釋放空間過多,則适當降低該值。

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

如何解決這些OOM

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

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

如果是記憶體洩漏,可進一步通過工具檢視洩漏對象到GC Roots的引用鍊。于是就能找到洩漏對象是通過怎樣的路徑與GCRoots相關聯并導緻垃圾收集器無法自動回收它們的。掌握了洩漏對象的類型資訊,以及GCRoots引用鍊的資訊,就可以比較準确地定位出洩漏代碼的位置。

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

方法區的内部結構

類型資訊

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

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

域(Field)資訊

  • JVM必須在方法區中儲存類型的所有域(field,也稱為屬性)的相關資訊以及域的聲明順序;
  • 域的相關資訊包括:域名稱、 域類型、域修飾符(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 MethodAreaDemo2 {
    public static void main(String[] args) {
        Order order = null;
        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!");
    }           

運作結果為:

hello!
1           

可以打開 IDEA 的 Terminal 視窗,在 MethodAreaDemo2.class 所在的路徑下,輸入 javap -v -p MethodAreaDemo2.class 指令

深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區

通過圖檔我們可以看出被聲明為 final 的類變量的處理方法是不一樣的,全局常量在編譯的時候就被配置設定了。

運作時常量池

說到運作時常量池,我們先來了解一下什麼是常量池表。

常量池表

一個有效的位元組碼檔案中除了包含類的版本資訊、字段、方法以及接口等描述資訊外,還包含一項資訊那就是常量池表(Constant Pool Table),裡邊存儲着數量值、字元串值、類引用、字段引用和方法引用。

深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區

為什麼位元組碼檔案需要常量池?

java 源檔案中的類、接口,編譯後會産生一個位元組碼檔案。而位元組碼檔案需要資料支援,通常這種資料會很大,以至于不能直接存放到位元組碼中。換一種方式,可以将指向這些資料的符号引用存到位元組碼檔案的常量池中,這樣位元組碼隻需使用常量池就可以在運作時通過動态連結找到相應的資料并使用。

運作時常量池

運作時常量池( Runtime Constant Pool)是方法區的一部分,類加載器加載位元組碼檔案時,将常量池表加載進方法區的運作時常量池。運作時常量池中包含多種不同的常量,包括編譯期就已經明确的數值字面量,也包括到運作期解析後才能夠獲得的方法或者字段引用。此時不再是常量池中的符号位址了,這裡換為真實位址。

運作時常量池,相對于 Class 檔案常量池的另一重要特征是:具備動态性,比如 String.intern()。

演進細節

針對的是 Hotspot 的虛拟機:

  • jdk1.6 及之前:有永久代 ,靜态變量存放在永久代上;
  • jdk1.7:有永久代,但已經逐漸“去永久代”,字元串常量池、靜态變量移除,儲存在堆中;
  • jdk1.8及之後:無永久代,類型資訊、字段、方法、常量儲存在本地記憶體的元空間,但字元串常量池、靜态變量仍在堆中;

演變示例圖

深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區
深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區
深入了解JVM虛拟機——Java記憶體模型結構之搞懂方法區

為什麼要将永久代替換為元空間呢?

  1. 永久代使用的是 JVM 的記憶體,受 JVM 設定的記憶體大小限制;元空間使用的是本地直接記憶體,它的最大可配置設定空間是系統可用記憶體的空間。因為元空間裡存放的是類的中繼資料,是以随着記憶體空間的增大,能加載的類就更多了,相應的溢出的機率會大大減小。
  2. 在 JDK8,合并 HotSpot 和 JRockit 的代碼時,JRockit 從來沒有一個叫永久代的東西,合并之後就沒有必要額外的設定這麼一個永久代的地方了。
  3. 對永久代進行調優是很困難的。

StringTable 為什麼要調整

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

垃圾回收

相對而言,垃圾收集行為在這個區域是比較少出現的,但并非資料進入方法區後就“永久存在”了。方法區的垃圾收集主要回收兩部分内容:常量池中廢奔的常量和不再使用的類型。

方法區内常量池中主要存放字面量和符号引用兩大類常量:

  • 字面量比較接近 Java 語言層次的常量概念,如文本字元串、被聲明為 final 的常量值等。
  • 符号引用則屬于編譯原理方面的概念,包括類和接口的全限定名、字段的名稱和描述符、方法的名稱和描述符。

HotSpot 虛拟機對常量池的回收政策是很明确的,隻要常量池中的常量沒有被任何地方引用,就可以被回收。

類型判定

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

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

Java 虛拟機被允許對滿足上述三個條件的無用類進行回收,這裡說的僅僅是“被允許”,而并不是和對象一樣,沒有引用了就必然會回收。

繼續閱讀