java基礎(八) 深入解析常量池與裝拆箱機制

程式設計師歐陽思海發表於2018-04-18

引言

本文將介紹常量池 與 裝箱拆箱機制,之所以將兩者合在一起介紹,是因為網上不少文章在談到常量池時,將包裝類的快取機制,java常量池,不加區別地混在一起討論,更有甚者完全將這兩者視為一個整體,給初學者帶來不少困擾,我就是過來的。同時,也因為包裝類的快取 與 字串常量池的思想是一樣的,很容易混淆,但是實現方式是不一樣的。

一、常量池

在介紹常量池前,先來介紹一下常量、字面常量、符號常量的定義。

常量 可分為 字面常量(也稱為直接常量)和 符號常量

字面常量: 是指在程式中無需預先定義就可使用的數字、字元、boolen值、字串等。簡單的說,就是確定值的本身。如 10,2L,2.3f,3.5,“hello”,'a',true、false、null 等等。

符號常量: 是指在程式中用識別符號預先定義的,其值在程式中不可改變的量。如 final int a = 5;

常量池

常量池引入的 目的 是為了避免頻繁的建立和銷燬物件而影響系統效能,其實現了物件的共享。這是一種 享元模式 的實現。

二、 java常量池

Java的常量池可以細分為以下三類:

  • 量池,編譯階段)
  • 執行時常量池(又稱動態常量池,執行階段)
  • 字串常量池(全域性的常量池)

1. class檔案常量池

class檔案常量池,也被稱為 靜態常量池 ,它是.class檔案所包含的一項資訊。用於存放編譯器生成的各種字面量(Literal)和符號引用(Symbolic References)。 常量池在.class檔案的位置

這裡寫圖片描述
字面量: 就是上面所說的字面常量。 符號引用: 是一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可(它與直接引用區分一下,直接引用 一般是指向方法區的本地指標,相對偏移量或是一個能間接定位到目標的控制程式碼)。符號引用可以看作是一個虛擬地址,只有在JVM載入完類,確認了字面量的地址,才會將 符號引用 換成 直接引用。一般包括下面三類常量:

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

常量池的資訊

這裡寫圖片描述

2. 執行時常量池

執行時常量池,又稱為 動態常量池 ,是JVM在完成載入類之後將class檔案中常量池載入到記憶體中,並儲存在方法區中。也就是說,執行時常量池中的常量,基本來源於各個class檔案中的常量池。 執行時常量池相對於CLass檔案常量池的另外一個重要特徵是具備 動態性 ,Java語言並不要求常量一定只有編譯期才能產生,也就是並非預置入CLass檔案中常量池的內容才能進入方法區執行時常量池,執行期間也可能將新的常量放入池中,這種特性被開發人員利用比較多的就是String類的intern()方法。

jvm在執行某個類的時候,必須經過載入、連線、初始化,而連線又包括驗證、準備、解析三個階段。而當類載入到記憶體中後,jvm就會將class常量池中的內容存放到執行時常量池中,也就是說,每個class對應執行時常量池中的一個獨立空間,每個class檔案存放的位置互不干擾。而在解析階段,就會將符號引用替換成對應的直接引用。   不過,String型別 的字面常量要注意:並不是直接在堆上分配空間來建立物件的,JVM為String 字串額外維護了一個常量池 字串常量池,所以遇到字串常量是要先去字串池中尋找是否有重複,如果有,則返回對應的引用。否則,才建立並新增到字串常量池中。換句話說,對於String型別的字面常量,必須要在 字串常量池 中維護一個全域性的引用。

3. 字串常量池(string pool也有叫做string literal pool)

字串常量池儲存的就是字串的字面常量。詳細一點,字串常量池裡的內容是在類載入完成,經過驗證,準備階段之後在堆中生成字串物件例項,然後將該字串物件例項的引用值存到string pool中(記住:string pool中存的是引用值而不是具體的例項物件,具體的例項物件是在堆中開闢的一塊空間存放的。)。 在HotSpot VM裡實現的string pool功能的是一個StringTable類,它是一個雜湊表,裡面存的是駐留字串(也就是我們常說的用雙引號括起來的)的引用(而不是駐留字串例項本身),也就是說在堆中的某些字串例項被這個StringTable引用之後就等同被賦予了”駐留字串”的身份。這個StringTable在每個HotSpot VM的例項只有一份,被所有的類共享。

執行時常量池 與 字串常量池 的區別

字串常量池是位於執行時常量池中的。

