Java 中容易混淆的概念:Java 8 中的常量池、字符串池、包裝類對象池

1 - 引言

摘錄一些網上流傳比較廣泛的認識,但如果你認爲只懂這些就夠了,這篇文章就沒有必要繼續看下去了!!!

  1. 常量池分爲靜態常量池、運行時常量池。
  2. 靜態常量池在 .class 中,運行時常量池在方法區中,JDK 1.8 元空間(metaspace)成爲方法區的新實現,永久代被廢除。
  3. 字符串池在JDK 1.7 之後被分離到堆區。
  4. String str = new String("Hello world") 創建了 2 個對象,一個駐留在字符串池,一個分配在 Java 堆,str 指向堆上的實例。
  5. String.intern() 能在運行時向字符串池添加常量。
  6. 部分包裝類實現了池化技術,-128~127 以內的對象可以重用。

本文的實例講解都是針對 HotSpot 虛擬機的,如下圖,一般 Oracle 官網上安裝的 JDK 都使用該款虛擬機,使用 java -version 就能查看相關信息了。
在這裏插入圖片描述

2 - 常量池

2.1 你真的懂 Java的“字面量”和“常量”嗎?

在計算機科學中,字面量(literal)是用於表達源代碼中一個固定值的表示法(notation)。幾乎所有計算機編程語言都具有對基本值的字面量表示,諸如:整數、浮點數以及字符串等 1 。整數是程序中最常用的數字,整數在 Java 中就是一個整數字面量,例如十進制的1、2、16等,16進制的0x01、0x0A等。Java 中的字符串字面量和其他大多數語言相同,將一系列字符用雙引號括起來,如 "Hello world"等。

那麼常量又是什麼呢?如果是從 C/C++ 轉過來的程序員,一般認爲常量是被 const 修飾的變量或者某些宏定義,而在 Java 中,final 修飾的變量也可以被稱爲是常量。

但 Java 程序員的圈子裏,常量不單單指 final 變量,任何具有不變性的東西我們將它稱爲常量也不會帶來什麼歧義。

偶爾會在某些論壇中看到“字符串是常量,不可修改”。那麼,這種說法是從哪裏來的呢?這就要提到到 Java 中 String 類的設計了,打開 String 的源碼,我們看到前面的幾行定義如下:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    ...
}

我們看到不僅類定義使用 final 修飾,關鍵的字符數組同樣聲明爲 private final。但這就能保證字符串的不可修改性嗎?並不能,final修飾類定義只能使類不被繼承,字符數組被 final 修飾只能保證 value 不能指向其他內存,但我們仍然可以通過 value[0] = 'V' 的方式直接修改 value 的內容。

String 是不可變,關鍵是因爲 SUN 公司的工程師,在後面所有 String 的方法裏很小心的沒有去動數組裏的元素,沒有暴露內部成員字段。private final char value[] 這一句裏,private的私有訪問權限的作用都比 final 大。而且設計師還很小心地把整個 String 設成 final 禁止繼承,避免被其他人繼承後破壞。所以 String 是不可變的關鍵都在底層的實現,而不是一個 final。考驗的是工程師構造數據類型,封裝數據的功力 2

關於 String 的不可修改性更詳細的內容請參考引用 2

2.2 常量和靜態/運行時常量池有什麼關係?什麼是常量池?

在Java程序中,有很多的東西是永恆的,不會在運行過程中變化。比如一個類的名字,一個類字段的名字/所屬類型,一個類方法的名字/返回類型/參數名與所屬類型,一個常量,還有在程序中出現的大量的字面值。而這些在JVM解釋執行程序的時候是非常重要的。那麼編譯器將源程序編譯成class文件後,會用一部分字節分類存儲這些不變的代碼,而這些字節我們就稱爲常量池 3

Java 中靜態/運行時常量池並非特指保存 final 常量,它還保存諸如字面量、類和接口全限定名、字段、方法名稱、修飾符等永恆不變的東西。

2.3 字節碼下的常量池以及常量池的加載機制

JDK 1.8 下常量池存儲的常量類型主要是字面量和符號引用。

下面是靜態/運行時常量池的常量表類型:

