1. 程式人生 > >String StringBuilder StringBuffer 對比 總結得非常好

String StringBuilder StringBuffer 對比 總結得非常好

作者:每次上網衝杯Java時,都能看到關於String無休無止的爭論。還是覺得有必要讓這個討厭又很可愛的String美眉,赤裸裸的站在我們這些Java色狼面前了。嘿嘿....

眾所周知,String是由字元組成的串,在程式中使用頻率很高。Java中的String是一個類,而並非基本資料型別。 不過她卻不是普通的類哦!!!

【鏡頭1】 String物件的建立 
      1、關於類物件的建立,很普通的一種方式就是利用構造器,String類也不例外:String s=new String("Hello world"); 問題是引數"Hello world"是什麼東西,也是字串物件嗎?莫非用字串物件建立一個字串物件?

      2、當然,String類物件還有一種大家都很喜歡的建立方式:String s="Hello world"; 但是有點怪呀,怎麼與基本資料型別的賦值操作(int i=1)很像呀?

      在開始解釋這些問題之前,我們先引入一些必要的知識:

★ Java class檔案結構 和常量池 
      我們都知道,Java程式要執行,首先需要編譯器將原始碼檔案編譯成位元組碼檔案(也就是.class檔案)。然後在由JVM解釋執行。
      class檔案是8位位元組的二進位制流 。這些二進位制流的涵義由一些緊湊的有意義的項 組成。比如class位元組流中最開始的4個位元組組成的項叫做魔數 (magic),其意義在於分辨class檔案(值為0xCAFEBABE)與非class檔案。class位元組流大致結構如下圖左側。

                                

      其中,在class檔案中有一個非常重要的項——常量池 。這個常量池專門放置原始碼中的符號資訊(並且不同的符號資訊放置在不同標誌的常量表中)。如上圖右側是HelloWorld程式碼中的常量表(HelloWorld程式碼如下),其中有四個不同型別的常量表(四個不同的常量池入口)。關於常量池的具體細節,請參照我的部落格《Class檔案內容及常量池 》

Java程式碼  收藏程式碼
  1. publicclass HelloWorld{  
  2.     void hello(){  
  3.         System.out.println("Hello world");  
  4.     }  
  5. }  

      通過上圖可見,程式碼中的"Hello world"字串字面值被編譯之後,可以清楚的看到存放在了class常量池中的字串常量表中(上圖右側紅框區域)。

★ JVM執行class檔案

      原始碼編譯成class檔案之後,JVM就要執行這個class檔案。它首先會用類裝載器載入進class檔案。然後需要建立許多記憶體資料結構來存放class檔案中的位元組資料。比如class檔案對應的類資訊資料、常量池結構、方法中的二進位制指令序列、類方法與欄位的描述資訊等等。當然,在執行的時候,還需要為方法建立棧幀等。這麼多的記憶體結構當然需要管理,JVM會把這些東西都組織到幾個“執行時資料區 ”中。這裡面就有我們經常說的“方法區 ”、“ ”、“Java棧 ”等。

      上面我們提到了,在Java原始碼中的每一個字面值字串,都會在編譯成class檔案階段,形成標誌號 為8(CONSTANT_String_info)的常量表  當JVM載入 class檔案的時候,會為對應的常量池建立一個記憶體資料結構,並存放在方法區中。同時JVM會自動為CONSTANT_String_info常量表中 的字串常量字面值 在堆中 建立 新的String物件(intern字串 物件,又叫拘留字串物件)。然後把CONSTANT_String_info常量表的入口地址轉變成這個堆中String物件的直接地址(常量池解 析)。 

      這裡很關鍵的就是這個拘留字串物件 原始碼中所有相同字面值的字串常量只可能建立唯一一個拘留字串物件。 實際上JVM是通過一個記錄了拘留字串引用的內部資料結構來維持這一特性的。在Java程式中,可以呼叫String的intern()方法來使得一個常規字串物件成為拘留字串物件。我們會在後面介紹這個方法的。

★ 操作碼助憶符指令 
      有了上面闡述的兩個知識前提,下面我們將根據二進位制指令來區別兩種字串物件的建立方式:  

      (1) String s=new String("Hello world");編譯成class檔案後的指令(在myeclipse中檢視):

