字串的拼接在專案中使用的非常頻繁,但稍不留意往往又會造成一些效能問題。
字串的拼接在專案中使用的非常頻繁,但稍不留意往往又會造成一些效能問題。最近 Review 程式碼時發現同事寫了如下的程式碼,於是給他提了一個 bug。
@Testpublic void testForAdd() { String result = "NO_"; for (int i = 0; i < 10; i++) { result += i; } System.out.println(result);}
本文就帶大家從表象到底層的來聊聊,為什麼這種寫法會有效能問題。
IDE 的提示 如果你使用的 IDE 安裝了程式碼檢查的外掛,會很輕易的看到上面程式碼中的 “+=” 操作會有黃色的背景,這是外掛在提示,此處使用有問題。
下面來看一下關於 “+=”,IDEA 給出的提示詳情:
String concatenation ‘+=’ in loopInspection info: Reports String concatenation in loops. As every String concatenation copies the whole String, usually it is preferable to replace it with explicit calls to StringBuilder.append() or StringBuffer.append().
這段提示簡單翻譯過來就是:迴圈中,字串拼接使用了 “+=”。檢驗資訊:報告迴圈中的字串拼接。每次 String 的拼接都會複製整個 String。通常建議將其替換為 StringBuilder.append() 或 StringBuffer.append()。
提示資訊中給出了原因,並且給出瞭解決方案的建議。但事實真的如提示中這麼簡單嗎?Java8 以後使用 String 拼接 JVM 編譯時不是已經預設最佳化構建成 StringBuilder 了嗎,怎麼還有問題?下面我們就來深入分析一下。
位元組碼的反編譯 對上面的程式碼,我們透過位元組碼反編譯一下,看看 JVM 在此過程中是否幫我們進行了最佳化,是否涉及到整個 String 的複製。
使用 javap -c 命令來檢視位元組碼內容:
public void testForAdd();Code: //從常量池引用#2並推向棧頂,操作了String初始化的變數“NO_” 0: ldc #2 // String NO_ 2: astore_1 3: iconst_0 4: istore_2 5: iload_2 6: bipush 10 //如果棧頂兩個值大於等於0(此時0-10)則跳轉36(code),這裡開始進入for迴圈處理 8: if_icmpge 36 //建立StringBuilder物件,其引用進棧 11: new #3 // class java/lang/StringBuilder 14: dup //呼叫StringBuilder的構造方法 15: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V 18: aload_1 19: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 22: iload_2 //呼叫append方法 23: invokevirtual #6 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder; //呼叫toString方法,並將產生的String存入棧頂 26: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 29: astore_1 30: iinc 2, 1 33: goto 5 36: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream; 39: aload_1 40: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 43: return
上述反編譯的位元組碼操作中已經將關鍵部分標註出來了。編號 0 處會載入定義的 “NO_” 字串,編號 8 處開始進行迴圈的判斷,符合條件(0-10)的部分便會執行後續的迴圈體中的內容。在迴圈體內,編號 11 建立 StringBuilder 物件,編號 15 呼叫 StringBuilder 的構造方法,編號 23 呼叫 append 方法,編號 26 呼叫 toString 方法。
經過上述的步驟我們能夠發現什麼?JVM 在編譯時的確幫我們進行了最佳化,將 for 迴圈中的字串拼接轉化成了 StringBuilder,並透過 appen 方法和 toString 方法進行處理。這樣有問題嗎?JVM 已經優化了啊!
但是,關鍵問題來了:每次 for 迴圈都會新建立一個 StringBuilder,都會進行 append 和 toString 操作,然後銷燬。這就變得可怕了,這與每次都建立 String 物件並複製有過之而無不及。
經過上述分析之後,上面的程式碼的效果相當於如下程式碼:
@Testpublic void testForAdd1() { String result = "NO_"; for (int i = 0; i < 10; i++) { result = new StringBuilder(result).append(i).toString(); } System.out.println(result);}
這樣來看是不是更直觀了?至此,想必大家已經明白為什麼給那位同事提 bug 了吧。
方案改進 那麼,針對上面的問題,程式碼該如何進行改進呢?直接上程式碼:
@Testpublic void testForAppend() { StringBuilder result = new StringBuilder("NO_"); for (int i = 0; i < 10; i++) { result.append(i); } System.out.println(result);}
將 StringBuilder 物件的建立放在外面,for 迴圈中直接呼叫 append 即可。再來看一下這段程式碼的位元組碼操作:
public void testForAppend();Code: 0: new #3 // class java/lang/StringBuilder 3: dup 4: ldc #2 // String NO_ 6: invokespecial #10 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V 9: astore_1 10: iconst_0 11: istore_2 12: iload_2 13: bipush 10 15: if_icmpge 30 18: aload_1 19: iload_2 20: invokevirtual #6 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder; 23: pop 24: iinc 2, 1 27: goto 12 30: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream; 33: aload_1 34: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V 37: return
對照最開始的位元組碼內容,看看是不是簡化了很多,問題完美解決。
for 迴圈內的場景 上面介紹的使用場景主要針對透過 for 迴圈來獲得一個整字串,但某些業務場景中可能拼接字串本身只在 for 迴圈當中,並不會在 for 迴圈外部處理,比如:
@Testpublic void testInfoForAppend() { for (int i = 0; i < 10; i++) { String result = "NO_" + i; System.out.println(result); }}
上述程式碼中 for 迴圈內部的字串拼接還可能會更復雜,我們已經知道 JVM 會最佳化成上面提到的 StringBuilder 進行處理。同時,每次都會建立 StringBuilder 物件,那麼針對這種情況,只能聽之任之嗎?
直接上兩種方法的示例程式碼:
@Testpublic void testDelete() { StringBuilder result = new StringBuilder(); for (int i = 0; i < 10; i++) { result.delete(0,result.length()); result.append(i); System.out.println(result); }}@Testpublic void testSetLength() { StringBuilder result = new StringBuilder(); for (int i = 0; i < 10; i++) { result.setLength(0); result.append(i); System.out.println(result); }}
關於上述示例的驗證和底層操作,感興趣的朋友可以繼續深挖一下,這裡只說結論。經過試驗,這兩種方法的效能都要比預設的處理方式要好很多。同時 delete 操作的方式略微優於 setLength 的方式,推薦使用 delete 的方式。
小結 透過 IDE 的一個提示資訊,我們進行底層原理深挖及實現的驗證,竟然發現這麼多可提升的空間和隱藏知識點,是不是很有成就感?最後,我們再來稍微總結一下 String 和 StringBuilder 涉及到的知識點(基於 Java8 及以上版本):
沒有迴圈的字串拼接,直接使用 + 就可以了,JVM 會幫我們進行最佳化。 併發場景進行字串拼接,使用 StringBuffer 代替 StringBuilder,StringBuffer 是執行緒安全的。 迴圈內 JVM 的最佳化存在一定的缺陷,可在迴圈體外構建 StringBuilder,迴圈體內進行 append 操作。 對於純迴圈體內使用的字串拼接,可在迴圈體外構建 StringBuilder,使用完進行清除操作(delete 或 setLength)。
連結:https://juejin.cn/post/6907534078078091271