常量表類型 標誌值(佔1 byte) 描述
CONSTANT_Utf8 1 UTF-8編碼的Unicode字符串
CONSTANT_Integer 3 int類型的字面值
CONSTANT_Float 4 float類型的字面值
CONSTANT_Long 5 long類型的字面值
CONSTANT_Double 6 double類型的字面值
CONSTANT_Class 7 對一個類或接口的符號引用
CONSTANT_String 8 String類型字面值的符號引用
CONSTANT_Fieldref 9 對一個字段的符號引用
CONSTANT_Methodref 10 對一個類中方法的符號引用
CONSTANT_InterfaceMethodref 11 對一個接口中方法的符號引用
CONSTANT_NameAndType 12 對一個字段或方法的部分符號引用

下面講一下符號引用:一個 Java 程序啓動時加載了衆多的類,有JDK的,也有我們自己定義的,那麼我們怎麼在程序運行的時候準確定位到類的位置呢?比如 String str = new String("xxx"),我們怎麼在虛擬機內存中找到 String 這個類的定義(或者說類的字節碼)呢?

答案就在常量池的符號引用中。在未加載到JVM的時候,在 .class 文件的靜態常量池中我們可以找到這麼一項 CONSTANT_Class,當然這一項僅僅只是符號引用,我們只知道有 java.lang.String 這麼一個類。只有等 JVM 啓動,並判斷程序用到 java.lang.String 的時候纔會加載 String 的 .class 文件到內存中(準確地說是方法區),之後,我們就可以在運行時常量池中將原本的符號引用替換爲直接引用了。也就是說實際上我們的定位是依靠運行時常量池的,這也就是爲什麼運行時常量池對於動態加載非常重要的原因。

詳細的內容可以瞭解一下 JVM 的類加載過程(加載、連接和初始化),如下圖,將 .class 文件中的靜態常量池轉換爲方法區的運行時常量池發生在“Loading”階段,而符號引用替換爲直接引用發生在 “Resolution”階段。

在這裏插入圖片描述
我們特別關注 CONSTANT_Utf8CONSTANT_String 這兩種常量類型。

CONSTANT_Utf8:用 UTF-8 編碼方式來表示程序中所有的重要常量字符串。這些字符串包括: ①類或接口的全限定名, ②超類的全限定名,③父接口的全限定名, ④類字段名和所屬類型名,⑤類方法名和返回類型名、以及參數名和所屬類型名,⑥字符串字面值。

每一個 CONSTANT_Utf8 常量項包括三項信息:length of byte array、length of string、string,以 System.out.println("Hello world") 爲例,我們可以找到下面這兩個 utf8 常量項(out、println 相關常量項省略了)。
在這裏插入圖片描述
在這裏插入圖片描述

CONSTANT_String:字符串字面量都以 utf8 的形式存儲,但是使用CONSTANT_Utf8 存儲的各種類型字符串這麼多,哪些是字符串字面量?哪些是全限定名字符串?所以需要一些指向該 utf8 項的符號引用常量來區分。CONSTANT_Class 的作用也是類似的,指向的是類全限定名的 utf8 項。

更加詳細的內容參考《Java虛擬機規範 Java SE 8版》4

2.4 是不是所有的數字字面量都會被存到常量池中?

看看下面的代碼:

void main(){
	int i = 1;
}

是不是能在常量池中找到CONSTANT_Integer 爲 1 的項呢?很遺憾,我們並沒有找到這麼一項 ,直到 int i = 32768 我們纔在表中找到 CONSTANT_Integer 爲 32768 的項。

爲什麼會出現這種情況呢?對於整數字面量來說,如果值在 -32768~32767 都會直接嵌入指令中,而不會保存在常量區。

對於 long、double 都有一些類似的情況,比如long l = 1Ldouble d = 1.0,都找不到對應的常量項。

但是如果使用 final 修飾變量,將其定義成類常量(注意不是在方法體內定義的局部常量),結果又有所不同,如下:

class main{
	final int i = 1;
}

此時,我們可以在常量池中找到 CONSTANT_Integer 爲 1 的項。

3 - 包裝類對象池 \neJVM 常量池

包裝類的對象池(也有稱常量池)和JVM的靜態/運行時常量池沒有任何關係。靜態/運行時常量池有點類似於符號表的概念,與對象池相差甚遠。