Class位元組碼指令集程式碼  收藏程式碼
  1. 0  new java.lang.String [15]  //在堆中分配一個String類物件的空間,並將該物件的地址堆入運算元棧。  
  2. 3  dup //複製運算元棧頂資料,並壓入運算元棧。該指令使得運算元棧中有兩個String物件的引用值。  
  3. 4  ldc <String "Hello world"> [17] //將常量池中的字串常量"Hello world"指向的堆中拘留String物件的地址壓入運算元棧  
  4. 6  invokespecial java.lang.String(java.lang.String) [19] //呼叫String的初始化方法,彈出運算元棧棧頂的兩個物件地址,用拘留String物件的值初始化new指令建立的String物件,然後將這個物件的引用壓入運算元棧  
  5. 9  astore_1 [s] // 彈出運算元棧頂資料存放在區域性變數區的第一個位置上。此時存放的是new指令創建出的,已經被初始化的String物件的地址 (此時的棧頂值彈出存入區域性變數中去)。  

注意:
這裡有個dup指令。其作用就是複製之前分配的Java.lang.String空間的引用並壓入棧頂。那麼這裡為什麼需要這樣麼做呢?因為invokespecial指令通過[15]這個常量池入口尋找到了java.lang.String()構造方法,構造方法雖然找到了。但是必須還得知道是誰的構造方法,所以要將之前分配的空間的應用壓入棧頂讓invokespecial命令應用才知道原來這個構造方法是剛才建立的那個引用的,呼叫完成之後將棧頂的值彈出。之後呼叫astore_1將此時的棧頂值彈出存入區域性變數中去。     


 事實上,在執行這段指令之前,JVM就已經為"Hello world"在堆中建立了一個拘留字串( 值得注意的是:如果源程式中還有一個"Hello world"字串常量,那麼他們都對應了一個堆中的拘留字串)。然後用這個拘留字串的值來初始化堆中用new指令創建出來的新的String物件,區域性變數s實際上儲存的是new出來的堆物件地址。 大家注意了,此時在JVM管理的堆中,有兩個相同字串值的String物件:一個是拘留字串物件,一個是new新建的字串物件。如果還有一條建立語句String s1=new String("Hello world");堆中有幾個值為"Hello world"的字串呢? 答案是3個,大家好好想想為什麼吧!

      (2)將String s="Hello world";編譯成class檔案後的指令:

Class位元組碼指令集程式碼  收藏程式碼
  1. 0  ldc <String "Hello world"> [15]//將常量池中的字串常量"Hello world"指向的堆中拘留String物件的地址壓入運算元棧  
  2. 2  astore_1 [str] // 彈出運算元棧頂資料存放在區域性變數區的第一個位置上。此時存放的是拘留字串物件在堆中的地址  

      和上面的建立指令有很大的不同,區域性變數s儲存的是早已建立好的拘留字串的堆地址(沒有new 的物件了) 大家好好想想,如果還有一條穿件語句String s1="Hello word";此時堆中有幾個值為"Hello world"的字串呢?答案是1個。那麼區域性變數s與s1儲存的地址是否相同呢?  呵呵, 這個你應該知道了吧。

★ 鏡頭總結: String型別脫光了其實也很普通。真正讓她神祕的原因就在於CONSTANT_String_info常量表 拘留字串物件 的存在。現在我們可以解決江湖上的許多紛爭了。

   紛爭1】關於字串相等關係的爭論

Java程式碼  收藏程式碼
  1. //程式碼1
  2. String sa=new String("Hello world");            
  3. String sb=new String("Hello world");      
  4. System.out.println(sa==sb);  // false     
  5. //程式碼2  
  6. String sc="Hello world";    
  7. String sd="Hello world";  
  8. System.out.println(sc==sd);  // true 

       程式碼1中區域性變數sa,sb中儲存的是JVM在堆中new出來的兩個String物件的記憶體地址。雖然這兩個String物件的值(char[]存放的字元序列)都是"Hello world"。 因此"=="比較的是兩個不同的堆地址。程式碼2中區域性變數sc,sd中儲存的也是地址,但卻都是常量池中"Hello world"指向的堆的唯一的那個拘留字串物件的地址 。自然相等了。

  【紛爭2】 字串“+”操作的內幕

Java程式碼  收藏程式碼
  1. //程式碼1
  2. String sa = "ab";                                          
  3. String sb = "cd";                                       
  4. String sab=sa+sb;                                      
  5. String s="abcd";  
  6. System.out.println(sab==s); // false
  7. //程式碼2
  8. String sc="ab"+"cd";  
  9. String sd="abcd";  
  10. System.out.println(sc==sd); //true

       程式碼1中區域性變數sa,sb儲存的是堆中兩個拘留字串物件的地址。而當執行sa+sb時,JVM首先會在堆中建立一個StringBuilder類,同時用sa指向的拘留字串物件完成初始化,然後呼叫append方法完成對sb所指向的拘留字串的合併操作,接著呼叫StringBuilder的toString()方法在堆中建立一個String物件,最後將剛生成的String物件的堆地址存放在區域性變數sab中。而區域性變數s儲存的是常量池中"abcd"所對應的拘留字串物件的地址。 sab與s地址當然不一樣了。這裡要注意了,程式碼1的堆中實際上有五個字串物件:三個拘留字串物件、一個String物件和一個StringBuilder物件。
      程式碼2中"ab"+"cd"會直接在編譯期就合併成常量"abcd", 因此相同字面值常量"abcd"所對應的是同一個拘留字串物件,自然地址也就相同。

