??需要說明的一點是,這篇文章是以《深入理解Java虛擬機》第二版這本書為基礎的,這里假設大家已經了解了JVM的運行時區域,以及class文件結構,類加載流程等基礎內容。當然,文中我們也會提一提相關的內容作為復習總結
一.JVM有幾種常量池
??主要分為:Class文件常量池、運行時常量池,當然還有全局字符串常量池,以及基本類型包裝類對象常量池
1.Class文件常量池
??閱讀過《深入理解Java虛擬機》這本書第6章內容的小伙伴肯定知道,class文件是一組以8位字節為單位的二進制數據流,在java代碼的編譯期間,我們編寫的.java文件就被編譯為.class文件格式的二進制數據存放在磁盤中,其中就包括class文件常量池。
??class 文件中存在常量池(非運行時常量池),其在編譯階段就已經確定;JVM 規范對 class 文件結構有著嚴格的規范,必須符合此規范的 class 文件才會被 JVM 認可和裝載。
為了方便說明,我們這里先寫一個很簡單的類:
class JavaBean{
private int value = 1;
public String s = "abc";
public final static int f = 0x101;
public void setValue(int v){
final int temp = 3;
this.value = temp + v;
}
public int getValue(){
return value;
}
}
通過javah命令編譯之后,用javap -v 命令查看編譯后的文件:
class JavaBasicKnowledge.JavaBean
minor version: 0
major version: 52
flags: ACC_SUPER
Constant pool:
#1 = Methodref #6.#29 // java/lang/Object."<init>":()V
#2 = Fieldref #5.#30 // JavaBasicKnowledge/JavaBean.value:I
#3 = String #31 // abc
#4 = Fieldref #5.#32 // JavaBasicKnowledge/JavaBean.s:Ljava/lang/String;
#5 = Class #33 // JavaBasicKnowledge/JavaBean
#6 = Class #34 // java/lang/Object
#7 = Utf8 value
#8 = Utf8 I
#9 = Utf8 s
#10 = Utf8 Ljava/lang/String;
#11 = Utf8 f
#12 = Utf8 ConstantValue
#13 = Integer 257
#14 = Utf8 <init>
#15 = Utf8 ()V
#16 = Utf8 Code
#17 = Utf8 LineNumberTable
#18 = Utf8 LocalVariableTable
#19 = Utf8 this
#20 = Utf8 LJavaBasicKnowledge/JavaBean;
#21 = Utf8 setValue
#22 = Utf8 (I)V
#23 = Utf8 v
#24 = Utf8 temp
#25 = Utf8 getValue
#26 = Utf8 ()I
#27 = Utf8 SourceFile
#28 = Utf8 StringConstantPool.java
#29 = NameAndType #14:#15 // "<init>":()V
#30 = NameAndType #7:#8 // value:I
#31 = Utf8 abc
#32 = NameAndType #9:#10 // s:Ljava/lang/String;
#33 = Utf8 JavaBasicKnowledge/JavaBean
#34 = Utf8 java/lang/Object
可以看到這個命令之后我們得到了該class文件的版本號、常量池、已經編譯后的字節碼指令(處于篇幅原因這里省略),下面我們會對照這個class文件來講解:
??這里我們需要說明一下,既然是常量池,那么其中個存放的肯定是“常量”,那么什么是“常量”呢?class文件常量池主要存放兩大常量:字面量和符號引用:
1).字面量
字面量接近于java語言層面的常量概念,主要包括:
-
文本字符串,也就是我們經常聲明的:
public String s = "abc";
中的"abc"
#9 = Utf8 s
#3 = String #31 // abc
#31 = Utf8 abc
- 用final修飾的成員變量,包括靜態變量、實例變量和局部變量
#11 = Utf8 f
#12 = Utf8 ConstantValue
#13 = Integer 257
??這里需要說明的一點,上面說的存在于常量池的字面量,指的是數據的值,也就是abc
和0x101(257)
,通過上面對常量池的觀察可知這兩個字面量是確實存在于常量池的。
??而對于基本類型數據(甚至是方法中的局部變量),也就是上面的private int value = 1
;常量池中只保留了他的的字段描述符I
和字段的名稱value
,他們的字面量不會存在于常量池:
2).符號引用
符號引用主要設涉及編譯原理方面的概念,包括下面三類常量:
-
類和接口的全限定名,也就是
Ljava/lang/String;
這樣,將類名中原來的"."替換為"/"得到的,主要用于在運行時解析得到類的直接引用,像上面:
#5 = Class #33 // JavaBasicKnowledge/JavaBean
#33 = Utf8 JavaBasicKnowledge/JavaBean
- 字段的名稱和描述符,字段也就是類或者接口中聲明的變量,包括類級別變量(static)和實例級的變量
#4 = Fieldref #5.#32 // JavaBasicKnowledge/JavaBean.value:I
#5 = Class #33 // JavaBasicKnowledge/JavaBean
#32 = NameAndType #7:#8 // value:I
#7 = Utf8 value
#8 = Utf8 I
//這兩個是局部變量,值保留字段名稱
#23 = Utf8 v
#24 = Utf8 temp
可以看到,class文件的常量池中也存在方法中的局部變量,但是沒有;但是常量池外面的字段表中不包括局部變量;
- 方法的名稱和描述符,方法的描述類似于JNI動態注冊時的“方法簽名”,也就是參數類型+返回值類型:
#21 = Utf8 setValue
#22 = Utf8 (I)V
#25 = Utf8 getValue
#26 = Utf8 ()I
2.運行時常量池
??運行時常量池是方法區的一部分,所以也是全局共享的。我們知道,jvm在執行某個類的時候,必須經過加載、連接(驗證,準備,解析)、初始化,在第一步的加載階段,虛擬機需要完成下面3件事情:
- 通過一個類的“全限定名”來獲取此類的二進制字節流
- 將這個字節流所代表的靜態儲存結構轉化為方法區的運行時數據結構
- 在內存中生成一個類代表這類的java.lang.Class對象,作為方法區這個類的各種數據訪問的入口
??這里需要說明的一點是,類對象和普通的實例對象是不同的,類對象是在類加載的時候生成的,普通的實例對象一般是在調用new之后創建。
??上面第二條,將class字節流代表的靜態儲存結構轉化為方法區的運行時數據結構,其中就包含了class文件常量池進入運行時常量池的過程。這里需要強調一下,不同的類共用一個運行時常量池(http://blog.csdn.net/fan2012huan/article/details/52759614),同時在進入運行時常量池的過程中,多個class文件中常量池中相同的字符串只會存在一份在運行時常量池中,這也是一種優化。
??運行時常量池的作用是存儲 Java class文件常量池中的符號信息。運行時常量池 中保存著一些 class 文件中描述的符號引用,同時在類加載的“解析階段”還會將這些符號引用所翻譯出來的直接引用(直接指向實例對象的指針)存儲在 運行時常量池 中。
??運行時常量池相對于 class 常量池一大特征就是其具有動態性,Java 規范并不要求常量只能在運行時才產生,也就是說運行時常量池中的內容并不全部來自 class 常量池,class 常量池并非運行時常量池的唯一數據輸入口;在運行時可以通過代碼生成常量并將其放入運行時常量池中,這種特性被用的較多的是String.intern()(這個方法下面將會詳細講)。
二.全局字符串常量池
??字符串常量池單獨列出來說有兩個原因:
- 不同于基本數據類型,String類型是一個final對象,他的字面量存在于class文件常量池中,但是運行期行為卻與普通常量不同
- JDK 1.7中,字符串常量池和類引用被移動到了Java堆中(與運行時常量池分離),因此不同版本的String行為也有所差異
1.Java中創建字符串對象的兩種方式
??這個問題我想大家一定非常清楚了吧,一般有如下兩種:
String s0 =”hellow”;
String s1=new String (“hellow”);
??第一種我們之前已經見過了,這種方式聲明的字面量hellow
是在編譯期就已經確定的,它會直接進入class文件常量池中;當運行期間在全局字符串常量池中會保存它的一個引用,實際上最終還是要在堆上創建一個”hellow”
對象,這個后面會講。
??第二種方式方式使用了new String()
,也就是調用了String類的構造函數,我們知道new指令是創建一個類的實例對象并完成加載初始化的,因此這個字符串對象是在運行期才能確定的,創建的字符串對象是在堆內存上。
??因此此時調用System.out.println(s0 == s1);
返回的肯定是flase,因此==
符號比較的是兩邊元素的地址,s1和s0都存在于堆上,但是地址肯定不相同。
下面我們來看看幾個非常常見的題目:
String s1 = "Hello";
String s2 = "Hello";
String s3 = "Hel" + "lo";
String s4 = "Hel" + new String("lo");
String s5 = new String("Hello");
String s7 = "H";
String s8 = "ello";
String s9 = s7 + s8;
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // true
System.out.println(s1 == s4); // false
System.out.println(s1 == s9); // false
1) s1 == s2
??這個對比第一部分常量池的講解應該很好理解,因為字面量"Hello"
在運行時會進入運行時常量池(中的字符串常量池,JDK1.7以前),同時同一份字面量只會保留一份,所有引用都指向這一份字符串,自然引用的地址也就相同了。
2) s1 == s3
??這個主要牽扯String"+"號編譯器優化的問題,s3雖然是動態拼接出來的字符串,但是所有參與拼接的部分都是已知的字面量,在編譯期間,這種拼接會被優化,編譯器直接幫你拼好,因此String s3 = "Hel" + "lo";在class文件中被優化成String s3 = "Hello";,所以s1 == s3成立。
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: ldc #2 // String Hello
2: astore_1
3: ldc #2 // String Hello
5: astore_2
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: aload_1
10: aload_2
11: if_acmpne 18
14: iconst_1
15: goto 19
18: iconst_0
19: invokevirtual #4 // Method java/io/PrintStream.println:(Z)V
22: return
??通過查看編譯后的方法代碼,可以看到這里加入操作數棧的ldc指令有兩次,都是“Hello”,沒有出現“Hel”或者“lo”,同時這兩個“Hello”指向常量池的通過一個地址,都是#2
,因此常量池中也只存在一個“Hello”
字面量。
3) s1 != s4
??其實這個也不難理解,但是我們還是先來看看編譯后的字節碼:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=4, locals=3, args_size=1
0: ldc #2 // String Hello
2: astore_1
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: ldc #5 // String Hel
12: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: new #7 // class java/lang/String
18: dup
19: ldc #8 // String lo
21: invokespecial #9 // Method java/lang/String."<init>":(Ljava/lang/String;)V
24: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
27: invokevirtual #10 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
30: astore_2
31: getstatic #11 // Field java/lang/System.out:Ljava/io/PrintStream;
34: aload_1
35: aload_2
36: if_acmpne 43
39: iconst_1
40: goto 44
43: iconst_0
44: invokevirtual #12 // Method java/io/PrintStream.println:(Z)V
47: return
??我們就不對操作符一一解釋了,可以看到這次確實出現了“String Hel”
和“String lo”
,原因上面我們也說過,這是因為new String("lo")
在堆中new了一個String對象出來,而“Hel”
字面量是通過另一種操作在堆中創建的對象,這兩個在堆中不同地方創建的對象是通過StringBuilder.append
方法拼接出來的,并且最終會調用StringBuilder.toString
方法輸出(最終輸出的也是“Hello”),這些通過上面字節碼的分析都可以看得出來,我們來看看StringBuilder.toString
方法:
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
??可以看到,這個最終是拼接出來的一個String對象,也就是說,s4指向的一個經過StringBuilder拼接之后的String對象,而s1指向的是另一個對象,這兩個對象的地址當然是不同的了。
4) s1 != s9
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=5, args_size=1
0: ldc #2 // String Hello
2: astore_1
3: ldc #3 // String H
5: astore_2
6: ldc #4 // String ello
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
16: aload_2
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_3
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4
29: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
32: aload_1
33: aload 4
35: if_acmpne 42
38: iconst_1
39: goto 43
42: iconst_0
43: invokevirtual #10 // Method java/io/PrintStream.println:(Z)V
46: return
??從變異后的字節碼看,這和3)中的情況是相同的,都是通過StringBuilder.append拼接后toString輸出的全新對象,至于這個對象被分配到哪里去了,我們也不知道。
2.String s1 = "Hello",到底有沒有在堆中創建對象?
??上面這張圖比是我們通常理解的JVM運行時數據區的結構,但是還有不完整的地方,為了說明全局字符串常量池概念,就必須拿出下面這張圖:
這張圖中,可以看到,方法區實際上是在一塊叫“非堆”的區域包含——可以簡單粗略的理解為非堆中包含了永生代,而永生代中又包含了方法區和字符串常量池,我們放大一下,一遍大家看的更清楚些:
??其中的Interned String就是全局共享的“字符串常量池(String Pool)”,和運行時常量池不是一個概念。但我們在代碼中申明String s1 = "Hello";
這句代碼后,在類加載的過程中,類的class文件的信息會被解析到內存的方法區里。
??class文件里常量池里大部分數據會被加載到“運行時常量池”,包括String的字面量;但同時“Hello”字符串的一個引用會被存到同樣在“非堆”區域的“字符串常量池”中,而"Hello"本體還是和所有對象一樣,創建在Java堆中。
??當主線程開始創建s1時,虛擬機會先去字符串池中找是否有equals(“Hello”)的String,如果相等就把在字符串池中“Hello”的引用復制給s1;如果找不到相等的字符串,就會在堆中新建一個對象,同時把引用駐留在字符串池,再把引用賦給str。
??當用字面量賦值的方法創建字符串時,無論創建多少次,只要字符串的值相同,它們所指向的都是堆中的同一個對象。
字符串常量池的本質
??看到這里,是時候引出字符串常量池的概念了:字符串常量池是JVM所維護的一個字符串實例的引用表,在HotSpot VM中,它是一個叫做StringTable的全局表。在字符串常量池中維護的是字符串實例的引用,底層C++實現就是一個Hashtable。這些被維護的引用所指的字符串實例,被稱作”被駐留的字符串”或”interned string”或通常所說的”進入了字符串常量池的字符串”。
??再強調一遍:運行時常量池在方法區(Non-heap),而JDK1.7后,字符串常量池被移到了heap區,因此兩者根本就不是一個概念。
3.String"字面量" 是何時進入字符串常量池的?
先說結論:在執行ldc指令時,該指令表示int、float或String型常量從常量池推送至棧頂
JVM規范里Class文件的常量池項的類型,有兩種東西(這段內容建議配合看書上168頁內容):
- CONSTANT_Utf8_info
- CONSTANT_String_info
??在HotSpot VM中,運行時常量池里,CONSTANT_Utf8_info可以表示Class文件的方法、字段等等,其結構如下:
首先是1個字節的tag,表示這是一個CONSTANT_Utf8_info結構的常量,然后是兩個字節的length,表示要儲存字節的長度,之后是一個字節的byte數組,表示真正的儲存的length個長度的字符串。這里需要注意的是,一個字節只是代表這里有一個byte類型的數組,而這個數組的長度當然可以遠遠大于一個字節。當然,由于CONSTANT_Utf8_info結構只能用u2即兩個字節來表示長度,因此長度的最大值為2byte,也就是65535(注意這跟Android中dex字節碼65535方法數限制沒有什么關系,但是道理是一樣的).
??后者CONSTANT_String_info是String常量的類型,但它并不直接持有String常量的內容,而是只持有一個index,這個index所指定的另一個常量池項必須是一個CONSTANT_Utf8類型的常量,這里才真正持有字符串的內容。
??CONSTANT_Utf8會在類加載的過程中就全部創建出來,而CONSTANT_String則是lazy resolve的,在第一次引用該項的ldc指令被第一次執行到的時候才會resolve。在尚未resolve的時候,HotSpot VM把它的類型叫做JVM_CONSTANT_UnresolvedString,內容跟Class文件里一樣只是一個index;等到resolve過后這個項的常量類型就會變成最終的JVM_CONSTANT_String,
??也就是說,就HotSpot VM的實現來說,加載類的時候,那些字符串字面量會進入到當前類的運行時常量池,不會進入全局的字符串常量池(即在StringTable中并沒有相應的引用,在堆中也沒有對應的對象產生),在執行ldc指令時,觸發lazy resolution這個動作:
??ldc字節碼在這里的執行語義是:到當前類的運行時常量池(runtime constant pool,HotSpot VM里是ConstantPool + ConstantPoolCache)去查找該index對應的項,如果該項尚未resolve則resolve之,并返回resolve后的內容。
??在遇到String類型常量時,resolve的過程如果發現StringTable已經有了內容匹配的java.lang.String的引用,則直接返回這個引用,反之,如果StringTable里尚未有內容匹配的String實例的引用,則會在Java堆里創建一個對應內容的String對象,然后在StringTable記錄下這個引用,并返回這個引用出去。
??可見,ldc指令是否需要創建新的String實例,全看在第一次執行這一條ldc指令時,StringTable是否已經記錄了一個對應內容的String的引用。
4.String.intern()用法
String.intern()官方給的定義:
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.
實際上,就是去拿String的內容去Stringtable里查表,如果存在,則返回引用,不存在,就把該對象的"引用"存在Stringtable表里。
這里采用《深入理解Java虛擬機》書上的兩個例子來解釋這個問題,第一個例子在P57頁:
public class RuntimeConstantPoolOOM{
public static void main(String[] args) {
String str1 = new StringBuilder("計算機").append("軟件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
}
以上代碼,在 JDK6 下執行結果為 false、false,在 JDK7 以上執行結果為 true、false。
??首先我們調用StringBuilder創建了一個"計算機軟件"String對象,因為調用了new關鍵字,因此是在運行時創建,之前JVM中是沒有這個字符串的。
??在 JDK6 下,intern()會把首次遇到的字符串實例復制到永久代中,返回的也是這個永久代中字符串實例的引用;而在JDK1.7開始,intern()方法不在復制字符串實例,tring 的 intern 方法首先將嘗試在常量池中查找該對象的引用,如果找到則直接返回該對象在常量池中的引用地址
??因此在1.7中,“計算機軟件”這個字符串實例只存在一份,存在于java堆中!通過3中的分析,我們知道當String str1 = new StringBuilder("計算機").append("軟件").toString();
這句代碼執行完之后,已經在堆中創建了一個字符串對象,并且在全局字符串常量池中保留了這個字符串的引用,那么str1.intern()直接返回這個引用,這當然滿足str1.intern() == str1
——都是他自己嘛;對于引用str2,因為JVM中已經有“java”這個字符串了,因此new StringBuilder("ja").append("va").toString()
會重新創建一個新的“java”字符串對象,而intern()會返回首次遇到的常量的實例引用,因此他返回的是系統中的那個"java"字符串對象引用(首次),因此會返回false
??在 JDK6 下 str1、str2 指向的是新創建的對象,該對象將在 Java Heap 中創建,所以 str1、str2 指向的是 Java Heap 中的內存地址;調用 intern 方法后將嘗試在常量池中查找該對象,沒找到后將其放入常量池并返回,所以此時 str1/str2.intern() 指向的是常量池中的地址,JDK6常量池在永久代,與堆隔離,所以 s1.intern()和s1 的地址當然不同了。
第二個例子在P56頁:
public class Test2 {
public static void main(String[] args) {
/**
* 首先設置 持久代最大和最小內存占用(限定為10M)
* VM args: -XX:PermSize=10M -XX:MaxPremSize=10M
*/
List<String> list = new ArrayList<String>();
// 無限循環 使用 list 對其引用保證 不被GC intern 方法保證其加入到常量池中
int i = 0;
while (true) {
// 此處永久執行,最多就是將整個 int 范圍轉化成字符串并放入常量池
list.add(String.valueOf(i++).intern());
}
}
}
以上代碼在 JDK6 下會出現 Perm 內存溢出,JDK7 or high 則沒問題。
??JDK6 常量池存在持久代(不經心CG),設置了持久代大小后,不斷while循環必將撐滿 Perm 導致內存溢出;JDK7 常量池被移動到 Native Heap(Java Heap,HotSpot VM中不區分native堆和Java堆),所以即使設置了持久代大小,也不會對常量池產生影響;不斷while循環在當前的代碼中,所有int的字符串相加還不至于撐滿 Heap 區,所以不會出現異常。
三.JAVA 基本類型的封裝類及對應常量池
??java中基本類型的包裝類的大部分都實現了常量池技術,這些類是Byte,Short,Integer,Long,Character,Boolean
,另外兩種浮點數類型的包裝類則沒有實現。另外上面這5種整型的包裝類也只是在對應值小于等于127時才可使用對象池,也即對象不負責創建和管理大于127的這些類的對象。
public class StringConstantPool{
public static void main(String[] args){
//5種整形的包裝類Byte,Short,Integer,Long,Character的對象,
//在值小于127時可以使用常量池
Integer i1=127;
Integer i2=127;
System.out.println(i1==i2);//輸出true
//值大于127時,不會從常量池中取對象
Integer i3=128;
Integer i4=128;
System.out.println(i3==i4);//輸出false
//Boolean類也實現了常量池技術
Boolean bool1=true;
Boolean bool2=true;
System.out.println(bool1==bool2);//輸出true
//浮點類型的包裝類沒有實現常量池技術
Double d1=1.0;
Double d2=1.0;
System.out.println(d1==d2); //輸出false
}
}
??在JDK5.0之前是不允許直接將基本數據類型的數據直接賦值給其對應地包裝類的,如:Integer i = 5;
但是在JDK5.0中支持這種寫法,因為編譯器會自動將上面的代碼轉換成如下代碼:Integer i=Integer.valueOf(5);
這就是Java的裝箱.JDK5.0也提供了自動拆箱:Integer i =5; int j = i;
??以及,這里常量池中緩存的是包裝類對象,而不是基本數據類型,要注意!!!