包裝類的對象池是池化技術的應用,並非是虛擬機層面的東西,而是 Java 在類封裝裏實現的。打開 Integer 的源代碼,找到 cache 相關的內容:

 	/**
     * Cache to support the object identity semantics of autoboxing for values between
     * -128 and 127 (inclusive) as required by JLS.
     *
     * The cache is initialized on first usage.  The size of the cache
     * may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
     * During VM initialization, java.lang.Integer.IntegerCache.high property
     * may be set and saved in the private system properties in the
     * sun.misc.VM class.
     */

    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() {}
    }

	/**
     * Returns an {@code Integer} instance representing the specified
     * {@code int} value.  If a new {@code Integer} instance is not
     * required, this method should generally be used in preference to
     * the constructor {@link #Integer(int)}, as this method is likely
     * to yield significantly better space and time performance by
     * caching frequently requested values.
     *
     * This method will always cache values in the range -128 to 127,
     * inclusive, and may cache other values outside of this range.
     *
     * @param  i an {@code int} value.
     * @return an {@code Integer} instance representing {@code i}.
     * @since  1.5
     */
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

IntegerCache 是 Integer 在內部維護的一個靜態內部類,用於對象緩存。通過源碼我們知道,Integer 對象池在底層實際上就是一個變量名爲 cache 的數組,裏面包含了 -128 ~ 127 的 Integer 對象實例。

使用對象池的方法就是通過 Integer.valueOf() 返回 cache 中的對象,像 Integer i = 10 這種自動裝箱實際上也是調用 Integer.valueOf() 完成的。

如果使用的是 new 構造器,則會跳過 valueOf(),所以不會使用對象池中的實例。

Integer i1 = 10;
Integer i2 = 10;
Integer i3 = new Integer(10);
Integer i4 = new Integer(10);
Integer i5 = Integer.valueOf(10);

System.out.println(i1 == i2);  // true
System.out.println(i2 == i3);  // false
System.out.println(i3 == i4);  // false
System.out.println(i1 == i5);  // true

注意到註釋中的一句話 “The cache is initialized on first usage”,緩存池的初始化在第一次使用的時候已經全部完成,這涉及到設計模式的一些應用。這和常量池中字面量的保存有很大區別,Integer 不需要顯示地出現在代碼中才添加到池中,初始化時它已經包含了所有需要緩存的對象。

4 - 字符串池

字符串池也是類似於對象池的這麼一種概念,但它是 JVM 層面的技術。

在 JDK 1.6 以及以前的版本中,字符串池是放在 Perm 區(Permanent Generation,永久代)。Perm 區是一個類靜態的區域,主要存儲一些加載類的信息,常量池,方法片段等內容,容量是固定的,默認在 32 M 到 96 M 間,我們可以通過 -XX:MaxPermSize = N 來配置永久代的大小,但是在運行過程中它仍然還是固定大小的。也有說 Perm 區實際上就是 HotSpot 下的方法區,HotSpot 的開發人員更願意將方法區稱爲 Permanent Generation,這裏我們不做過多的探討。

在 JDK 1.7 的版本中,字符串池移到Java Heap。在 JDK 1.8 中永久代的說法被廢棄,元空間成爲方法區的替代品。(本文 5.1 章節補充關於爲什麼永久代被廢棄)

4.1 字符串池的實現——StringTable

由於字符串池是虛擬機層面的技術,所以在 String 的類定義中並沒有類似 IntegerCache 這樣的對象池,String 類中提及緩存/池的概念只有intern() 這個方法,我將部分註釋做了一些翻譯和刪減:

	/**
     * 返回一個標準的字符串對象。
     * 
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * 
     * 當 intern 方法被調用,若池中包含一個被{@link #equals(Object)}方法認定爲和該
     * String對象相等的String,那麼返回池中的String,否則,將該String對象添加到池中
     * 並返回它的引用。
     * 
     * All literal strings and string-valued constant expressions are
     * interned. 
     */
    public native String intern();

我們看到,intern() 是一個native 的方法,那麼說明它本身並不是由 Java 語言實現的,而是通過 jni (Java Native Interface)調用了其他語言(如C/C++)實現的一些外部方法。

在 JDK 1.7後,Oracle 接管了 Java 的源碼後就不對外開放了,根據 JDK 的主要開發人員聲明 openJdk7 和 JDK 1.7 使用的是同一分主代碼,只是分支代碼會有些許的變動。所以可以直接跟蹤 openJdk7 的源碼來探究 intern() 的實現。

