面試官:說下你對方法區演變過程和內部結構的理解

之前我們已經瞭解過“運行時數據區”的程序計數器、虛擬機棧、本地方法棧和堆空間,今天我們就來了解一下最後一個模塊——方法區。

簡介

創建對象時內存分配簡圖


《Java虛擬機規範》中明確說明:“儘管所有的方法區在邏輯上屬於堆的一部分,但一些簡單的實現可能不會選擇去進行垃圾收集或者進行壓縮。”

雖然 Java 虛擬機規範把方法區描述爲堆的一個邏輯部分,但是它卻有一個別名叫做 Non-Heap(非堆),目的應該是與 Java 堆區分開來。所以,方法區可以看作是一塊獨立於 Java 堆的內存空間。

方法區與 Java 堆一樣,是各個線程共享的內存區域。方法區在 JVM 啓動時就會被創建,並且它的實際的物理內存空間是可以不連續的,關閉 JVM 就會釋放這個區域的內存。

永久代、元空間

《java虛擬機規範》對如何實現方法區,不做統一要求。例如:BEA JRockit/IBM J9 中不存在永久代的概念。而對於 HotSpot 來說,在 jdk7 及以前,習慣上把方法區的實現稱爲永久代,而從 jdk8 開始,使用元空間取代了永久代。

方法區是 Java 虛擬機規範中的概念,而永久代和元空間是 HotSpot 虛擬機對方法區的一種實現。通俗點講:如果把方法區比作接口的話,那永久代和元空間可以比作實現該接口的實現類。

直接內存

永久代、元空間並不只是名字變了,內部結構也進行了調整。永久代使用的是 JVM 的內存,而元空間使用的是本地的直接內存。

直接內存並不是 JVM 運行時數據區的一部分,因此不會受到 Java 堆的限制。但是它會受到本機總內存大小以及處理器尋址空間的限制,所以如果這部分內存也被頻繁的使用,依然會導致 OOM 錯誤的出現。

方法區的大小

方法區的大小是可以進行設置的,可以選擇固定大小也可以進行擴展。

jdk7 及以前

-XX:PermSize=N //方法區 (永久代) 初始分配空間,默認值爲 20.75M
-XX:MaxPermSize=N //方法區 (永久代) 最大可分配空間。32位機器默認是64M,64位機器默認是82M

jdk8及以後

默認值依賴於平臺,windows下:

-XX:MetaspaceSize=N //方法區 (元空間) 初始分配空間,如果未指定此標誌,則元空間將根據運行時的應用程序需求動態地重新調整大小。
-XX:MaxMetaspaceSize=N //方法區 (元空間) 最大可分配空間,默認值爲 -1,即沒有限制

與永久代很大的不同就是,如果不指定大小的話,隨着更多類的創建,虛擬機會耗盡所有可用的系統內存。

方法區的大小決定了系統可以保存多少個類,如果系統定義了太多的類,比如:加載大量的第三方 jar 包、Tomcat 部署的工程過多、大量動態生成反射類等都會導致方法區溢出,拋出內存溢出錯誤。

  • 永久代:OutOfMemoryError:PermGen space
  • 元空間:OutOfMemoryError:Metaspace

至於如何解決 OOM 異常,將在以後的文章中講解!

jvisualvm

我們可以通過 JDK 自帶的 jvisualvm 工具來查看程序加載的類文件:

public class MethodAreaDemo1 {
    public static void main(String[] args) {
        System.out.println("start...");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("end...");
    }
}

運行程序,可以看到一個簡單的程序就需要加載這麼多的類文件。

高水位線

對於一個64位的服務器端 JVM 來說,XX:MetaspaceSize=21 就是初始的高水位線,一旦觸及這個水位線,Full GC 將會被觸發並卸載沒用的類(即這些類對應的類加載器不再存活),然後這個高水位線將會重置。

新的高水位線的值取決於 GC 後釋放了多少元空間:

  • 如果釋放的空間不足,那麼在不超過 MaxMetaspaceSize 時,適當提高該值;
  • 如果釋放空間過多,則適當降低該值。

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

內部結構

《深入理解Java虛擬機》書中對方法區存儲內容描述如下:它用於存儲已被虛擬機加載的類型信息、常量、靜態變量、即時編譯器編譯後的代碼緩存等。接下來我們就一起來看一下它的內部結構。

類型信息

對每個加載的類型( 類 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 命令

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

運行時常量池

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

常量池表

一個有效的字節碼文件中除了包含類的版本信息、字段、方法以及接口等描述信息外,還包含一項信息那就是常量池表(Constant Pool Table),裏邊存儲着數量值字符串值類引用字段引用方法引用

爲什麼字節碼文件需要常量池?

java 源文件中的類、接口,編譯後會產生一個字節碼文件。而字節碼文件需要數據支持,通常這種數據會很大,以至於不能直接存放到字節碼中。換一種方式,可以將指向這些數據的符號引用存到字節碼文件的常量池中,這樣字節碼只需使用常量池就可以在運行時通過動態鏈接找到相應的數據並使用。

運行時常量池

運行時常量池( Runtime Constant Pool)是方法區的一部分,類加載器加載字節碼文件時,將常量池表加載進方法區的運行時常量池。運行時常量池中包含多種不同的常量,包括編譯期就已經明確的數值字面量,也包括到運行期解析後才能夠獲得的方法或者字段引用。此時不再是常量池中的符號地址了,這裏換爲真實地址。

運行時常量池,相對於 Class 文件常量池的另一重要特徵是:具備動態性,比如 String.intern()

演進細節

針對的是 Hotspot 的虛擬機:

  • jdk1.6 及之前:有永久代 ,靜態變量存放在永久代上;
  • jdk1.7:有永久代,但已經逐步“去永久代”,字符串常量池、靜態變量移除,保存在堆中;
  • jdk1.8及之後: 無永久代,類型信息、字段、方法、常量保存在本地內存的元空間,但字符串常量池、靜態變量仍在堆中;

演變示例圖

爲什麼要將永久代替換爲元空間呢?

  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 虛擬機被允許對滿足上述三個條件的無用類進行回收,這裏說的僅僅是“被允許”,而並不是和對象一樣,沒有引用了就必然會回收。

以上就是今天的全部內容了,如果你有不同的意見或者更好的idea,歡迎聯繫阿Q,添加阿Q可以加入技術交流羣參與討論呦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章