網上有不少文章是將字串常量池作為執行時常量池同等來說,我一開始也以為這兩者就是同一個東西,其實不然。執行時常量池 與 字串常量池 在HotSpot的JDK1.6以前,都是放在方法區的,JDK1.7就將字串常量池移到了堆外記憶體中去。執行時常量池 為每一個Class檔案的常量池提供一個執行時的記憶體空間;而字串常量池則為所有Class檔案的String型別的字面常量維護一個公共的常量池,也就是Class檔案的常量池載入進執行時常量池後,其String字面常量的引用指向要與字串常量池的維護的要一致。

我們來幾個例子理解一下常量池

@ Example 1  簡單的例子

public class Test_6 {
public static void main(String[] args) {
    String str = "Hello World!";
}
}
複製程式碼

我們使用使用javap -v MyTest.class 檢視class檔案的位元組碼,經javap 處理可以輸出我們能看懂的資訊。如下圖:

這裡寫圖片描述
class檔案的索引#16位置(第16個常量池項)儲存的是 一個描述了字串字面常量資訊(型別,以及內容索引)的資料結構體,這個結構體被稱為CONSTANT_String_info。這個結構體並沒有儲存字串的內容,而是儲存了一個指向字串內容的索引--#17,即第17項儲存的是Hello World 的二進位制碼。

@ Example 2  String的+運算例子

我們再來看一個比較複雜的例子

public class Test_6 {
public static void main(String[] args) {
    String str_aa = "Love";
    String str_bb = "beautiful" + " girl";
    String str_cc = str_aa+" China";
}
}
複製程式碼

同樣,檢視class檔案的位元組碼資訊:

這裡寫圖片描述
  class檔案的常量池儲存了Lovebeautiful girlChina,但卻沒有 Love China。為什麼 str_bb 與 str_cc 都是通過 + 連結得到的,為什麼str_cc的值沒有出現在常量池中,而str_bb的值卻出現了。

這是因為str_bb的值是由兩個常量計算得到的,這種只有常量的表示式計算在編譯期間由編譯器計算得到的,要記住,能由編譯器完成的計算,就不會拖到執行期間來計算。   而str_cc的計算中包含了變數str_aa,涉及到變數的表示式計算都是在執行期間計算的,因為變數是無法在編譯期間確定它的值,特別是多執行緒下,同時得到結果是CPU動態分配空間儲存的,也就是說地址也無法確定。我們再去細看,就會發現常量池中的包含了StringBuilder以及其方法的描述資訊,其實,這個StringBuilder是為了計算str_aa+" China"表示式,先呼叫append()方法,新增兩個字串,在呼叫toString()方法,返回結果。也就是說,在執行期間,String字串通過 + 來連結的表示式計算都是通過建立StringBuilder來完成的

@ Example 3  String新建物件例子

下面的例子,str_bb的值是直接通過new新建一個物件,觀察靜態常量池。

public class MyTest {
public static void main(String[] args) {

    String str_bb = new String("Hello");
}
}
複製程式碼

檢視對應class檔案的位元組碼資訊:

這裡寫圖片描述
  通過new新建物件的操作是在執行期間才完成的,為什麼這裡仍舊在class檔案的常量池中出現呢?這是因為"Hello"本身就是一個字面常量,這是很容易讓人忽略的。有雙引號包裹的都是字面常量。同時,new建立一個String字串物件,確實是在執行時完成的,但這個物件將不同於字串常量池中所維護的常量。

二、自動裝箱拆箱機制 與 快取機制

先來簡單介紹一下自動裝箱拆箱機制

1、自動裝拆箱機制介紹

裝箱: 可以自動將基本型別直接轉換成對應的包裝型別。 拆箱: 自動將包裝型別轉換成對應的基本型別值;

    //普通的建立物件方式
    Integer a = new Integer(5);
    //裝箱
    Integer b = 5;
    //拆箱
    int c = b+5;
複製程式碼

2. 自動裝箱拆箱的原理

裝箱拆箱究竟是是怎麼實現,感覺有點神奇,居然可以使基本型別與包裝型別快速轉換。我們再稍微簡化上面的例子:

public class Test_6 {
public static void main(String[] args) {

    //裝箱
    Integer b = 5;
    //拆箱
    int c = b+5;
}
}
複製程式碼

依舊使用 javap -v Test_6.class 檢視這個類的class檔案的位元組碼資訊,如下圖:

