浅谈String
原文鏈接:http://www.jianshu.com/p/2f209af80f84
前言
String字符串在Java應用中使用非常頻繁,只有理解了它在虛擬機中的實現機制,才能寫出健壯的應用,本文使用的JDK版本為1.8.0_3。
常量池
Java代碼被編譯成class文件時,會生成一個常量池(Constant pool)的數據結構,用以保存字面常量和符號引用(類名、方法名、接口名和字段名等)。
public class Test { public static void main(String[] args) { String test = "test"; } }很簡單的一段代碼,通過命令 javap -verbose 查看class文件中 Constant pool 實現:
上圖中的常量池中的內容:
Constant pool:#1 = Methodref #4.#13 // java/lang/Object."<init>":()V#2 = String #14 // test#3 = Class #15 // com/ctrip/ttd/whywhy/test#4 = Class #16 // java/lang/Object#5 = Utf8 <init>#6 = Utf8 ()V#7 = Utf8 Code#8 = Utf8 LineNumberTable#9 = Utf8 main#10 = Utf8 ([Ljava/lang/String;)V#11 = Utf8 SourceFile#12 = Utf8 test.java#13 = NameAndType #5:#6 // "<init>":()V#14 = Utf8 test#15 = Utf8 com/ctrip/ttd/whywhy/test#16 = Utf8 java/lang/Object通過反編譯出來的字節碼可以看出字符串 “test” 在常量池中的定義方式:
#2 = String #14 // test #14 = Utf8 test在main方法字節碼指令中,0 ~ 2行對應代碼 String test = “test”; 由兩部分組成:ldc #2 和 astore_1。
// main方法字節碼指令public static void main(java.lang.String[]);Code:0: ldc #2 // String test2: astore_13: return1、Test類加載到虛擬機時,”test”字符串在Constant pool中使用符號引用symbol表示,當調用 ldc #2 指令時,如果Constant pool中索引 #2 的symbol還未解析,則調用C++底層的 StringTable::intern 方法生成char數組,并將引用保存在StringTable和常量池中,當下次調用 ldc #2 時,可以直接從Constant pool根據索引 #2獲取 “test” 字符串的引用,避免再次到StringTable中查找。
2、astore_1指令將”test”字符串的引用保存在局部變量表中。
常量池的內存分配 在 JDK6、7、8中有不同的實現:
1、JDK6及之前版本中,常量池的內存在永久代PermGen進行分配,所以常量池會受到PermGen內存大小的限制。
2、JDK7中,常量池的內存在Java堆上進行分配,意味著常量池不受固定大小的限制了。
3、JDK8中,虛擬機團隊移除了永久代PermGen。
字符串初始化
字符串可以通過兩種方式進行初始化:字面常量和String對象。
字面常量:
public class StringTest {public static void main(String[] args) {String a = "java";String b = "java";String c = "ja" + "va";} }通過 “javap -c” 命令查看字節碼指令實現:
上圖中的字節碼指令:
public static void main(java.lang.String[]);Code:0: ldc #2 // String java2: astore_13: ldc #2 // String java5: astore_26: ldc #2 // String java8: astore_39: return其中ldc指令將int、float和String類型的常量值從常量池中推送到棧頂,所以a和b都指向常量池的”java”字符串。通過指令實現可以發現:變量a、b和c都指向常量池的 “java” 字符串,表達式 “ja” + “va” 在編譯期間會把結果值”java”直接賦值給c。
String對象
public class StringTest {public static void main(String[] args) {String a = "java";String c = new String("java");} }這種情況下,a == c 成立么?字節碼實現如下:
上圖中的字節碼:
public static void main(java.lang.String[]);Code:0: ldc #2 // String java2: astore_13: new #3 // class java/lang/String6: dup7: ldc #2 // String java9: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V12: astore_213: return其中3 ~ 9行指令對應代碼 String c = new String(“java”); 實現:
1、第3行new指令,在Java堆上為String對象申請內存;
2、第7行ldc指令,嘗試從常量池中獲取”java”字符串,如果常量池中不存在,則在常量池中新建”java”字符串,并返回;
3、第9行invokespecial指令,調用構造方法,初始化String對象。
其中String對象中使用char數組存儲字符串,變量a指向常量池的”java”字符串,變量c指向Java堆的String對象,且該對象的char數組指向常量池的”java”字符串,所以很顯然 a != c,如下圖所示:
通過 “字面量 + String對象” 進行賦值會發生什么?
public class StringTest {public static void main(String[] args) {String a = "hello ";String b = "world";String c = a + b;String d = "hello world";} }這種情況下,c == d成立么?字節碼實現如下:
上圖中的字節碼:
6: new #4 // class java/lang/StringBuilder9: dup10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V13: aload_114: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;17: aload_218: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;其中6 ~ 21行指令對應代碼 String c = a + b; 實現:
1、第6行new指令,在Java堆上為StringBuilder對象申請內存;
2、第10行invokespecial指令,調用構造方法,初始化StringBuilder對象;
3、第14、18行invokespecial指令,調用append方法,添加a和b字符串;
4、第21行invokespecial指令,調用toString方法,生成String對象。
通過指令實現可以發現,字符串變量的連接動作,在編譯階段會被轉化成StringBuilder的append操作,變量c最終指向Java堆上新建String對象,變量d指向常量池的”hello world”字符串,所以 c != d。
不過有種特殊情況,當final修飾的變量發生連接動作時,虛擬機會進行優化,將表達式結果直接賦值給目標變量:
public class StringTest {public static void main(String[] args) {final String a = "hello ";final String b = "world";String c = a + b;String d = "hello world";} }指令實現如下:
上圖中的字節碼:
0: ldc #2 // String hello world2: astore_33: ldc #2 // String hello worldString.intern()原理
String.intern()是一個Native方法,底層調用C++的 StringTable::intern 方法,源碼注釋:當調用 intern 方法時,如果常量池中已經該字符串,則返回池中的字符串;否則將此字符串添加到常量池中,并返回字符串的引用。
package com.ctrip.ttd.whywhy; class Test {public static void main(String args[]) {String s1 = new StringBuilder().append("String").append("Test").toString();System.out.println(s1.intern() == s1);String s2 = new StringBuilder().append("ja").append("va").toString();System.out.println(s2.intern() == s2);} }在 JDK6 和 JDK7 中結果不一樣:
1、JDK6的執行結果:false false
對于這個結果很好理解。在JDK6中,常量池在永久代分配內存,永久代和Java堆的內存是物理隔離的,執行intern方法時,如果常量池不存在該字符串,虛擬機會在常量池中復制該字符串,并返回引用,所以需要謹慎使用intern方法,避免常量池中字符串過多,導致性能變慢,甚至發生PermGen內存溢出。
2、JDK7的執行結果:true false
對于這個結果就有點懵了。在JDK7中,常量池已經在Java堆上分配內存,執行intern方法時,如果常量池已經存在該字符串,則直接返回字符串引用,否則復制該字符串對象的引用到常量池中并返回,所以在JDK7中,可以重新考慮使用intern方法,減少String對象所占的內存空間。
對于變量s1,常量池中沒有 “StringTest” 字符串,s1.intern() 和 s1都是指向Java對象上的String對象。
對于變量s2,常量池中一開始就已經存在 “java” 字符串,所以 s2.intern() 返回常量池中 “java” 字符串的引用。
String.intern()性能
常量池底層使用StringTable數據結構保存字符串引用,實現和HashMap類似,根據字符串的hashcode定位到對應的數組,遍歷鏈表查找字符串,當字符串比較多時,會降低查詢效率。
在JDK6中,由于常量池在PermGen中,受到內存大小的限制,不建議使用該方法。
在JDK7、8中,可以通過-XX:StringTableSize參數StringTable大小,下面通過幾個測試用例看看intern方法的性能。
執行一百萬次intern()方法,不同StringTableSize的耗時情況如下:
1、-XX:StringTableSize=1009, 平均耗時23000ms;
2、-XX:StringTableSize=10009, 平均耗時2200ms;
3、-XX:StringTableSize=100009, 平均耗時200ms;
4、默認情況下,平均耗時400ms;
在默認StringTableSize下,執行不同次intern()方法的耗時情況如下:
1、一萬次,平均耗時5ms;
2、十萬次,平均耗時25ms;
3、五十萬次,平均耗時130ms;
4、一百萬次,平均耗時400ms;
5、五百萬次,平均耗時5000ms;
6、一千萬次,平均耗時15000ms;
從這些測試數據可以看出,盡管在Java 7以上對intern()做了細致的優化,但其耗時仍然很顯著,如果無限制的使用intern()方法,將導致系統性能下降,不過可以將有限值的字符串放入常量池,提高內存利用率,所以intern()方法是一把雙刃劍。
總結
- 上一篇: 为myeclipse分配更大的内存
- 下一篇: 浅谈StringBuilder