1. 程式人生 > 程式設計 >Java 中方法區與常量池

Java 中方法區與常量池

前言正文全域性字串池(string pool也有叫做string literal pool)class 檔案常量池(class constant pool)執行時常量池(runtime constant pool)三種常量池之間的關聯總結參考連結

前言

Java 的 JVM 的記憶體可分為 3 個區:堆記憶體(heap)、棧記憶體(stack)和方法區(method)也叫靜態儲存區。

在學習的過程中經常還會聽到常量池這一術語,在上節關於資料做 == 比較時,提到了字串常量池,經查詢得知常量池既不屬於堆,也不屬於棧記憶體 ,那麼常量池可能就和方法區有所關係,為此閱讀《深入淺出JVM》一書,瞭解常量池和方法區的關聯,同時對於常量池的分類也有了一定的認識。

本文所有程式碼都是基於 JDK1.8 進行的。

正文

在探討常量池的型別之前需要明白什麼是常量。

  • 用 final 修飾的成員變量表示常量,值一旦給定就無法改變!
  • final 修飾的變數有三種:靜態變數、例項變數和區域性變數,分別表示三種型別的常量。

在 Java 的記憶體分配中,總共 3 種常量池:

全域性字串池(string pool也有叫做string literal pool)

字串常量池在 Java 記憶體區域的哪個位置

  • 在 JDK6.0 及之前版本,字串常量池是放在 Perm Gen 區(也就是方法區)中,此時常量池中儲存的是物件。
  • 在 JDK7.0 版本,字串常量池被移到了堆中了。此時常量池儲存的就是引用了。在 JDK8.0 中,永久代(方法區)被元空間取代了。

字串常量池是什麼?

在 HotSpot VM 裡實現的 string pool 功能的是一個 StringTable 類,它是一個 Hash 表,預設值大小長度是1009;裡面存的是駐留字串的引用(而不是駐留字串例項自身)。也就是說某些普通的字串例項被這個 StringTable 引用之後就等同被賦予了“駐留字串”的身份。這個 StringTable 在每個 HotSpot VM 的例項裡只有一份,被所有的類共享。

StringTable 本質上就是個 HashSet<String>。這是個純執行時的結構,而且是惰性(lazy)維護的。注意它只儲存對java.lang.String 例項的引用,而不儲存 String 物件的內容。 注意,它只存了引用,根據這個引用可以得到具體的 String 物件。

在 JDK6.0 中,StringTable 的長度是固定的,長度就是 1009,因此如果放入 String Pool 中的 String 非常多,就會造成 hash 衝突,導致連結串列過長,當呼叫 String#intern() 時會需要到連結串列上一個一個找,從而導致效能大幅度下降;

在 JDK7.0 中,StringTable 的長度可以通過引數指定:

-XX:StringTableSize=66666
複製程式碼

class 檔案常量池(class constant pool)

我們都知道,class 檔案中除了包含類的版本、欄位、方法、介面等描述資訊外,還有一項資訊就是常量池(constant pool table),用於存放編譯器生成的各種字面量(Literal)和符號引用(Symbolic References)字面量比較接近 Java 語言層面常量的概念,如文字字串、被宣告為 final 的常量值等。 符號引用則屬於編譯原理方面的概念,包括瞭如下三種型別的常量:

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

常量池的每一項常量都是一個表,一共有如下表所示的11種各不相同的表結構資料,這每個表開始的第一位都是一個位元組的標誌位(取值1-12),代表當前這個常量屬於哪種常量型別。


每種不同型別的常量型別具有不同的結構,具體的結構本文就先不敘述了,本文著重區分這三個常量池的概念(讀者若想深入瞭解每種常量型別的資料結構可以檢視《深入理解java虛擬機器器》第六章的內容,其實是自己還沒弄明白,後續回來填坑 )。

執行時常量池(runtime constant pool)

執行時常量池是方法區的一部分。

當 Java 檔案被編譯成 class 檔案之後,也就是會生成上面所說的 class 常量池,那麼執行時常量池又是什麼時候產生的呢?

