首頁>技術>

字串的拼接在專案中使用的非常頻繁,但稍不留意往往又會造成一些效能問題。

字串的拼接在專案中使用的非常頻繁,但稍不留意往往又會造成一些效能問題。最近 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

13
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 一文帶你學會如何用Python生成帶誤差棒的並列和堆積柱狀圖