【鏡頭二】  String三姐妹(String,StringBuffer,StringBuilder) 
        String扒的差不多了。但他還有兩個妹妹StringBuffer,StringBuilder長的也不錯哦!我們也要下手了:
                           String(大姐,出生於JDK1.0時代)          不可變字元序列
                           StringBuffer(二姐,出生於JDK1.0時代)    執行緒安全的可變字元序列
                           StringBuilder(小妹,出生於JDK1.5時代)   非執行緒安全的可變字元序列
 

★StringBuffer與String的可變性問題。 
         我們先看看這兩個類的部分原始碼:

Java程式碼  收藏程式碼
  1. //String 
  2. publicfinalclass String  
  3. {  
  4.         privatefinalchar value[];  
  5.          public String(String original) {  
  6.               // 把原字串original切分成字元陣列並賦給value[];
  7.          }  
  8. }  
  9. //StringBuffer 
  10. publicfinalclass StringBuffer extends AbstractStringBuilder  
  11. {  
  12.          char value[]; //繼承了父類AbstractStringBuilder中的value[]
  13.          public StringBuffer(String str) {  
  14.                  super(str.length() + 16); //繼承父類的構造器,並建立一個大小為str.length()+16的value[]陣列
  15.                  append(str); //將str切分成字元序列並加入到value[]中
  16.         }  
  17. }  

      很顯然,String和StringBuffer中的value[]都用於儲存字元序列。但是,
      (1) String中的是常量(final)陣列,只能被賦值一次。 
      比如:new String("abc")使得value[]={'a','b','c'}(檢視jdk String 就是這麼實現的),之後這個String物件中的value[]再也不能改變了。這也正是大家常說的,String是不可變的原因 。    
     注意:這個對初學者來說有個誤區,有人說String str1=new String("abc"); str1=new String("cba");不是改變了字串str1嗎?那麼你有必要先搞懂物件引用和物件本身的區別。這裡我簡單的說明一下,物件本身指的是存放在堆空間中的該物件的例項資料(非靜態非常量欄位)。而物件引用指的是堆中物件本身所存放的地址,一般方法區和Java棧中儲存的都是物件引用,而非物件本身的資料。


      (2) StringBuffer中的value[]就是一個很普通的陣列,而且可以通過append()方法將新字串加入value[]末尾。這樣也就改變了value[]的內容和大小了。

      比如:new StringBuffer("abc")使得value[]={'a','b','c','',''...}(注意構造的長度是str.length()+16)。如果再將這個物件append("abc"),那麼這個物件中的value[]={'a','b','c','a','b','c',''....}。這也就是為什麼大家說 StringBuffer是可變字串 的涵義了。從這一點也可以看出,StringBuffer中的value[]完全可以作為字串的緩衝區功能。其累加效能是很不錯的,在後面我們會進行比較。

     總結,討論String和StringBuffer可不可變。本質上是指物件中的value[]字元陣列可不可變,而不是物件引用可不可變。 

★StringBuffer與StringBuilder的執行緒安全性問題 
      StringBuffer和StringBuilder可以算是雙胞胎了,這兩者的方法沒有很大區別。但線上程安全性方面,StringBuffer允許多執行緒進行字元操作。這是因為在原始碼中StringBuffer的很多方法都被關鍵字synchronized 修飾了,而StringBuilder沒有。
      有多執行緒程式設計經驗的程式設計師應該知道synchronized。這個關鍵字是為執行緒同步機制 設定的。我簡要闡述一下synchronized的含義:
      每一個類物件都對應一把鎖,當某個執行緒A呼叫類物件O中的synchronized方法M時,必須獲得物件O的鎖才能夠執行M方法,否則執行緒A阻塞。一旦執行緒A開始執行M方法,將獨佔物件O的鎖。使得其它需要呼叫O物件的M方法的執行緒阻塞。只有執行緒A執行完畢,釋放鎖後。那些阻塞執行緒才有機會重新呼叫M方法。這就是解決執行緒同步問題的鎖機制。 
      瞭解了synchronized的含義以後,大家可能都會有這個感覺。多執行緒程式設計中StringBuffer比StringBuilder要安全多了 ,事實確實如此。如果有多個執行緒需要對同一個字串緩衝區進行操作的時候,StringBuffer應該是不二選擇。
      注意:是不是String也不安全呢?事實上不存在這個問題,String是不可變的。執行緒對於堆中指定的一個String物件只能讀取,無法修改。試問:還有什麼不安全的呢? 

