1. 程式人生 > 實用技巧 >Class常量池、執行時常量池、字串常量池的一些思考

Class常量池、執行時常量池、字串常量池的一些思考

Class常量池、執行時常量池、字串常量池

class常量池

java程式碼經過編譯之後都成了xxx.class檔案,這是java引以為傲的可移植性的基石。class檔案中,在CAFEBABE、主次版本號之後就是常量池入口了,入口是一個u2型別的資料,也就是佔據2個位元組,用來給常量池的容量計數,假設這個u2的數字為0x0016,那麼對應十進位制為22,那麼常量池中右21個常量,1-21,其中第0個用於表達“不引用任何一個常量”。在這兩個位元組之後就是編譯器為我們生成的常量了,這些常量包含了兩大類:字面量符號引用,通過一個例子看一下:

public class ThreePoolDemo {
    int a=1;
}

javap反編譯結果如下:

Classfile 
Constant pool:
   #1 = Class              #2             // com/hustdj/jdkStudy/threePool/ThreePoolDemo
   #2 = Utf8               com/hustdj/jdkStudy/threePool/ThreePoolDemo
   #3 = Class              #4             // java/lang/Object
   #4 = Utf8               java/lang/Object
   #5 = Utf8               a
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Methodref          #3.#11         // java/lang/Object."<init>":()V
  #11 = NameAndType        #7:#8          // "<init>":()V
  #12 = Fieldref           #1.#13         // com/hustdj/jdkStudy/threePool/ThreePoolDemo.a:I
  #13 = NameAndType        #5:#6          // a:I
  #14 = Utf8               LineNumberTable
  #15 = Utf8               LocalVariableTable
  #16 = Utf8               this
  #17 = Utf8               Lcom/hustdj/jdkStudy/threePool/ThreePoolDemo;
  #18 = Utf8               SourceFile
  #19 = Utf8               ThreePoolDemo.java
{
  int a;
    descriptor: I
    flags: (0x0000)

  public com.hustdj.jdkStudy.threePool.ThreePoolDemo();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #10                 // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #12                 // Field a:I
         9: return
      LineNumberTable:
        line 3: 0
        line 4: 4
        line 3: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   Lcom/hustdj/jdkStudy/threePool/ThreePoolDemo;
}
SourceFile: "ThreePoolDemo.java"

通過反編譯我們一睹Constant Pool真容,密密麻麻一大段,我們不妨就關注關注我們定義的成員變數a

//在<init>方法的第六行
6: putfield      #12                 
//可以看到進行了putfield,給成員變數賦值,雖然後面的註釋提醒了我們是變數a
//但是不妨跟著去看看常量池中的#12
#12 = Fieldref           #1.#13         
//這是一個Fieldref它又指向了#,#13,繼續追蹤
//#1代表是哪一個類,它又指向了一個UTF8的常量,這個常量就儲存了完整的類名
#1 = Class              #2             
#2 = Utf8               com/hustdj/jdkStudy/threePool/ThreePoolDemo
//#13告訴了你這個變數的name和type
#13 = NameAndType        #5:#6
//name是a,type是int
#5 = Utf8               a
#6 = Utf8               I

可以看到,在方法給成員變數a賦值是怎麼賦值的,通過Constant Pool來確定我們要給com/hustdj/jdkStudy/threePool/ThreePoolDemo物件的name為a型別為int的這麼一個變數賦值,相當於一個通訊錄,我要找一個人,你就告訴我這個人住在那裡,姓甚名誰。但是此刻它們都是符號引用,也就是說還僅僅是一串UTF8的字串,通過Constant Pool確定了一串字串,對應要找的哪個欄位、方法、物件,而這些符號引用需要等到類載入的解析階段變成直接引用,也就是直接指向對應的記憶體指標、偏移量等

執行時常量池

在《Java虛擬機器規範8》中是這樣描述的,執行時常量池(Runtime constant pool)是class檔案中每一個類或者介面的常量池表(constant pool)的執行時表示形式,它包含了若干常量,從編譯期可知的數值字面量到必須在執行期解析之後才能獲得的方法、欄位引用。也就是說class常量池=執行時常量池,只不過是不同的表現形式而已,一個是靜態的,一個是動態的,其中靜態的符號引用也都在執行時被解析成了動態的直接引用。

