1. 程式人生 > >java基礎(八) 深入解析常量池與裝拆箱機制

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

java

引言

??本文將介紹常量池 與 裝箱拆箱機制,之所以將兩者合在一起介紹,是因為網上不少文章在談到常量池時,將包裝類的緩存機制,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,獲取優質資源。

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