字符串常量池詳解
在深入學習字符串類之前, 我們先搞懂JVM是怎樣處理新生字符串的.
當你知道字符串的初始化細節后, 再去寫String s = "hello"
或String s = new String("hello")
等代碼時, 就能做到心中有數.
- 首先得搞懂字符串常量池的概念.
- 常量池是Java的一項技術, 八種基礎數據類型除了float和double都實現了常量池技術. 這項技術從字面上是很好理解的: 把經常用到的數據存放在某塊內存中, 避免頻繁的數據創建與銷毀, 實現數據共享, 提高系統性能.
- 字符串常量池是Java常量池技術的一種實現, 在近代的JDK版本中(1.7后), 字符串常量池被實現在Java堆內存中.
- 下面通過三行代碼讓大家對字符串常量池建立初步認識:
public static void main(String[] args) {
String s1 = "hello";
String s2 = new String("hello");
System.out.println(s1 == s2); //false
}
- 我們先來看看第一行代碼
String s1 = "hello";
干了什么.
- 對於這種直接通過雙引號""聲明字符串的方式, 虛擬機首先會到字符串常量池中查找該字符串是否已經存在. 如果存在會直接返回該引用, 如果不存在則會在堆內存中創建該字符串對象, 然后到字符串常量池中注冊該字符串.
- 在本案例中虛擬機首先會到字符串常量池中查找是否有存在"hello"字符串對應的引用. 發現沒有后會在堆內存創建"hello"字符串對象(內存地址0x0001), 然后到字符串常量池中注冊地址為0x0001的"hello"對象, 也就是添加指向0x0001的引用. 最后把字符串對象返回給s1.
- 溫馨提示: 圖中的字符串常量池中的數據是虛構的, 由於字符串常量池底層是用HashTable實現的, 存儲的是鍵值對, 為了方便大家理解, 示意圖簡化了字符串常量池對照表, 並采用了一些虛擬的數值.
- 下面看
String s2 = new String("hello");
的示意圖
- 當我們使用new關鍵字創建字符串對象的時候, JVM將不會查詢字符串常量池, 它將會直接在堆內存中創建一個字符串對象, 並返回給所屬變量.
- 所以s1和s2指向的是兩個完全不同的對象, 判斷s1 == s2的時候會返回false.
如果上面的知識理解起來沒有問題的話, 下面看些難點的.
public static void main(String[] args) {
String s1 = new String("hello ") + new String("world");
s1.intern();
String s2 = "hello world";
System.out.println(s1 == s2); //true
}
- 第一行代碼
String s1 = new String("hello ") + new String("world");
的執行過程是這樣子的:
- 依次在堆內存中創建"hello "和"world"兩個字符串對象
- 然后把它們拼接起來 (底層使用StringBuilder實現, 后面會帶大家讀反編譯代碼)
- 在拼接完成后會產生新的"hello world"對象, 這時變量s1指向新對象"hello world".
- 執行完第一行代碼后, 內存是這樣子的:
- 第二行代碼
s1.intern();
- String類的源碼中有對
intern()
方法的詳細介紹, 翻譯過來的意思是: 當調用intern()
方法時, 首先會去常量池中查找是否有該字符串對應的引用, 如果有就直接返回該字符串; 如果沒有, 就會在常量池中注冊該字符串的引用, 然后返回該字符串. - 由於第一行代碼采用的是new的方式創建字符串, 所以在字符串常量池中沒有保存"hello world"對應的引用, 虛擬機會在常量池中進行注冊, 注冊完后的內存示意圖如下:
- 第三行代碼
String s2 = "hello world";
- 這種直接通過雙引號""聲明字符串背后的運行機制我們在第一個案例提到過, 這里正好復習一下.
- 首先虛擬機會去檢查字符串常量池, 發現有指向"hello world"的引用. 然后把該引用所指向的字符串直接返回給所屬變量.
- 執行完第三行代碼后, 內存示意圖如下:
- 如圖所示, s1和s2指向的是相同的對象, 所以當判斷s1 == s2時返回true.
- 最后我們對字符串常量池進行總結: 當用new關鍵字創建字符串對象時, 不會查詢字符串常量池; 當用雙引號直接聲明字符串對象時, 虛擬機將會查詢字符串常量池. 說白了就是: 字符串常量池提供了字符串的復用功能, 除非我們要顯式創建新的字符串對象, 否則對同一個字符串虛擬機只會維護一份拷貝.
配合反編譯代碼驗證字符串初始化操作.
- 相信看到這里, 再見到有關的面試題, 你已經無所畏懼了, 因為你已經懂得了背后原理.
- 在結束之前我們不妨再做一道壓軸題
public class Main {
public static void main(String[] args) {
String s1 = "hello ";
String s2 = "world";
String s3 = s1 + s2;
String s4 = "hello world";
System.out.println(s3 == s4);
}
}
這道壓軸題是經過精心設計的, 它不但照應上面所講的字符串常量池知識, 也引出了后面的話題.
- 如果看這篇文章是你第一次往底層探索字符串的經歷, 那我估計你不能立即給出答案. 因為我第一次見這幾行代碼時也卡殼了.
- 首先第一行和第二行是常規的字符串對象聲明, 我們已經很熟悉了, 它們分別會在堆內存創建字符串對象, 並會在字符串常量池中進行注冊.
- 影響我們做出判斷的是第三行代碼
String s3 = s1 + s2;
, 我們不知道s1 + s2
在創建完新字符串"hello world"后是否會在字符串常量池進行注冊. 說白了就是我們不知道這行代碼是以雙引號""形式聲明字符串, 還是用new關鍵字創建字符串. - 這時, 我們應該去讀一讀這段代碼的反編譯代碼. 如果你沒有讀過反編譯代碼, 不妨借此機會入門.
- 在命令行中輸入
javap -c 對應.class文件的絕對路徑
, 按回車后即可看到反編譯文件的代碼段.
C:\Users\liuyj>javap -c C:\Users\liuyj\IdeaProjects\Test\target\classes\forTest\Main.class
Compiled from "Main.java"
public class forTest.Main {
public forTest.Main();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String hello
2: astore_1
3: ldc #3 // String world
5: astore_2
6: new #4 // class java/lang/StringBuilder
9: dup
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
13: aload_1
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
17: aload_2
18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
24: astore_3
25: ldc #8 // String hello world
27: astore 4
29: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
32: aload_3
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
}
- 首先調用構造器完成Main類的初始化
0: ldc #2 // String hello
- 從常量池中獲取"hello "字符串並推送至棧頂, 此時拿到了"hello "的引用
2: astore_1
- 將棧頂的字符串引用存入第二個本地變量s1, 也就是s1已經指向了"hello "
3: ldc #3 // String world
5: astore_2
- 重復開始的步驟, 此時變量s2指向"word"
6: new #4 // class java/lang/StringBuilder
- 刺激的東西來了: 這時創建了一個StringBuilder, 並把其引用值壓到棧頂
9: dup
- 復制棧頂的值, 並繼續壓入棧定, 也就意味着棧從上到下有兩份StringBuilder的引用, 將來要操作兩次StringBuilder.
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
- 調用StringBuilder的一些初始化方法, 靜態方法或父類方法, 完成初始化.
- 13: aload_1
- 把第二個本地變量也就是s1壓入棧頂, 現在棧頂從上往下數兩個數據依次是:s1變量和StringBuilder的引用
14: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
- 調用StringBuilder的append方法, 棧頂的兩個數據在這里調用方法時就用上了.
- 接下來又調用了一次append方法(之前StringBuilder的引用拷貝兩份就用途在此)
- 完成后, StringBuilder中已經拼接好了"hello world", 看到這里相信大家已經明白虛擬機是如何拼接字符串的了. 接下來就是關鍵環節
21: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
24: astore_3
- 拼接完字符串后, 虛擬機調用StringBuilder的
toString()
方法獲得字符串hello world
, 並存放至s3. - 激動人心的時刻來了, 我們之所以不知道這道題的答案是因為不知道字符串拼接后是以new的形式還是以雙引號""的形式創建字符串對象.
- 下面是我們追蹤StringBuilder的
toString()
方法源碼:
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
- ok, 這道題解了, s3是通過new關鍵字獲得字符串對象的.
- 回到題目, 也就是說字符串常量表中沒有存儲"hello world"的引用, 當s4以引號的形式聲明字符串時, 由於在字符串常量池中查不到相應的引用, 所以會在堆內存中新創建一個字符串對象. 所以s3和s4指向的不是同一個字符串對象, 結果為false.
詳解字符串操作類
- 明白了字符串常量池, 我相信關於字符串的創建你已經有十足的把握了. 但是這還不夠, 作為一名合格的Java工程師, 我們還必須對字符串的操作做到了如指掌. 注意! 不是說你不用查api能熟練操作字符串就了如指掌了, 而是說對String, StringBuilder, StringBuffer三大字符串操作類背后的實現了然於胸, 這樣才能在開發的過程中做出正確, 高效的選擇.
String, StringBuilder, StringBuffer的底層實現
- 點進String的源碼, 我們可以看見String類是通過char類型數組實現的.
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
...
}
- 接着查看StringBuilder和StringBuffer的源碼, 我們發現這兩者都繼承自AbstractStringBuilder類, 通過查看該類的源碼, 得知StringBuilder和StringBuffer兩個類也是通過char類型數組實現的
abstract class AbstractStringBuilder implements Appendable, CharSequence {
/**
* The value is used for character storage.
*/
char[] value;
...
}
-
而且通過StringBuilder和StringBuffer繼承自同一個父類這點, 我們可以推斷出它倆的方法都是差不多的. 通過查看源碼也發現確實如此, 只不過StringBuffer在方法上添加了
synchronized
關鍵字, 證明它的方法絕大多數方法都是線程同步方法. 也就是說在多線程的環境下我們應該使用StringBuffer以保證線程安全, 在單線程環境下我們應使用StringBuilder以獲得更高的效率. -
既然如此, 我們的比較也就落到了StringBuilder和String身上了.
關於StringBuilder和String之間的討論
- 通過查看StringBuilder和String的源碼我們會發現兩者之間一個關鍵的區別: 對於String, 凡是涉及到返回參數類型為String類型的方法, 在返回的時候都會通過new關鍵字創建一個新的字符串對象; 而對於StringBuilder, 大多數方法都會返回StringBuilder對象自身.
/**
* 下面截取幾個String類的方法
*/
public String substring(int beginIndex) {
if (beginIndex < 0) {
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;
if (subLen < 0) {
throw new StringIndexOutOfBoundsException(subLen);
}
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}
public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}
/**
* 下面截取幾個StringBuilder類的方法
*/
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
@Override
public StringBuilder replace(int start, int end, String str) {
super.replace(start, end, str);
return this;
}
- 就因為這點區別, 使得兩者在操作字符串時在不同的場景下會體現出不同的效率.
- 下面還是以拼接字符串為例比較一下兩者的性能
public class Main {
public static int time = 50000;
public static void main(String[] args) {
long start = System.currentTimeMillis();
String s = "";
for(int i = 0; i < time; i++){
s += "test";
}
long end = System.currentTimeMillis();
System.out.println("String類使用時間: " + (end - start) + "毫秒");
}
}
//String類使用時間: 4781毫秒
public class Main {
public static int time = 50000;
public static void main(String[] args) {
long start = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for(int i = 0; i < time; i++){
sb.append("test");
}
long end = System.currentTimeMillis();
System.out.println("StringBuilder類使用時間: " + (end - start) + "毫秒");
}
}
//StringBuilder類使用時間: 5毫秒
- 就拼接5萬次字符串而言, StringBuilder的效率是String類的956倍.
- 我們再次通過反編譯代碼看看造成兩者性能差距的原因, 先看String類. (為了方便閱讀代碼, 我刪除了計時部分的代碼, 並重新編譯, 得到的main方法反編譯代碼如下)
public static void main(java.lang.String[]);
Code:
0: ldc #2 // String, 將""空字符串加載到棧頂
2: astore_1 //存放到s變量中
3: iconst_0 //把int型數0壓棧
4: istore_2 //存到變量i中
5: iload_2 //把i的值壓到棧頂(0)
6: getstatic #3 // Field time:I 拿到靜態變量time的值, 壓到棧頂
9: if_icmpge 38 // 比較棧頂兩個int值, for循環中的判定, 如果i比time小就繼續執行, 否則跳轉
//從這里開始, 就是for循環部分
12: new #4 // class java/lang/StringBuilder
15: dup
16: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
19: aload_1
20: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: ldc #7 // String test
25: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
28: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
31: astore_1 //每拼接完一次, 就把新的字符串對象引用保存在第二個本地變量中
//到這里一次for循環結束
32: iinc 2, 1 //變量i加1
35: goto 5 //繼續循環
38: return
- 從反匯編代碼中可以看到, 當用String類拼接字符串時, 每次都會生成一個StringBuilder對象, 然后調用兩次append()方法把字符串拼接好, 最后通過StringBuilder的toString()方法new出一個新的字符串對象.
- 也就是說每次拼接都會new出兩個對象, 並進行兩次方法調用, 如果拼接的次數過多, 創建對象所帶來的時延會降低系統效率, 同時會造成巨大的內存浪費. 而且當內存不夠用時, 虛擬機會進行垃圾回收, 這也是一項相當耗時的操作, 會大大降低系統性能.
- 下面是使用StringBuilder拼接字符串得到的反編譯代碼.
public static void main(java.lang.String[]);
Code:
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
7: astore_1
8: iconst_0
9: istore_2
10: iload_2
11: getstatic #4 // Field time:I
14: if_icmpge 30
//從這里開始執行for循環內的代碼
17: aload_1
18: ldc #5 // String test
20: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: pop
//到這里一次for循環結束
24: iinc 2, 1
27: goto 10
30: return
- 可以看到StringBuilder拼接字符串就簡單多了, 直接把要拼接的字符串放到棧頂進行append就完事了, 除了開始時創建了StringBuilder對象, 運行時期沒有創建過其他任何對象, 每次循環只調用一次append方法. 所以從效率上看, 拼接大量字符串時, StringBuilder要比String類給力得多.
- 當然String類也不是沒有優勢的, 從操作字符串api的豐富度上來講, String是要多於StringBuilder的, 在日常操作中很多業務都需要用到String類的api.
- 在拼接字符串時, 如果是簡單的拼接, 比如說
String s = "hello " + "world";
, String類的效率會更高一點. - 但如果需要拼接大量字符串, StringBuilder無疑是更合適的選擇.
- 講到這里, Java中的字符串背后的原理就講得差不多, 相信在了解虛擬機操作字符串的細節后, 你在使用字符串時會更加得心應手. 字符串是編程中一個重要的話題, 本文圍繞Java體系講解的字符串知識只是字符串知識的冰山一角. 字符串操作的背后是數據結構和算法的應用, 如何能夠以盡可能低的時間復雜度去操作字符串, 又是一門大學問.