JVM 在執行某個類的時候,必須經過載入、連線、初始化,而連線又包括驗證、準備、解析(resolve)三個階段。而當類載入到記憶體中後,JVM 就會將 class 檔案常量池中的內容存放到執行時常量池中,由此可知,執行時常量池也是每個類都有一個。在上面也說了,class 常量池中存的是字面量和符號引用,也就是說它們存的並不是物件的例項,而是物件的符號引用值。而經過resolve 之後,也就是把符號引用替換為直接引用,解析的過程會去查詢全域性字串池,也就是上面所說的 StringTable,以保證執行時常量池所引用的字串與全域性字串池中所引用的是一致的。

三種常量池之間的關聯

關於 JVM 執行的時候,還涉及到了字串常量池

在類載入階段, JVM 會在堆中建立對應這些 class 檔案常量池中的字串物件例項,並在字串常量池中駐留其引用。具體在 resolve 階段執行。這些常量全域性共享。
複製程式碼

這裡說的比較籠統,沒錯,是 resolve 階段,但是並不是大家想的那樣,立即就建立物件並且在字串常量池中駐留了引用。 JVM 規範裡明確指定 resolve 階段可以是 lazy 的。

JVM 規範裡 Class 檔案常量池項的型別,有兩種東西:CONSTANT_Utf8 和CONSTANT_String。前者是 UTF-8 編碼的字串型別,後者是 String 常量的型別,但它並不直接持有 String 常量的內容,而是隻持有一個 index,這個 index 所指定的另一個常量池項必須是一個 CONSTANT_Utf8 型別的常量,這裡才真正持有字串的內容。

在HotSpot VM中,執行時常量池裡,

CONSTANT_Utf8 -> Symbol*(一個指標,指向一個Symbol型別的C++物件,內容是跟Class檔案同樣格式的UTF-8編碼的字串)
CONSTANT_String -> java.lang.String(一個實際的Java物件的引用,C++型別是oop)
複製程式碼

CONSTANT_Utf8 會在類載入的過程中就全部創建出來,而 CONSTANT_String 則是 lazy resolve 的,例如說在第一次引用該項的 ldc 指令被第一次執行到的時候才會 resolve。那麼在尚未 resolve 的時候,HotSpot VM 把它的型別叫做JVM_CONSTANT_UnresolvedString,內容跟 Class 檔案裡一樣只是一個 index;等到 resolve 過後這個項的常量型別就會變成最終的 JVM_CONSTANT_String,而內容則變成實際的那個 oop。

看到這裡想必也就明白了, 就 HotSpot VM 的實現來說,載入類的時候,那些字串字面量會進入到當前類的執行時常量池,不會進入全域性的字串常量池(即在 StringTable 中並沒有相應的引用,在堆中也沒有對應的物件產生)。所以上面提到的,經過 resolve 時,會去查詢全域性字串池,最後把符號引用替換為直接引用。(即字面量和符號引用雖然在類載入的時候就存入到執行時常量池,但是對於 lazy resolve 的字面量,具體操作還是會在 resolve 之後進行的。)

關於 lazy resolution 需要在這裡瞭解一下 ldc 指令

簡單地說,它用於將 String 型常量值從常量池中推送至棧頂。

以下面程式碼為例:

    public static void main(String[] args) {
        String s = "abc";
    }
複製程式碼

比如說該程式碼檔案為 Test.java,首先在檔案目錄下開啟 Dos 視窗,執行 javac Test.java 進行編譯,然後輸入 javap -verbose Test 檢視其編譯後的 class 檔案如下:


使用 ldc 指令將"abc"載入到運算元棧頂,然後用 astore_1 把它賦值給我們定義的區域性變數 s,然後 return。

結合上文所講,在 resolve 階段( constant pool resolution ),字串字面量被建立物件並在字串常量池中駐留其引用,但是這個 resolve 是 lazy 的。換句話說並沒有真正的物件,字串常量池裡自然也沒有,那麼 ldc 指令還怎麼把值推送至棧頂並進行了賦值操作?或者換一個角度想,既然 resolve 階段是 lazy 的,那總有一個時候它要真正的執行吧,是什麼時候?

執行 ldc 指令就是觸發 lazy resolution 動作的條件