那麼執行時常量池是和類繫結的,每個類、介面有自己的執行時常量池,每一個執行時常量池的記憶體是在方法區進行分配的,這只是概念上的方法區,每個虛擬機器有自己的實現,同一個虛擬機器不同的版本也有不同的實現,以常用的Hotspot虛擬機器為例

  • 在1.6執行時常量池以及字串常量池存放在方法區,此時Hotspot對於方法區的實現為永久代(關於是否屬於堆記憶體https://www.zhihu.com/question/49044988)永久代屬於GC heap的一部分
  • 在1.7字串常量池被從方法區拿到了堆,執行時常量池還留在方法區中
  • 在1.8中hotspot移除了永久代用元空間取代它,字串常量池還在堆中,而執行時常量池依然在方法區也就是元空間(堆外記憶體)

字串常量池

為了減少頻繁建立相同字串的開銷,JVM弄了一個String Pool,它是全域性共享的,整個JVM獨一份,與之對應的有一個StringTable,,簡單來說它就是一個Hash Map,key--字串字面量,value--指向真正的字串物件的指標。任何通過字面量建立字串的方式都需要先通過HashMap檢查,如果有這個字面量,則直接返回value,如果沒有則建立一個。示例如下:

public class StringPoolDemo {
    public static void main(String[] args) {
        String a="123";
        String b="123";
        System.out.println(a==b);
    }
}
//輸出為true

它的過程如下:

如果這樣呢?

public class StringPoolDemo {
    public static void main(String[] args) {
        String a = new String("123");
        String b="123";
        System.out.println(a==b);
    }
}
//輸出false

它的過程如下:

如果這樣呢?

public class StringPoolDemo {
    public static void main(String[] args) {
        String a = new String("123");
        String b=a.intern();
        System.out.println(a==b);
    }
}

過程如下:

String s = new String(new char[]{'1', '2', '3'});
String s1=s.intern();
String s2 = "123";
System.out.println(s1==s);
System.out.println(s1==s2);
System.out.println(s==s2);

它的過程如下:

  1. 通過new建立了一個String物件,此時String Table並沒有記錄
  2. s.intern(),檢視String Table發現,並沒有這樣的一個字串,那麼新增記錄並且返回對應的地址,即s1指向snew出來的string物件
  3. s="123",同樣想去string table裡面檢視,發現已經有這樣的字串了,直接返回地址即可

所以s=s1=s2,三者指向了相同的物件

總結一下:

  • 直接根據字面量建立字串物件,首先檢查string table有沒有這個字串字面量,有的話直接返回對應的物件地址,沒有則建立一個string物件,並且string table記錄字串字面量->物件地址的對映
  • new必定會在heap中建立一個物件
  • intern執行的思路與通過字面量建立的思路一致,先檢查string table有沒有這樣的字串,有的話直接返回物件地址,沒有則入池,建立對映

再加入一些編譯期優化呢?以下程式碼摘自Java語言規範8

package com.hustdj.jdkStudy.threePool;

public class StringPoolDemo {
    public static void main(String[] args) {
        String hello="Hello",lo="lo";
        System.out.println(hello=="Hello");
        System.out.println(Other.hello==hello);
        System.out.println(com.hustdj.jdkStudy.other.Other.hello==hello);
        System.out.println(hello=="Hel"+"lo");
        System.out.println(hello=="Hel"+lo);
        System.out.println(hello==("Hel"+lo).intern());
    }
}

class Other{
    public static String hello="Hello";
}


package com.hustdj.jdkStudy.other;

public class Other {
    public static String hello="Hello";
}

輸出結果如下:

true
true
true
true
false
true

解釋如下:

//字串池是JVM層面的,與類、包無關
System.out.println(hello=="Hello");
System.out.println(Other.hello==hello);
System.out.println(com.hustdj.jdkStudy.other.Other.hello==hello);
//編譯期優化自動轉換成:hello=="Hello"
System.out.println(hello=="Hel"+"lo");
//通過StringBuilder.toString等於:new String("Hello");
System.out.println(hello=="Hel"+lo);
//intern操作時,string pool已經有"Hello"物件了,直接返回相同的引用,可以理解為入池失敗
System.out.println(hello==("Hel"+lo).intern());

可見JVM為了減少相同String物件的重複建立還是做了不少努力呀

Integer快取

同樣是減少重複物件的建立,Integer同樣做出了努力,示例程式碼如下:

public class UnboxingTest {
    public static void main(String[] args) {
        Integer a=1;
        Integer b=1;
        System.out.println(a==b);
    }
}
//輸出結果為true

Integer和String難道說採用了同樣的策略,Integer池?當然不是,遇事不決先看看位元組碼

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=3, args_size=1
         0: iconst_1
         1: invokestatic  #16                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         4: astore_1
         5: iconst_1
         6: invokestatic  #16                 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         9: astore_2
        10: getstatic     #22                 // Field java/lang/System.out:Ljava/io/PrintStream;
        13: aload_1
        14: aload_2
        15: if_acmpne     22
        18: iconst_1
        19: goto          23
        22: iconst_0
        23: invokevirtual #28                 // Method java/io/PrintStream.println:(Z)V
        26: return

可以看到Integer a= 1實際的指令應該是Integer a =Integer.valueOf(1)

那麼我們來看看Integer的原始碼:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

private static class IntegerCache {
    static final int low = -128;
    static final int high;
    static final Integer cache[];

    static {
        // high value may be configured by property
        int h = 127;
        String integerCacheHighPropValue =
            sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
        if (integerCacheHighPropValue != null) {
            try {
                int i = parseInt(integerCacheHighPropValue);
                i = Math.max(i, 127);
                // Maximum array size is Integer.MAX_VALUE
                h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
            } catch( NumberFormatException nfe) {
                // If the property cannot be parsed into an int, ignore it.
            }
        }
        high = h;

        cache = new Integer[(high - low) + 1];
        int j = low;
        for(int k = 0; k < cache.length; k++)
            cache[k] = new Integer(j++);

        // range [-128, 127] must be interned (JLS7 5.1.7)
        assert IntegerCache.high >= 127;
    }

    private IntegerCache() {}
}

不難發現,在Integer類初始完成之後就已經存在了-128<=value<=127的所有Integer物件,valueOf傳入的引數如果在這之間的話直接返回相應的物件即可,並且上限是可以修改的。

此外,Short、Character、Long、Byte、Boolean都是有快取處理的,而Float、Double沒有,它們的valueOf如下

public static Short valueOf(short s) {
    final int offset = 128;
    int sAsInt = s;
    if (sAsInt >= -128 && sAsInt <= 127) { // must cache
        return ShortCache.cache[sAsInt + offset];
    }
    return new Short(s);
}

public static Character valueOf(char c) {
    if (c <= 127) { // must cache
        return CharacterCache.cache[(int)c];
    }
    return new Character(c);
}

public static Long valueOf(long l) {
    final int offset = 128;
    if (l >= -128 && l <= 127) { // will cache
        return LongCache.cache[(int)l + offset];
    }
    return new Long(l);
}

public static Byte valueOf(byte b) {
    final int offset = 128;
    return ByteCache.cache[(int)b + offset];
}

public static Boolean valueOf(boolean b) {
    return (b ? TRUE : FALSE);
}

public static Float valueOf(float f) {
    return new Float(f);
}

public static Double valueOf(double d) {
    return new Double(d);
}

總結

  • String Pool是JVM層面實現的,Integer這些是Java層面通過靜態程式碼塊在類載入的初始化階段完成的
  • Integer的預設快取範圍為[-128,127],其它詳見程式碼,Float、Double並不提供快取
  • Integer的快取上限可擴大,最大為Integer.MAX_VALUE - (-low) -1

常量池的記憶體分佈問題

前面關於常量池的記憶體分佈已經做了介紹,這裡再補充一些。詳見關於問題方法區的Class資訊,又稱為永久代,是否屬於Java堆?的知乎討論https://www.zhihu.com/question/49044988

總結如下:

  • 永久代/方法區也屬於GC Heap的一部分

  • SymbolTable / StringTable,這倆table一直在native memory裡面

  • JDK6的以永久代(PermGen)作為方法區的實現,除了JIT編譯的程式碼存在native memory中以外,其他的方法區的資料都存在永久代中(此時的String Pool中的字串示例都是在永久代中的)

  • JDK7還是以永久代作為方法區的實現

    • 把Symbol的儲存從PermGen移動到了native memory
    • 把靜態變數從instanceKlass末尾(位於PermGen內)移動到了java.lang.Class物件的末尾(位於普通Java heap內)
    • StringTable引用的java.lang.String例項則從PermGen移動到了普通Java heap
  • JDK8中永久代徹底被移除,用元空間作為方法區的實現

為什麼需要移來移去呢?

在PermGen中元資料可能會隨著每一次Full GC發生而進行移動。HotSpot虛擬機器的每種型別的垃圾回收器都需要特殊處理PermGen中的元資料,分離出來以後可以簡化Full GC以及對以後的併發隔離類元資料等方面進行優化。

參考文獻

https://www.zhihu.com/question/49044988

https://segmentfault.com/a/1190000012577387