//// \openjdk7\hotspot\src\share\vm\prims\jvm.cpp

// String support 
JVM_ENTRY(jstring, JVM_InternString(JNIEnv *env, jstring str))
  JVMWrapper("JVM_InternString");
  JvmtiVMObjectAllocEventCollector oam;
  if (str == NULL) return NULL;
  oop string = JNIHandles::resolve_non_null(str);
  oop result = StringTable::intern(string, CHECK_NULL);
  return (jstring) JNIHandles::make_local(env, result);
JVM_END

大體實現:Java 調用 c++ 實現的 StringTable 的 intern() 方法。StringTable 的 intern() 方法跟 Java 中的 HashMap 的實現是差不多的,只是不能自動擴容,默認大小是1009。(不懂HashMap/HashSet/HashTable 實現的趕緊惡補~)

字符串池(String pool)實際上是一個 HashTable。Java 中 HashMap 和 HashTable 的原理大同小異,將字符串池看作哈希表更便於我們套用學習數據結構時的一些知識。比如解決數據衝突時,HashMap 和 HashTable 使用的是開散列(或者說拉鍊法)。

參考引用 5

The string pool is implemented as a fixed capacity hash map with each bucket containing a list of strings with the same hash code. Some implementation details could be obtained from the following Java bug report: http://bugs.sun.com/view_bug.do?bug_id=6962930.

常量池是一個固定容量的 hash map,每一個 bucket 包含一系列相同 hash 碼的字符串。

The default pool size is 1009 (it is present in the source code of the above mentioned bug report, increased in Java7u40). It was a constant in the early versions of Java 6 and became configurable between Java6u30 and Java6u41. It is configurable in Java 7 from the beginning (at least it is configurable in Java7u02). You need to specify -XX:StringTableSize = N, where N is the string pool map size. Ensure it is a prime number for the better performance.

池的默認大小爲 1009,在早期的 1.6 版本中是固定的,但是在 Java6u30 後,我們已經可以通過 -XX:StringTableSize = N 參數來配置這個 hash map 的大小。

This parameter will not help you a lot in Java 6, because you are still limited by a fixed size PermGen size. The further discussion will exclude Java 6.

但是配置 hash map 的大小在 1.6 版本中意義不大,因爲,此時 String pool 還在永久代中,正如我們前面所說,永久代的大小是固定的,hash map 的大小受限於此,我們仍需要小心使用 intern(),否則就有溢出的風險。

4.2 字符串池存的是實例還是引用?

這個問法其實本身就不太妥當,根據《Java 虛擬機規範》,堆是供對象實例化分配的區域,Java 程序中的對象實例都應該分配在堆上,我們通過引用對這些實例進行訪問。在 HotSpot 下的 reference 類型使用的都是直接指針的訪問形式,也就是直接指向堆上的實例對象。(相信大家也聽過 reference 使用句柄而非直接指針的另一種訪問形式,不過這裏討論的是 HotSpot VM)

字符串池這個 HashTable 保存的本質上是 reference,我們實際上想要知道的是字符串池是怎麼保存引用,引用的指向,有多少個實例的引用而已?

看一道比較常見的面試題,下面的代碼創建了多少個 String 對象?

String s1 = new String("he") + new String("llo");
String s2 = s1.intern();

System.out.println(s1 == s2);
// 在 JDK 1.6 下輸出是 false,創建了 6 個對象
// 在 JDK 1.7 之後的版本輸出是 true,創建了 5 個對象
// 當然我們這裏沒有考慮GC,但這些對象確實存在或存在過

爲什麼輸出會有這些變化呢?主要還是字符串池從永久代中脫離、移入堆區的原因, intern() 方法也相應發生了變化:

  1. 在 JDK 1.6 中,調用 intern() 首先會在字符串池中尋找 equal() 相等的字符串,假如字符串存在就返回該字符串在字符串池中的引用;假如字符串不存在,虛擬機會重新在永久代上創建一個實例,將 StringTable 的一個表項指向這個新創建的實例。
    在這裏插入圖片描述
  2. 在 JDK 1.7 中,由於字符串池不在永久代了,intern() 做了一些修改,更方便地利用堆中的對象。字符串存在時和 JDK 1.6一樣,但是字符串不存在時不再需要重新創建實例,可以直接指向堆上的實例。
    在這裏插入圖片描述

