JVM常量池的深入講解
提示:這裡咱們要說的常量池,常量池就是咱們面試中所說的常量池,談談你對常量池的認識?面試官一問咱們就懵逼瞭,你要記得你腦子中有一張圖!!! 剩下的就好辦瞭
提示:請各位大佬批評指正!!
前言
提示:學習的時候會有點頭疼哦
一、Class常量池與運行時常量池
Class常量池可以理解為是Class文件中的資源倉庫。 Class文件中除瞭包含類的版本、字段、方法、接口等描述信息外,還有一項信息就是 常量池(constant pool table) ,用於存放編譯期生成的各種 字面量(Literal)和符號引用(Symbolic References)。
還是回到前面說的class文件的16進制大體結構如下圖:
對應的含義如下,細節可以查下oracle官方文檔
當然我們一般不會去人工解析這種16進制的字節碼文件,我們一般可以通過javap命令生成更可讀的JVM字節碼指令文件:
javap -v Test.class
public class com.qjc.construction.Test minor version: 0 major version: 51 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #7.#27 // java/lang/Object."<init>":()V #2 = Class #28 // com/qjc/construction/Test #3 = Methodref #2.#27 // com/qjc/construction/Test."<init>":()V #4 = Methodref #2.#29 // com/qjc/construction/Test.test:()I #5 = Fieldref #30.#31 // java/lang/System.out:Ljava/io/PrintStream; #6 = Methodref #32.#33 // java/io/PrintStream.println:(Ljava/lang/Object;)V #7 = Class #34 // java/lang/Object #8 = Utf8 <init> #9 = Utf8 ()V #10 = Utf8 Code #11 = Utf8 LineNumberTable #12 = Utf8 LocalVariableTable #13 = Utf8 this #14 = Utf8 Lcom/qjc/construction/Test; #15 = Utf8 test #16 = Utf8 ()I #17 = Utf8 a #18 = Utf8 I #19 = Utf8 b #20 = Utf8 c #21 = Utf8 main #22 = Utf8 ([Ljava/lang/String;)V #23 = Utf8 args #24 = Utf8 [Ljava/lang/String; #25 = Utf8 SourceFile #26 = Utf8 Test.java #27 = NameAndType #8:#9 // "<init>":()V #28 = Utf8 com/qjc/construction/Test #29 = NameAndType #15:#16 // test:()I #30 = Class #35 // java/lang/System #31 = NameAndType #36:#37 // out:Ljava/io/PrintStream; #32 = Class #38 // java/io/PrintStream #33 = NameAndType #39:#40 // println:(Ljava/lang/Object;)V #34 = Utf8 java/lang/Object #35 = Utf8 java/lang/System #36 = Utf8 out #37 = Utf8 Ljava/io/PrintStream; #38 = Utf8 java/io/PrintStream #39 = Utf8 println #40 = Utf8 (Ljava/lang/Object;)V
Constant pool: 就是class常量池信息,常量池中主要存放兩大類常量:字面量和符號引用。
字面量
字面量就是指由字母、數字等構成的字符串或者數值常量
字面量隻可以右值出現,所謂右值是指等號右邊的值,如:int a=1 這裡的a為左值,1為右值。在這個例子中1就是字面量。
int a = 1; int b = 2; int c = "abcdefg"; int d = "abcdefg";
符號引用
符號引用是編譯原理中的概念,是相對於直接引用來說的。主要包括瞭以下三類常量:
- 類和接口的全限定名
- 字段的名稱和描述符
- 方法的名稱和描述符
上面的a,b就是字段名稱,就是一種符號引用,還有Test類常量池裡的 Lcom/qjc/construction/Test; 是類的全限定名,main和上面的a,b就是字段名稱,就是一種符號引用,還有Test類常量池裡的 Lcom/qjc/construction/Test; 是類的全限定名,main和test是方法名稱,()是一種UTF8格式的描述符,這些都是符號引用。
這些常量池現在是靜態信息,隻有到運行時被加載到內存後,這些符號才有對應的內存地址信息,這些常量池一旦被裝入內存就變成運行時常量池,對應的符號引用在程序加載或運行時會被轉變為被加載到內存區域的代碼的直接引用,也就是我們說的動態鏈接瞭。例如,test()這個符號引用在運行時就會被轉變為test()方法具體代碼在內存中的地址,主要通過對象頭裡的類型指針去轉換直接引用。是方法名稱,()是一種UTF8格式的描述符,這些都是符號引用。
這些常量池現在是靜態信息,隻有到運行時被加載到內存後,這些符號才有對應的內存地址信息,這些常量池一旦被裝入內存就變成運行時常量池,對應的符號引用在程序加載或運行時會被轉變為被加載到內存區域的代碼的直接引用,也就是我們說的動態鏈接瞭。例如,test()這個符號引用在運行時就會被轉變為test()方法具體代碼在內存中的地址,主要通過對象頭裡的類型指針去轉換直接引用。
二、字符串常量池
字符串常量池的設計思想
1.字符串的分配,和其他的對象分配一樣,耗費高昂的時間與空間代價,作為最基礎的數據類型,大量頻繁的創建字符串,極大程度地影響程序的性能
2.JVM為瞭提高性能和減少內存開銷,在實例化字符串常量的時候進行瞭一些優化
- 為字符串開辟一個字符串常量池,類似於緩存區
- 創建字符串常量時,首先查詢字符串常量池是否存在該字符串
- 存在該字符串,返回引用實例,不存在,實例化該字符串並放入池中
- 三種字符串操作(Jdk1.7 及以上版本)
- 直接賦值字符串
三種字符串操作(Jdk1.7 及以上版本)直接賦值字符串
String s = "qjc"; // s指向常量池中的引用
這種方式創建的字符串對象,隻會在常量池中。
因為有”qjc”這個字面量,創建對象s的時候,JVM會先去常量池中通過 equals(key) 方法,判斷是否有相同的對象。如果有,則直接返回該對象在常量池中的引用;如果沒有,則會在常量池中創建一個新對象,再返回引用。
new String();
String s1 = new String("qjc"); // s1指向內存中的對象引用
這種方式會保證字符串常量池和堆中都有這個對象,沒有就創建,最後返回堆內存中的對象引用。
步驟大致如下:
因為有”qjc”這個字面量,所以會先檢查字符串常量池中是否存在字符串”qjc”
不存在,先在字符串常量池裡創建一個字符串對象;再去內存中創建一個字符串對象”qjc”;
存在的話,就直接去堆內存中創建一個字符串對象”qjc”;
最後,將內存中的引用返回。
intern方法
String s1 = new String("qjc"); String s2 = s1.intern(); System.out.println(s1 == s2); //false
String中的intern方法是一個 native 的方法,當調用 intern方法時,如果池已經包含一個等於此String對象的字符串(用equals(oject)方法確定),則返回池中的字符串。否則,將intern返回的引用指向當前字符串 s1(jdk1.6版本需要將 s1 復制到字符串常量池裡)。
字符串常量池位置
Jdk1.6及之前: 有永久代, 運行時常量池在永久代,運行時常量池包含字符串常量池
Jdk1.7:有永久代,但已經逐步“去永久代”,字符串常量池從永久代裡的運行時常量池分離到堆裡
Jdk1.8及之後: 無永久代,運行時常量池在元空間,字符串常量池裡依然在堆裡
用一個程序證明下字符串常量池在哪裡:
/** * @author qijianchun * @title: TestPool * @projectName * @description: TODO JDK8:-Xms6M -Xmx6M -XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M * @description: TODO JDK6:-Xms6M -Xmx6M -XX:PermSize=6M -XX:MaxPermSize=6M * @date 2021/4/1313:02 */ public class TestPool { public static void main(String[] args) { ArrayList<String> list = new ArrayList<String>(); for (int i = 0; i < 10000000; i++) { String str = String.valueOf(i).intern(); list.add(str); } } }
結果:
運行結果:
jdk7及以上:Exception in thread “main” java.lang.OutOfMemoryError: Java heap space
jdk6:Exception in thread “main” java.lang.OutOfMemoryError: PermGen space
字符串常量池設計原理
字符串常量池底層是hotspot的C++實現的,底層類似一個 HashTable, 保存的本質上是字符串對象的引用。
看一道比較常見的面試題,下面的代碼創建瞭多少個 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 。
String常量池問題的幾個例子
示例1:
String s0="qjc"; String s1="qjc"; String s2="qj" + "c"; System.out.println( s0==s1 ); //true System.out.println( s0==s2 ); //true
分析:因為例子中的 s0和s1中的”qjc”都是字符串常量,它們在編譯期就被確定瞭,所以s0==s1為true;而”qj”和”c”也都是字符串常量,當一個字 符串由多個字符串常量連接而成時,它自己肯定也是字符串常量,所以s2也同樣在編譯期就被優化為一個字符串常量”qjc”,所以s2也是常量池中” qjc”的一個引用。所以我們得出s0s1s2;
示例2:
String s0="qjc"; String s1=new String("qjc"); String s2="qj" + new String("c"); System.out.println( s0==s1 );// false System.out.println( s0==s2 );// false System.out.println( s1==s2 );// false
分析:用new String() 創建的字符串不是常量,不能在編譯期就確定,所以new String() 創建的字符串不放入常量池中,它們有自己的地址空間。
s0還是常量池 中”qjc”的引用,s1因為無法在編譯期確定,所以是運行時創建的新對象”qjc”的引用,s2因為有後半部分 new String(”c”)所以也無法在編譯期確定,所以也是一個新創建對象”qjc”的引用;明白瞭這些也就知道為何得出此結果瞭。
示例3:
String a = "a1"; String b = "a" + 1; System.out.println(a == b); // true String a1 = "atrue"; String b1 = "a" + "true"; System.out.println(a1 == b1); // true String a2 = "a3.4"; String b2 = "a" + 3.4; System.out.println(a2 == b2); // true
分析:JVM對於字符串常量的”+“號連接,將在程序編譯期,JVM就將常量字符串的”+“連接優化為連接後的值,拿”a” + 1來說,經編譯器優化後在class中就已經是a1。在編譯期其字符串常量的值就確定下來,故上面程序最終的結果都為true。在編譯時就確定瞭,然後放入常量池
示例4:
String a = "ab"; String bb = "b"; String b = "a" + bb; System.out.println(a == b); // false
分析:JVM對於字符串引用,由於在字符串的”+“連接中,有字符串引用存在,而引用的值在程序編譯期是無法確定的,即”a” + bb無法被編譯器優化,隻有在程序運行期來動態分配並將連接後的新地址賦給b。所以上面程序的結果也就為false。
示例5:
String a = "ab"; final String bb = "b"; String b = "a" + bb; System.out.println(a == b); // true
分析:和示例4中唯一不同的是bb字符串加瞭final修飾,對於final修飾的變量,它在編譯時被解析為常量值的一個本地拷貝存儲到自己的常量池中或嵌入到它的字節碼流中。所以此時的”a” + bb和”a” + “b”效果是一樣的。故上面程序的結果為true。
示例6:
String a = "ab"; final String bb = getBB(); String b = "a" + bb; System.out.println(a == b); // false private static String getBB() { return "b"; }
分析:JVM對於字符串引用bb,它的值在編譯期無法確定,隻有在程序運行期調用方法後,將方法的返回值和”a”來動態連接並分配地址為b,故上面 程序的結果為false。
關於String是不可變的
通過上面例子可以得出得知:
String s = "a" + "b" + "c"; //就等價於String s = "abc"; String a = "a"; String b = "b"; String c = "c"; String s1 = a + b + c;
s1 這個就不一樣瞭,可以通過觀察其JVM指令碼發現s1的”+”操作會變成如下操作:
StringBuilder temp = new StringBuilder(); temp.append(a).append(b).append(c); String s = temp.toString();
因為調用toString方法就會 newString
這可就不一樣瞭 new String
再看一個例子:
//字符串常量池:"計算機"和"技術" 堆內存:str1引用的對象"計算機技術" //堆內存中還有個StringBuilder的對象,但是會被gc回收,StringBuilder的toString方法會new String(),這個String才是真正返回的對象引用 String str2 = new StringBuilder("計算機").append("技術").toString(); //沒有出現"計算機技術"字面量,所以不會在常量池裡生成"計算機技術"對象 System.out.println(str2 == str2.intern()); //true //"計算機技術" 在池中沒有,但是在heap中存在,則intern時,會直接返回該heap中的引用
//字符串常量池:"ja"和"va" 堆內存:str1引用的對象"java" //堆內存中還有個StringBuilder的對象,但是會被gc回收,StringBuilder的toString方法會new String(),這個String才是真正返回的對象引用 String str1 = new StringBuilder("ja").append("va").toString(); //沒有出現"java"字面量,所以不會在常量池裡生成"java"對象 System.out.println(str1 == str1.intern()); //false //java是關鍵字,在JVM初始化的相關類裡肯定早就放進字符串常量池瞭
String s1=new String("test"); System.out.println(s1==s1.intern()); //false //"test"作為字面量,放入瞭池中,而new時s1指向的是heap中新生成的string對象,s1.intern()指向的是"test"字面量之前在池中生成的字符串對象 String s2=new StringBuilder("abc").toString(); System.out.println(s2==s2.intern()); //false //同上
八種基本類型的包裝類和對象池
java中基本類型的包裝類的大部分都實現瞭常量池技術(嚴格來說應該叫對象池,在堆上),這些類是**Byte,Short,Integer,Long,Character,Boolean,**另外兩種浮點數類型的包裝類則沒有實現。另外Byte,Short,Integer,Long,Character這5種整型的包裝類也隻是在對應值小於等於127時才可使用對象池,也即對象不負責創建和管理大於127的這些類的對象。因為一般這種比較小的數用到的概率相對較大。
//5種整形的包裝類Byte,Short,Integer,Long,Character的對象, //在值小於127時可以使用對象池 Integer i1 = 127; //這種調用底層實際是執行的Integer.valueOf(127),裡面用到瞭IntegerCache對象池 Integer i2 = 127; System.out.println(i1 == i2);//輸出true //值大於127時,不會從對象池中取對象 Integer i3 = 128; Integer i4 = 128; System.out.println(i3 == i4);//輸出false //用new關鍵詞新生成對象不會使用對象池 Integer i5 = new Integer(127); Integer i6 = new Integer(127); System.out.println(i5 == i6);//輸出false
Boolean
//Boolean類也實現瞭對象池技術 Boolean bool1 = true; Boolean bool2 = true; System.out.println(bool1 == bool2);//輸出true
Double
//浮點類型的包裝類沒有實現對象池技術 Double d1 = 1.0; Double d2 = 1.0; System.out.println(d1 == d2);//輸出false
總結
到此這篇關於JVM常量池的文章就介紹到這瞭,更多相關JVM常量池內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- None Found