1. 程式人生 > 其它 >JVM之方法區詳解

JVM之方法區詳解

大家好,我是程式設計師學長,專注分享大資料、演算法、java、python等相關知識,歡迎和我一起交流學習。

聯絡我

JVM 系列文章我們已經更新完了JVM 的類載入子系統和虛擬機器棧,今天我們來聊一下 JVM 之方法區。

首先,我們來看一下方法區和堆、棧之間的互動關係。

  • User 存放在元空間,也可以說是在方法區中
  • 變數 user 存放在 java 棧的區域性變量表中
  • new User() 存放在 java 堆中

方法區的理解

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

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

方法區主要存放的是 Class,而堆中主要存放的是例項化物件。

  • 方法區和 JAVA 堆一樣,是各個執行緒共享的區域。
  • 方法區在 JVM 啟動的時候被建立,並且它實際的實體記憶體空間和 JAVA 堆 一樣都可以是不連續的。
  • 方法區的大小,和堆空間一樣,可以選擇固定大小或者可擴充套件的。
  • 方法區的大小決定了系統可以儲存多少個類,如果系統定義了太多的類,導致方法區溢位,虛擬機器同樣會丟擲記憶體溢位的錯誤。
    1. 載入大量的第三方的 jar 包
    2. Tomcat 部署的工程過多
    3. 大量動態生成的反射類
  • 關閉JVM會釋放這個記憶體區域

HotSpot 中方法區的演進

在 JDK1.7 及以前,習慣上把方法區,稱為永久代。從 JDK1.8開始,使用元空間代替了永久代。JDK1.8之後,元空間存放在堆外記憶體中。

《Java虛擬機器規範》中,對如何實現方法區,沒有做統一的要求。例如,IBM J9 中就不存在永久代的概念。從JDK1.8之後,HotSpot虛擬機器完全廢棄了永久代的概念,改用與 J9 一樣在本地記憶體中實現的元空間來代替。

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

設定方法區大小與OOM

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

jdk7及之前:

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

  • 通過 -XX:MaxPermsize 來設定永久代最大可分配空間。32 位的機器預設是 64M,64位的機器預設是 82M。

  • 當 JVM 載入的類資訊容量超過了這個值,會報異常 OutOfMemoryError:PermGen Space。

jdk8以後:

元資料大小可以使用引數 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定。與永久代不同,如果不指定大小,預設情況下,虛擬機器會耗盡所有的可用系統記憶體。如果元資料區發生溢位,虛擬機器一樣會丟擲異常 OutOfMemoryError: Metaspace。

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

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

如何解決OOM

要解決 OOM 異常或 Heap Space 的異常,一般的手段是首先通過記憶體分析工具(Eclipse Memory Analyzer)對 dump 出來的堆轉存快照進行分析,重點是確認記憶體中的物件是否是必要的,也就是要先分清楚到底是出現了記憶體洩露(Memory Leak)還是記憶體溢位(Memory Overflow)。

記憶體洩露就是指有大量的引用指向了一些物件,但是這些物件以後不會再被使用了,由於此時它們還和 GC Roots 有關聯,所以導致以後這些物件也不會被回收,這就是記憶體洩露的問題。

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

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

方法區的內部結構

方法區中主要儲存已被虛擬機器載入的型別資訊、常量、靜態變數、即時編譯器編譯後的程式碼快取等。

型別資訊

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

  • 這個型別的完整有效名稱(全名:包名.類名)
  • 這個型別直接父類的完整有效名稱(對於 interface 或是 java.lang.Object,都沒有父類)
  • 這個型別的修飾符(public、abstract、final 的某個子集)
  • 這個型別直接介面的一個有序列表
域資訊

JVM 必須在方法區中儲存型別的所有域的相關資訊以及域的宣告順序。

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

方法(Method)資訊

