深入分析Java使用+和StringBuilder進行字串拼接的差異

bsr1983發表於2013-09-03

       今天看到有網友在我的部落格留言,討論javaString在進行拼接時使用+StringBuilderStringBuffer中的執行速度差異很大,而且之前看的書上說java在編譯的時候會自動將+替換為StringBuilderStringBuffer,但對於這些我都沒有做深入的研究,今天準備花一點時間,仔細研究一下。

       首先看一下java編譯器在編譯的時候自動替換+StringBuilderStringBuffer的部分,程式碼如下。

       測試環境為win764位系統,8G記憶體,CPU i5-3470JDK版本為32位的JDK1.6.0_38

       第一次使用的測試程式碼為:

         

  public static void main(String[] args) {
       // TODO Auto-generated method stub
       String demoString="";
       int execTimes=10000;
       if(args!=null&&args.length>0)
       {
           execTimes=Integer.parseInt(args[0]);
       }
       System.out.println("execTimes="+execTimes);
       long starMs=System.currentTimeMillis();
       for(int i=0;i<execTimes;i++)
       {
           demoString=demoString+i;
       }
       long endMs=System.currentTimeMillis();
       System.out.println("+ exec millis="+(endMs-starMs));
    }

 

   輸入不同引數時的執行時間如下:

C:\>java StringAppendDemo 100
execTimes=100
+ exec millis=0
C:\>java StringAppendDemo 1000
execTimes=1000
+ exec millis=6
C:\>java StringAppendDemo 10000
execTimes=10000
+ exec millis=220
C:\>java StringAppendDemo 100000
execTimes=100000
+ exec millis=44267

 

可以看到,輸入的引數為10000100000時,其執行時間從0.2秒到了44秒。

我們先使用javap命令看一下編譯後的程式碼:

javap –c StringAppendDemo

這裡我摘錄了和迴圈拼接字串有關的那部分程式碼,具體為:

  

51:  lstore_3
  52:  iconst_0
  53:  istore  5
  55:  iload   5
  57:  iload_2
  58:  if_icmpge       87
  61:  new     #5; //class java/lang/StringBuilder
  64:  dup
  65:  invokespecial   #6; //Method java/lang/StringBuilder."<init>":()V
  68:  aload_1
  69:  invokevirtual   #8; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
  72:  iload   5
  74:  invokevirtual   #9; //Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
  77:  invokevirtual   #10; //Method java/lang/StringBuilder.toString:()Ljava/lang/String;
  80:  astore_1
  81:  iinc    5, 1
  84:  goto    55

 

可以看到,之前的+的確已經被編譯為了StringBuilder物件的append方法。通過這裡的位元組碼可以看到,對於每一個+都將被替換為一個StringBuilder而不是我所想象的只生成一個物件。也就是說,如果有10000+號就會生成10000StringBuilder物件。具體參看上面位元組碼的第84行,此處是執行完一次迴圈以後,再次跳轉到55行去執行。

接著,我們把再寫一個使用StringBuilder直接實現的方式,看看有什麼不一樣。

具體程式碼為:

public class StringBuilderAppendDemo {
       public static void main(String[] args) {
       // TODO Auto-generated method stub
       String demoString="";
       int execTimes=10000;
       if(args!=null&&args.length>0)
       {
           execTimes=Integer.parseInt(args[0]);
       }
       System.out.println("execTimes="+execTimes);
       long starMs=System.currentTimeMillis();
       StringBuilder strBuilder=new StringBuilder();
       for(int i=0;i<execTimes;i++)
       {
           strBuilder.append(i);
       }
       long endMs=System.currentTimeMillis();
       System.out.println("StringBuilder exec millis="+(endMs-starMs));
    }
}

 

和上次一樣的引數,看看執行時間的差異

C:\>java StringBuilderAppendDemo 100
execTimes=100
StringBuilder exec millis=0
C:\>java StringBuilderAppendDemo 1000
execTimes=1000
StringBuilder exec millis=1
C:\>java StringBuilderAppendDemo 10000
execTimes=10000
StringBuilder exec millis=1
C:\>java StringBuilderAppendDemo 100000
execTimes=100000
StringBuilder exec millis=5

 

可以看到,這裡的執行次數上升以後,執行時間並沒有出現大幅度的增加,那我們在看一下編譯後的位元組碼。

51:  lstore_3
 52:  new     #5; //class java/lang/StringBuilder
 55:  dup
 56:  invokespecial   #6; //Method java/lang/StringBuilder."<init>":()V
 59:  astore  5
 61:  iconst_0
 62:  istore  6
 64:  iload   6
 66:  iload_2
 67:  if_icmpge       84
 70:  aload   5
 72:  iload   6
 74:  invokevirtual   #9; //Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
 77:  pop
 78:  iinc    6, 1
 81:  goto    64

 