這裡寫圖片描述
  可以從class的位元組碼發現,靜態常量池中,由Integer.valueOf()Integer.initValue() 這兩個方法的描述。這就有點奇怪,例子中的程式碼中並沒有呼叫這兩個方法,為什麼編譯後會出現呢?

感覺還是不夠清晰,我們換另一種反編譯工具來反編譯一下,這次我們反編譯回java程式碼,使用命令 jad Test_6.class ,得到的反編譯程式碼如下:

public class Test_6
{
    public static void main(String args[])
    {
        Integer b = Integer.valueOf(5);
        int c = b.intValue() + 5;
    }
}
複製程式碼

這回就非常直觀明瞭了。所謂裝箱拆箱並沒有多厲害,還是要通過呼叫Integer.valueOf()(裝箱) 和 Integer.initValue()(拆箱)來完成的。也就是說,自動裝箱拆箱機制是一種語法簡寫,為了方便程式設計師,省去了手動裝箱拆箱的麻煩,變成了自動裝箱拆箱

判別是裝箱還是拆箱

在下面的兩個例子中,可能會讓你很迷惑:不知道到底使用了裝箱,還是使用了拆箱。

 Integer x = 1;
 Integer y = 2;
 Integer z = x+y;
複製程式碼

這種情況其實只要仔細想一下便可以知道:這是 先拆箱再裝箱。因為Integer型別是引用型別,所以不能參與加法運算,必須拆箱成基本型別來求和,在裝箱成Integer。如果改造上面的例子,把Integer變成Short,則正確程式碼如下:

 Short a = 5;
 Short b = 6;
 Short c = (short) (a+b);
複製程式碼

3. 包裝類的快取機制

我們先來看一個例子

public class MyTest {
    public static void main(String[] args) {
        Integer a = 5;
        Integer b = 5;
        
        Integer c = 129;
        Integer d = 129;

        System.out.println("a==b "+ (a == b));
        System.out.println("c==d "+ (c == d));
    }
}
複製程式碼

執行結果:

a == b  true c == d  false

咦,為什麼是a和b所指向的是一個物件呢?難道JVM在類載入時也為包裝型別維護了一個常量池?如果是這樣,為什麼變數c、d的地址不一樣。事實上,JVM確實沒有為包裝類維護一個常量池。變數a、b、c、d是由裝箱得到的,根據前面所說的,裝箱其實是編譯器自動新增了Integer.valueOf() 方法。祕密應該就在這個方法內,那麼我們看一下Integer.valueOf()的原始碼吧,如下:

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

程式碼很簡單,判斷裝箱所使用的基本型別值是否在 [ IntegerCache.low, IntegerCache.high] 的範圍內,如果在,返回IntegerCache.cache陣列中對應下標的元素。否則,才新建一個物件。我們繼續深入檢視 IntegerCache 的原始碼,如下:

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() {}
    }
複製程式碼

從原始碼中,可以知道,IntegerCache.cache是一個final的Integer陣列,這個陣列儲存的Integer物件元素的值範圍是[-128,127]。而且這個陣列的初始化程式碼是包裹在static程式碼塊中,也就是說IntegerCache.cache陣列的初始化是在類載入時完成的。

再看回上面的例子,變數a和b的使用的基本型別值為5,超出[-128,127]的範圍,所以就使用快取陣列中的元素,所以a、b的地址是一樣的。而c、d使用的基本型別值為129,超出快取範圍,所以都是各自在堆上建立一個對,地址自然就不一樣了。

包裝類快取總結與補充:

  • 包裝類與String類很相似,都是非可變類,即一經建立後,便不可以修改。正因為這種特性,兩者的物件例項在多執行緒下是安全的,不用擔心非同步修改的情況,這為他們實現共享提供了很好的保證,只需建立一個物件共享便可。
  • 包裝類的共享實現並不是由JVM來維護一個常量池,而是使用了快取機制(陣列),而且這個快取是在類載入時完成初始化,並且不可再修改。
  • 包裝類的陣列快取範圍是有限,只快取基本型別值在一個位元組範圍內,也就是說 -128 ~ 127。(Character的範圍是 0~127)
  • 目前並不是所有包裝類都提供快取機制,只有Byte、Character、Short、Integer 4個包裝類提供,Long、Float、Double 不提供。

出處:http://www.cnblogs.com/jinggod/p/8425748.html

文章有不當之處,歡迎指正,你也可以關注我的微信公眾號:好好學java,獲取優質資源。

相關文章