JVM 必須儲存所有方法的以下資訊,同域資訊一樣包括宣告順序:

  • 方法名稱
  • 方法的返回型別(或 void)
  • 方法引數的數量和型別(按順序)
  • 方法的修飾符(public,private,protected,static,final,synchronized,native,abstrat 的一個子集)
  • 方法的位元組碼(bytecodes)、運算元棧、區域性變量表及大小(abstract 和 native 方法除外)
  • 異常表(abstract 和 native 方法除外)
non-final 的類變數

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

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

// non-final 的類變數
public class MethodAreaTest {
    public static void main(String[] args) {
       Student student=new Student();
       student=null;
       student.hello();
       System.out.println(student.gender);

    }
}
class Student{
    public static String name="張三";
    public static final String gender="男";
    public static void hello(){
        System.out.println("hello");
    }
}

如上述程式碼所示,即使我們把 student 設定為 null,也不會出現空指標異常。

全域性常量

全域性常量就是使用 static final 進行修飾,每個全域性常量在編譯時就會被分配了。

執行時常量池 VS 常量池

顧名思義,執行時常量池就是指執行時的常量池。

  • 方法區,內部包含了執行時常量池
  • 位元組碼檔案,內部包含了常量池
  • 要弄清楚方法區,需要理解清楚 ClassFile,因為載入類的資訊都在方法區
  • 要弄清楚方法區的執行時常量池,就需要理解清楚 ClassFile 中的常量池

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

為什麼需要常量池

一個 java 原始檔中的類、介面,編譯後產生一個位元組碼檔案。而 java 中的位元組碼需要資料支援,通常這種資料會很大以至於不能直接存到位元組碼裡,換另一種方式,可以存到常量池,這個位元組碼包含了指向常量池的引用。

常量池中主要包括:

  • 數量值
  • 字串值
  • 類引用
  • 欄位引用
  • 方法引用

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

執行時常量池

執行時常量池是方法區的一部分。常量池表是 Class 檔案的一部分,用於存放編譯期生成的各種字面量和符合引用,這部分內容將在類載入後存放到方法區的執行時常量池中。

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

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

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

執行時常量池類似於傳統程式語言中的符號表,但是它所包含的資料卻比符號表要更豐富一些。

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

HotSpot 虛擬機器方法區的演進

  • jdk1.6及以前:有永久代,靜態變數儲存在永久代上。
  • jdk1.7 : 有永久代,但已經逐步在去“永久代”,字串常量池、靜態變數儲存在堆中。
  • jdk1.8 : 無永久代,型別資訊、欄位、方法、常量儲存在本地記憶體的元空間中,但字串常量池、靜態變數儲存在堆中。
字串常量池(StringTable)為什麼要調整位置

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

靜態變數存放在哪裡

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

方法區的垃圾回收

有些人認為方法區是沒有垃圾收集行為的,其實不然。《java 虛擬機器規範》對方法區的約束是非常寬鬆的,提到過可以不要求虛擬機器在方法區中實現垃圾收集。事實上也確實有未實現或未能完整實現方法區型別解除安裝的收集器存在。

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

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

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

  • 類和介面的全限定名
  • 欄位的名稱和描述符
  • 方法的名稱和描述符

HotSpot 虛擬機器對常量池的回收策略是很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收。回收廢棄常量和回收Java堆中的物件非常類似。(關於常量的回收比較簡單,重點是類的回收)。

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

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

Java 虛擬機器被允許對滿足上述三個條件的無用類進行回收,這裡說的僅僅是“被允許”,並不是和物件一樣,沒有引用了必然會回收。關於是否要對型別進行回收,HotSpot 虛擬機器提供了 -Xnoclassgc 引數進行控制,還可以使用 -verbose:class 以及 -XX:+TraceClassLoading、-XX:+TraceClassUnLoading 檢視類載入和解除安裝資訊。

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

最後

到此為止,我們就把 JVM 的方法區聊完了,如果覺得不錯,轉發、在看、點贊安排起來吧。

你知道的越多,你的思維越開闊。我們下期再見。