從位元組碼看Java對字串拼接的優化
Java中有個經典的問題,如下程式碼的輸出結果:
String s1 = "a";
String s2 = s1 + "b";
String s3 = "a" + "b";
System.out.println(s2 == "ab");
System.out.println(s3 == "ab");
答案可以在這裡直接給出,輸出結果為 false和true,而給出的解釋是:對於字串的直接拼接,會在編譯階段編譯器將拼接結果計算出來,並將結果放在常量區中。
對於這個題目來說,s1是變數,導致s2需要在執行時才能拼接出來;而s3是a和b兩個常量拼接的結果,所以在編譯時就已經得出結果。這就導致了,s2指向的是執行時生成的ab,而s3指向的是編譯時存放在常量區的ab。
上面的這種解釋很籠統抽象,為了更深入地理解原理,需要檢視上面程式碼編譯後的位元組碼:
stack=3, locals=4, args_size=1 0: ldc #16 // String a 2: astore_1 3: new #18 // class java/lang/StringBuilder 6: dup 7: aload_1 8: invokestatic #20 // Method java/lang/String.valueOf:(Ljava/lang/Object;)Ljava/lang/String; 11: invokespecial #26 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V 14: ldc #29 // String b 16: invokevirtual #31 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 19: invokevirtual #35 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 22: astore_2 23: ldc #39 // String ab 25: astore_3 // 下面的程式碼是列印邏輯 ......
在上面的位元組碼中,重點看3-22行,等價於:
String s2=new StringBuilder(s1).append("b").toString();
而23-25行,說明s3是直接通過符號引用指向常量區中的String型別的ab。
由此我們得出結論:Java中帶有變數的字串拼接,實際上是通過StringBuilder的append方法完成的;而若干個常量的拼接,會在編譯階段編譯器"聰明"地完成,執行時直接引用常量區的欄位即可。很顯然,兩種方式對應著兩個不同的引用。
所以,考慮到編譯階段的優化,字串的拼接就可以無所忌憚地用+了嗎?答案是否定的!考慮下面兩段程式碼:
程式碼1:
String a = "test";
for (int i = 0; i < 10; i++) {
a += i;
}
程式碼2:
StringBuilder builder = new StringBuilder("test");
for (int i = 0; i < 10; i++) {
builder.append(i);
}
String a = builder.toString();
上面的兩端程式碼從完成的功能角度看都是一致的:最終變數a的值都會變成test0123456789。但是檢視位元組碼後:
程式碼1位元組碼:
stack=3, locals=2, args_size=0
0: ldc #70 // String test
2: astore_0
3: iconst_0
4: istore_1
5: goto 30
8: new #18 // class java/lang/StringBuilder
11: dup
12: aload_0
13: invokestatic #20 // Method java/lang/String.valueOf:(Ljava/lang/Object;)Ljava/lang/String;
16: invokespecial #26 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
19: iload_1
20: invokevirtual #72 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
23: invokevirtual #35 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
26: astore_0
27: iinc 1, 1
30: iload_1
31: bipush 10
33: if_icmplt 8
36: return
程式碼2位元組碼:
stack=3, locals=2, args_size=0
0: new #18 // class java/lang/StringBuilder
3: dup
4: ldc #70 // String test
6: invokespecial #26 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
9: astore_0
10: iconst_0
11: istore_1
12: goto 24
15: aload_0
16: iload_1
17: invokevirtual #72 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
20: pop
21: iinc 1, 1
24: iload_1
25: bipush 10
27: if_icmplt 15
30: aload_0
31: invokevirtual #35 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
34: astore_1
35: return
在程式碼1的位元組碼中,8-27行發現,迴圈體中每一次迴圈都會new出一個StringBuilder,對應的java程式碼為:
String a = "test";
for (int i = 0; i < 10; i++) {
a = new StringBuilder(a).append(i).toString();
}
在程式碼2的位元組碼中,15-27的迴圈體中,並不會每次一次迴圈new出一個StringBuilder。
顯然,程式碼2的執行效率要高於程式碼1的執行效率。究其緣由是,編譯器雖然會在編譯階段對字串的拼接做了優化,但是並沒有做到跨越程式碼塊級別的優化。所以在每一次迴圈的程式碼塊中,程式碼1都會new出一個StringBuilder。如果迴圈次數更多,會創建出更多的StringBuilder物件,最終有可能導致OOM等問題。