字串常量池詳解
在深入學習字串類之前, 我們先搞懂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體系講解的字串知識只是字串知識的冰山一角. 字串操作的背後是資料結構和演算法的應用, 如何能夠以儘可能低的時間複雜度去操作字串, 又是一門大學問.
- 最後歡迎關注我的公眾號, 我會在公眾號中持續更新系統的Java後端面試題分析, 將會囊括Java基礎知識到主流框架原理, 力求深入每個知識點背後的原理. 還會分享關於程式設計的趣味漫畫.