★String和StringBuffer的效率問題(這可是個熱門話題呀!) 
      首先說明一點:StringBuffer和StringBuilder可謂雙胞胎,StringBuilder是1.5新引入的,其前身就是StringBuffer。StringBuilder的效率比StringBuffer稍高,如果不考慮執行緒安全,StringBuilder應該是首選。另外,JVM執行程式主要的時間耗費是在建立物件和回收物件上。


      我們用下面的程式碼執行1W次字串的連線操作,測試String,StringBuffer所執行的時間。

Java程式碼  收藏程式碼
  1. //測試程式碼
  2. publicclass RunTime{  
  3.     publicstaticvoid main(String[] args){  
  4.            ● 測試程式碼位置1
  5.           long beginTime=System.currentTimeMillis();  
  6.           for(int i=0;i<10000;i++){  
  7.                  ● 測試程式碼位置2
  8.           }  
  9.           long endTime=System.currentTimeMillis();  
  10.           System.out.println(endTime-beginTime);     
  11.     }  
  12. }  

(1) String常量與String變數的"+"操作比較 
        ▲測試①程式碼:     (測試程式碼位置1)  String str="";
                                  (測試程式碼位置2)  str="Heart"+"Raid";
            [耗時:  0ms]
             
       ▲測試②程式碼        (測試程式碼位置1)  String s1="Heart";
                                                           String s2="Raid";
                                                           String str="";
                                  (測試程式碼位置2)  str=s1+s2;
            [耗時:  15—16ms]
      結論:String常量的“+連線”  稍優於  String變數的“+連線”。 
      原因:測試①的"Heart"+"Raid"在編譯階段就已經連線起來,形成了一個字串常量"HeartRaid",並指向堆中的拘留字串物件。執行時只需要將"HeartRaid"指向的拘留字串物件地址取出1W次,存放在區域性變數str中。這確實不需要什麼時間。 
               測試②中區域性變數s1和s2存放的是兩個不同的拘留字串物件的地址。然後會通過下面三個步驟完成“+連線”:
                                1、StringBuilder temp=new StringBuilder(s1),
                                2、temp.append(s2);
                                3、str=temp.toString();
               我們發現,雖然在中間的時候也用到了append()方法,但是在開始和結束的時候分別建立了StringBuilder和String物件。可想而知:呼叫1W次,是不是就建立了1W次這兩種物件呢?不划算。

     但是,String變數的"+連線"操作比String常量的"+連線"操作使用的更加廣泛。 這一點是不言而喻的。
   

(2)String物件的"累+"連線操作與StringBuffer物件的append()累和連線操作比較。           ▲測試①程式碼:     (程式碼位置1)  String s1="Heart";
                                                       String s="";
                                    (程式碼位置2)  s=s+s1;
             [耗時:  4200—4500ms]
             
          ▲測試②程式碼        (程式碼位置1)  String s1="Heart";
                                                       StringBuffer sb=new StringBuffer();
                                    (程式碼位置2) sb.append(s1);
             [耗時:  0ms(當迴圈100000次的時候,耗時大概16—31ms)]
         結論:大量字串累加時,StringBuffer的append()效率遠好於String物件的"累+"連線 
         原因:測試① 中的s=s+s1,JVM會利用首先建立一個StringBuilder,並利用append方法完成s和s1所指向的字串物件值的合併操作,接著呼叫StringBuilder的 toString()方法在堆中建立一個新的String物件,其值為剛才字串的合併結果。而區域性變數s指向了新建立的String物件。

                  因為String物件中的value[]是不能改變的,每一次合併後字串值都需要建立一個新的String物件來存放。迴圈1W次自然需要建立1W個String物件和1W個StringBuilder物件,效率低就可想而知了。


                  測試②中sb.append(s1);只需要將自己的value[]陣列不停的擴大來存放s1即可。迴圈過程中無需在堆中建立任何新的物件。效率高就不足為奇了。
        

★ 鏡頭總結:

(1) 在編譯階段就能夠確定的字串常量,完全沒有必要建立String或StringBuffer物件。直接使用字串常量的"+"連線操作效率最高。

(2) StringBuffer物件的append效率要高於String物件的"+"連線操作。

(3) 不停的建立物件是程式低效的一個重要原因。那麼相同的字串值能否在堆中只建立一個String物件那。顯然拘留字串能夠做到這一點,除了程式中的字串常量會被JVM自動建立拘留字串之外,呼叫String的intern()方法也能做到這一點。當呼叫intern()時,如果常量池中已經有了當前String的值,那麼返回這個常量指向拘留物件的地址。如果沒有,則將String值加入常量池中,並建立一個新的拘留字串物件。