ldc 位元組碼在這裡的執行語義是:到當前類的執行時常量池(runtime constant pool,HotSpot VM裡是ConstantPool + ConstantPoolCache)去查詢該 index 對應的項,如果該項尚未 resolve 則 resolve 之,並返回 resolve 後的內容。
在遇到 String 型別常量時,resolve 的過程如果發現 StringTable 已經有了內容匹配的 java.lang.String 的引用,則直接返回這個引用;反之,如果 StringTable 裡尚未有內容匹配的 String 例項的引用,則會在 Java 堆裡建立一個對應內容的 String 物件,然後在 StringTable 記錄下這個引用,並返回這個引用。

可見,ldc 指令是否需要建立新的 String 例項,全看在第一次執行這一條 ldc 指令時,StringTable 是否已經記錄了一個對應內容的 String 的引用。

用以下程式碼做分析展示:

 public void main(String[] args) {
           String s1 = "abc";  
        String s2 = "abc";
        String s3 = "xxx";
    }
複製程式碼

檢視其編譯後的 class 檔案如下:

在這裡插入圖片描述
在這裡插入圖片描述

用圖解的方式展示:
在這裡插入圖片描述
在這裡插入圖片描述

String s1 = "abc";resolve 過程在字串常量池中發現沒有”abc“的引用,便在堆中新建一個”abc“的物件,並將該物件的引用存入到字串常量池中,然後把這個引用返回給 s1。

String s2 = "abc"; resolve 過程會發現 StringTable 中已經有了”abc“物件的引用,則直接返回該引用給 s2,並不會建立任何物件。

String s3 = "xxx"; 同第一行程式碼一樣,在堆中建立物件,並將該物件的引用存入到 StringTable,最後返回引用給 s3。

常量池與 intern 方法

"ab";//#1
        String s2 = new String(s1+"d");//#2
        s2.intern();//#3
        String s4 = "xxx";//#4
        "abd";//#5
        System.out.println(s2 == s3);//true
    }
複製程式碼

檢視其編譯後的 class 檔案如下:

通過 class 檔案資訊可知,“ab”、“d”、“xxx”,“abd”進入到了 class 檔案常量池,由於類在 resolve 階段是 lazy 的,所以是不會建立例項物件,更不會駐留字串常量池。

圖解如下:


進入 main 方法,對每行程式碼進行解讀。

  • 1,ldc 指令會把“ab”載入到棧頂,換句話說,在堆中建立“ab”物件,並把該物件的引用儲存到字串常量池中。
  • 2,ldc 指令會把“d”載入到棧頂,然後有個拼接操作,內部是建立了一個 StringBuilder 物件,一路 append,最後呼叫 StringBuilder 物件的 toString 方法得到一個 String 物件(內容是 abd,注意 toString 方法會 new 一個 String 物件),並把它賦值給 s2(賦值給 s2 的依然是物件的引用而已)。注意此時沒有把“abd”物件的引用放入字串常量池。
  • 3,intern 方法首先會去字串常量池中查詢是否有“abd”物件的引用,如果沒有,則把堆中“abd”物件的引用儲存到字串常量池中,並返回該引用,但是我們並沒有使用變數去接收它。
  • 4,無意義,只是為了說明 class 檔案中的“abd”字面量是#5時得到的。
  • 5,字串常量池中已經有“abd”物件的引用,因此直接將該引用返回給 s3。

總結

1、全域性字串常量池在每個 VM 中只有一份,存放的是字串常量的引用值。

2、class 常量池是在編譯的時候每個 class 都有的,在編譯階段,存放各種字面量和符號引用。

3、執行時常量池是在類載入完成之後,將每個class常量池中的符號引用值轉存到執行時常量池中,也就是說,每個 class 都有一個執行時常量池,類在解析之後,將符號引用替換成直接引用,與全域性常量池中的引用值保持一致。

4、class 檔案常量池中的字串字面量在類載入時進入到執行時常量池,在真正在 resolve 階段(即執行 ldc 指令時)時將該字串的引用存入到字串常量池中,另外執行時常量池相對於 class 檔案常量池具備動態性,有些常量不一定在編譯期產生,也就是並非預置入 class 檔案常量池的內容才能進入到方法區執行時常量池,執行期間通過 intern 方法,將字串常量存入到字串常量池中和執行時常量池(關於優先進入到哪個常量池,私以為先進入到字串常量池,具體實現還望大神指教)。

參考連結