由上面兩個圖,也不難理解爲什麼 JDK 1.6 字符串池溢出會拋出 OutOfMemoryError: PermGen space ,而在 JDK 1.7 拋出 OutOfMemoryError: Java heap space

(題外話:寫的過程中還是會下意識地說“字符串池中的字符串”而不是“字符串池中的引用指向的字符串”,也確實可以體諒爲什麼許多人被這些文字遊戲繞得頭昏腦脹)

第 4 節更加詳細的內容請參考引用 6

5 - 補充

5.1 永久代爲何被 HotSpot VM 廢棄?

引用 7 提到原因有兩個:

  1. 由於 Permanent Generation 內存經常不夠用或發生內存泄露,引發惱人的java.lang.OutOfMemoryError: PermGen (在Java Web開發中非常常見)。

  2. 移除 Permanent Generation 可以促進 HotSpot JVM 與 JRockit VM 的融合,因爲 JRockit 沒有永久代 。

    This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.
    —— JEP 122: Remove the Permanent Generation

早在 JDK 1.7 的版本中 Orcale 已經宣佈要廢棄永久代了,事實上,永久代擁有了實例對象,這本身就已經不符合虛擬機規範。大多數虛擬機都沒有設置永久代的概念,字符串池的移出,更是使得永久代名存實亡,HotSpot 讓永久代迴歸爲方法區, 恐怕也是順應潮流了。

甚至我們可以理解爲 JDK 1.6 下的 永久代 = 字符串池 + 方法區 或者 永久代 = (包含字符串池的)方法區。瞭解它的真實存在形式,怎麼稱呼也無傷大雅。

5.2 爲什麼 Java 要分常量、簡單類型、引用類型等

這是偶然看到的一段話,特別摘錄下來:

爲什麼 Java 要分常量、簡單類型、引用類型等?
顯然 Java 並非是爲了考試和刁難它的使用者而徒增這些概念的。唯一的動機就是增加複雜性換取性能。那麼如果不換取性能,最簡單的方式是什麼呢?顯然就是一切變量都是引用類型,這是最簡單的,一個引用類型可以概括 Java 裏所有的東西。

那麼簡單類型和常量是什麼?它是特例,用特例換取性能。
對於整數來說,它頻繁參與到計算中,如果用定義一個類,並且使用一個指針的方式來使用它,就會浪費很多性能,所以纔有了簡單類型。而常量是怎麼回事?它是對大量重複使用的引用類型的一種性能優化,用共享對象的方式,來將大量相同的對象合併存儲唯一的一份 8

6 - 初學者容易混淆的地方

  1. 提到常量池,一般指的是方法區中的靜態/運行時常量池。
  2. 字符串池/字符串常量池/字符串對象池/String Pool/String Table 都可以看作一個東西。
  3. 包裝類對象池技術和 JVM 的常量池沒有任何關係。

等等…


正文結束,歡迎留言討論。碼字不易,請尊重原創,轉載註明出處:https://blog.csdn.net/Xu_JL1997/article/details/89150026

說點題外話,筆者真的希望這篇文章可以被更多人看到,特別是我們這代新生的 Java 使用者們。這篇文章的東西沒有什麼新鮮的,拆開每一部分你都能在某些文章、文檔或者書裏找到,它的組織思路來源於我在學習中產生的一個又一個問題,我要做的不僅是找到答案抄下來背一背,這意義不大,這並不是考試。

很慶幸當我將這篇文章的知識串聯起來並寫下來時,我的收穫已經遠遠超出預期了。我是願意將這份收穫分享給讀者的,我已然盡了最大的努力,接下來只能順其自然了。


  1. 百度百科詞條“字面量” ↩︎

  2. 在java中String類爲什麼要設計成final ↩︎ ↩︎

  3. 百度百科詞條“常量池” ↩︎

  4. 《Java虛擬機規範 Java SE 8版》 ↩︎

  5. String.intern in Java 6, 7 and 8 – string pooling ↩︎

  6. String的Intern方法詳解 ↩︎

  7. 深入探究JVM(2) - 探祕Metaspace ↩︎

  8. CSDN問答:java 局部變量 int a =1 是存放在哪裏 ↩︎

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