通過位元組碼可以看到,整個迴圈拼接過程中,只在56行對StringBuilde物件進行了一次初始化,以後的拼接操作的迴圈都是從64行開始,然後到81行進行goto 64再次迴圈。

為了證明我們的推斷,我們需要看看虛擬機器中是否是這麼實現的。

參考程式碼:http://www.docjar.com/html/api/com/sun/tools/javac/jvm/Gen.java.html

具體的方法,標紅的地方就是在語法樹處理過程中的一個用來處理字串拼接“+”號的例子,其他部分進行的處理也類似,我們只保留需要的部分

 

public void visitAssignop(JCAssignOp tree) {
 OperatorSymbol operator = (OperatorSymbol) tree.operator;
 Item l;
 if (operator.opcode == string_add) {
 // Generate code to make a string buffer
 makeStringBuffer(tree.pos());
 
 // Generate code for first string, possibly save one
 // copy under buffer
 l = genExpr(tree.lhs, tree.lhs.type);
 if (l.width() > 0) {
 code.emitop0(dup_x1 + 3 * (l.width() - 1));
 }
 
 // Load first string and append to buffer.
 l.load();
 appendString(tree.lhs);
 
 // Append all other strings to buffer.
 appendStrings(tree.rhs);
 
 // Convert buffer to string.
 bufferToString(tree.pos());
 }
 剩餘程式碼已刪除。

 

而具體把+轉換為StringBuilder的方法為:


 

void makeStringBuffer(DiagnosticPosition pos) {
 code.emitop2(new_, makeRef(pos, stringBufferType));
 code.emitop0(dup);
 callMethod(
 pos, stringBufferType, names.init, List.<Type>nil(), false);
 }
 

 

看標紅出的程式碼可以知道,此處呼叫了stringBufferTypeinit方法來進行初始化。

看到此處有同學一定會有疑問,剛剛的位元組碼不是顯示替換成StringBuilder了嗎?原因在這裡:

protected Gen(Context context)95行)這個方法的程式碼,發現其中包含了stringBufferType變數的初始化:
stringBufferType = target.useStringBuilder() ? syms.stringBuilderType
                : syms.stringBufferType;108109110行)
通過一個三目運算子,根據當前的編譯的目標JDK是否啟用了StringBuilder來設定stringBufferType的真正型別。
回到處理“+”的程式碼,呼叫完makeStringBuffer方法後接著呼叫appendStrings方法和bufferToString方法。具體程式碼如下

 

/** Add all strings in tree to string buffer.
 */
 void appendStrings(JCTree tree) {
 tree = TreeInfo.skipParens(tree);
 if (tree.getTag() == JCTree.PLUS && tree.type.constValue() == null) {
 JCBinary op = (JCBinary) tree;
 if (op.operator.kind == MTH &&
 ((OperatorSymbol) op.operator).opcode == string_add) {
 appendStrings(op.lhs);
 appendStrings(op.rhs);
 return;
 }
 }
 genExpr(tree, tree.type).load();
 appendString(tree);
 }
 
 /** Convert string buffer on tos to string.
 */
 void bufferToString(DiagnosticPosition pos) {
 callMethod(
 pos,
 stringBufferType,
 names.toString,
 List.<Type>nil(),
 false);
 }
 

 

這裡其實就是將字串進行了快取,接著通過呼叫stringBufferTypetoString()方法把StringBuilder中的字元轉換為一個字串物件。

接著我們通過visualvm工具看看上述兩個例子執行過程中的記憶體使用和垃圾回收情況,visualvm工具路徑為JDK根目錄\bin\jvisualvm.exe

執行使用+操作符進行拼接的監視情況如下



 

可以看到在執行過程中,虛擬機器進行了52871GC操作共耗費了49.278s,也就是說,執行時間的很大一部分是花在了垃圾回收上。

記憶體使用情況如下:



 

可以看到記憶體的佔用大小也在忽上忽下,同樣是垃圾回收的表現。

至於第二個例子,因為執行時間僅僅在4毫秒所有,vistalvm還來不及捕捉就執行完畢了,沒有捕捉到相關的執行資料。

 

    綜上所述,如果在編寫程式碼的過程中大量使用+進行字串評價還是會對效能造成比較大的影響,但是使用的個數在1000以下還是可以接受的,大於10000的話,執行時間將可能超過1s,會對效能產生較大影響。如果有大量需要進行字串拼接的操作,最好還是使用StringBufferStringBuilder進行。

相關文章