閱讀754 返回首頁    go 阿裏雲 go 技術社區[雲棲]


Java基礎小技巧回顧--淺析String

對於字符串部分,小胖在《Java特種兵》一書穿插了不少講解,會講得更加透徹一些,本文是小胖幾年前寫的,當初還在初窺門徑階段,很多結論的總結僅用於簡單參考:


本文非常簡單,不過有很多朋友經常問,網上很多例子也寫個大概,很多人也隻是知道和大概,就本文而來讀起來非常的輕鬆,不過算是一些小技巧;但是我們的程序中相信用得最多的就是char數組和byte[]數組,而String就是由char[]數組組成的,一般情況下我們就可以認為String用得是最多的對象之一。


有關Sring的空間利用率方麵,這裏不想多說,隻能說很低很低,尤其是你定義的String長度很短的時候,簡直利用率不好說;在前序的一篇文章中說明了關於java的對象空間申請方法以及對象在JVM內部如何做對其的過程,就能較為明確的知道一個String是多麼的浪費空間;本文就不在多提及這方麵的問題了。


再談及到String與StringBuffer和StringBuilder的區別時,前麵一篇文章中將他們循環做了一係列的性能對比,發現StringBuilder性能最高,大家都知道用StringBuilder來用了,但是要明白細節才是最好的;簡單來講String是不可變的字符串,而StringBuffer和StringBuilder是可變的字符串對象,而StringBuffer是在進行內容修改時(即char數組修改)會進行線程同步操作,在同步過程中存在征用加鎖和訪問對象的過程,開銷較大,在方法內定義的局部變量中沒有必要同步,因為就是當前線程使用,所以StringBuilder為一個非同步的可變字符串對象。


OK,我們介紹了基本的概念,可以回到正題了;那麼String到底是一個神馬東西,通過前麵的對象結構來看,首先根據String內部的定義,應該有以下內容:一個char數組指針指向一個數組對象(數組對象也是一個對象,和普通對象最大的區別需要一個位置來記錄數組的長度)、offset、count、hash、serialVersionUID(這個不用計算在對象的大小中,因為在JVM啟動時就會被裝入到方法區中)。其次,還有對象對其的過程,而String的內容為char數組引用,指向的數組對象的內部的內容,也就是一個String相當於就包含了兩個對象,兩個對象都有頭部,以及對其方式,數組頭部會多一個保存數組長度的區域,頭部還會存儲對象加鎖狀態、唯一標識、方法區指針、GC中的Mark標誌等等相應的內容,如果頭部存儲空間不夠就會在外部開辟一個空間來存儲,內部用一個指針指向那塊空間;另外對象會按照8byte對其方法進行對其,即對象大小不是8byte的倍數,將會填充,方便尋址。


String經常說是不可變的字符串,但是我個人並不習慣將他說成是常量,而很多人也對String字符串不可變以及StringBuilder可變有著很多疑惑之處,String可以做+,為什麼說它不可變呢?String的+到底做了什麼?有人說String還有一些內容可能會放在常量池,這是什麼東西?常量池和常量池的字符串拚接結果是什麼(我曾在網上看到有人寫常量池中字符串和常量池中字符串拚接結果還在常量池,其實未必,後麵我們用事實來說話)?


當你對上述問題了如指掌,String你基本了解得有點通透了;OK,在解釋這個問題之前,我們先說明一個在Hotspot自從分代JVM產生後到目前為止(G1還沒有正式出來之前)不變的道理就是,當你在程序中隻要使用了new關鍵字或者通過任何反射機製實例化的任何對象都將首先放在堆當中,當然一般情況下首先是放在Eden空間中(在一些細節的版本中會有一些區別,如啟動了TABL、或對象超過指定大小直接進入Old或對象連Eden也放不下也會直接進入Old);這是不用說的事實,總之目前我們隻要知道它肯定是在堆當中的就可以了。


我們先來看一段非常非常簡單的代碼如下所示:

public class StringTest {

    public static void main(String[] args) {
        String a = "abc";
        String b = "def";
        
        String c = a + b;
        String d = "abc" + "def";
        
        String e = new String("abc");
        
        System.out.println(a == e);
        System.out.println(a.equals(e));
        System.out.println(a == "abc");
        System.out.println(a == e.intern());
        System.out.println(c == "abcdef");
        System.out.println(d == "abcdef");
    }
}

請在沒有在java上運行前猜猜結果是多少,然後再看結果。



結果如下:

false
true
true
true
false
true


如果你的結果不是猜得,而是直接自己通過理解得到的,後麵的文章你就不用看了,對你來說應該沒有多大意義,如果你某一個結果說得不對,或者是自己瞎猜出來的,OK,後文可能會對你的理解造成一些影響。


我們首先解釋前麵4個結果,再解釋最後2個結果;前4個其實在前麵的文章中已經說過他們的區別,不過為了方便文本繼續向下說明,這裏再說明一次,首先String a = "abc"這樣的申請,會將對象放入常量池中,也就是放在Perm Geration中的,而String e = new String("abc")這個對象是放在Eden空間的,所以當使用a == e發生地址對比,兩者肯定結果是不一樣的;而當發生a == "abc"兩個地址是一樣的,都是指向常量池的對應對象的首地址;而equals是對比值不用多說,肯定是一樣的;a == e.intern()為什麼也是true呢,就是當intern()這個方法發生時,它會在常量池中尋找和e這個字符串等值的字符串(匹配的方法為equals),如果沒有發現則在常量池申請一個一樣的字符串對象,並將對象首地址範圍,如果發現了則直接範圍首地址;而a是常量池中的對象,所以e在常量池中就能找到的地址就是a的首地址;關於這個問題就不多闡述了,也有相關的很多說明,下麵說下後麵兩個結果;算是較為神奇的結果,也是另很多人納悶的結果,不過不用著急,說完後就很簡單了。


後麵兩個結果一個是a指向常量池的“abc”,b指向常量池中的“def”,c是通過a和b相加,兩個都是常量池對象;而d是直接等價於“abc”+“def”按照道理說,兩個也是常量池對象,為什麼兩個對象和常量池的“abcdef”比較的結果不一樣呢?(關於他們為什麼是在常量池就不多說了,上麵那一段已經有結果了);我們不管怎麼樣,首先秒殺掉一句話就是:常量池的String+常量池String結果還在常量池,這句話是不正確的,或者你的測試用例正好是後者,那麼你中招了,很多事情隻是通過測試也未必能得出非常有效的結果,但是較為全麵的測試會讓我們得出更多的結論,看看我們兩種幾乎一摸一樣的測試,但是結果竟然是不一樣的;簡單說結果是前者的對象結果不是在常量池中(記住,常量池中同一個字符串肯定是唯一的),後者的結果肯定在常量池;為什麼,不是我說的,是Hotspot VM告訴我的,我們做一個簡單的小實驗,就知道是為什麼了,首先將代碼修改成這樣:

public class StringTest {

    public static void main(String[] args) {
        String a = "abc";
        String b = "def";
        
        String c = a + b;
    }
}

我們看看編譯完成後它是個什麼樣子:

C:\>javac StringTest.java

C:\>javap -verbose StringTest

Compiled from "StringTest.java"
public class StringTest extends java.lang.Object
  SourceFile: "StringTest.java"
  minor version: 0
  major version: 50
  Constant pool:
const #1 = Method       #9.#18; //  java/lang/Object."<init>":()V
const #2 = String       #19;    //  abc
const #3 = String       #20;    //  def
const #4 = class        #21;    //  java/lang/StringBuilder
const #5 = Method       #4.#18; //  java/lang/StringBuilder."<init>":()V
const #6 = Method       #4.#22; //  java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
const #7 = Method       #4.#23; //  java/lang/StringBuilder.toString:()Ljava/lang/String;
const #8 = class        #24;    //  StringTest
const #9 = class        #25;    //  java/lang/Object
const #10 = Asciz       <init>;
const #11 = Asciz       ()V;
const #12 = Asciz       Code;
const #13 = Asciz       LineNumberTable;
const #14 = Asciz       main;
const #15 = Asciz       ([Ljava/lang/String;)V;
const #16 = Asciz       SourceFile;
const #17 = Asciz       StringTest.java;
const #18 = NameAndType #10:#11;//  "<init>":()V
const #19 = Asciz       abc;
const #20 = Asciz       def;
const #21 = Asciz       java/lang/StringBuilder;
const #22 = NameAndType #26:#27;//  append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
const #23 = NameAndType #28:#29;//  toString:()Ljava/lang/String;
const #24 = Asciz       StringTest;
const #25 = Asciz       java/lang/Object;
const #26 = Asciz       append;
const #27 = Asciz       (Ljava/lang/String;)Ljava/lang/StringBuilder;;
const #28 = Asciz       toString;
const #29 = Asciz       ()Ljava/lang/String;;

{
public StringTest();
  Code:
   Stack=1, Locals=1, Args_size=1
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return
  LineNumberTable:
   line 2: 0


public static void main(java.lang.String[]);
  Code:
   Stack=2, Locals=4, Args_size=1
   0:   ldc     #2; //String abc
   2:   astore_1
   3:   ldc     #3; //String def
   5:   astore_2
   6:   new     #4; //class java/lang/StringBuilder
   9:   dup
   10:  invokespecial   #5; //Method java/lang/StringBuilder."<init>":()V
   13:  aload_1
   14:  invokevirtual   #6; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   17:  aload_2
   18:  invokevirtual   #6; //Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
   21:  invokevirtual   #7; //Method java/lang/StringBuilder.toString:()Ljava/lang/String;
   24:  astore_3
   25:  return
  LineNumberTable:
   line 7: 0
   line 8: 3
   line 10: 6
   line 13: 25


}



說明(這裏不解釋關於棧的計算指令,隻說明大概意思):首先看到使用了一個指針指向一個常量池中的對象內容為“abc”,而另一個指針指向“def”,此時通過new申請了一個StringBuilder(jdk 1.5以前是StringBuffer),然後調用這個StringBuilder的初始化方法;然後分別做了兩次append操作,然後最後做一個toString()操作;可見String的+在編譯後會被編譯為StringBuilder來運行(關於為什麼性能還是比StringBuilder慢那麼多,文章後麵來說明),我們知道這裏做了一個new StringBuilder的操作,並且做了一個toString的操作,前麵我們已經明確說明,凡是new出來的對象絕對不會放在常量池中;toString會發生一次內容拷貝,但是也不會在常量池中,所以在這裏常量池String+常量池String放在了堆中;而下麵這個後麵那種情況呢,我們也用同樣的方式來看看結果是什麼,代碼更簡單了:

public class StringTest {

    public static void main(String[] args) {
        String d = "abc" + "def";
    }
}

看下結果:

C:\>javac StringTest.java

C:\>javap -verbose StringTest

Compiled from "StringTest.java"
public class StringTest extends java.lang.Object
  SourceFile: "StringTest.java"
  minor version: 0
  major version: 50
  Constant pool:
const #1 = Method       #4.#13; //  java/lang/Object."<init>":()V
const #2 = String       #14;    //  abcdef
const #3 = class        #15;    //  StringTest
const #4 = class        #16;    //  java/lang/Object
const #5 = Asciz        <init>;
const #6 = Asciz        ()V;
const #7 = Asciz        Code;
const #8 = Asciz        LineNumberTable;
const #9 = Asciz        main;
const #10 = Asciz       ([Ljava/lang/String;)V;
const #11 = Asciz       SourceFile;
const #12 = Asciz       StringTest.java;
const #13 = NameAndType #5:#6;//  "<init>":()V
const #14 = Asciz       abcdef;
const #15 = Asciz       StringTest;
const #16 = Asciz       java/lang/Object;

{
public StringTest();
  Code:
   Stack=1, Locals=1, Args_size=1
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return
  LineNumberTable:
   line 2: 0


public static void main(java.lang.String[]);
  Code:
   Stack=1, Locals=2, Args_size=1
   0:   ldc     #2; //String abcdef
   2:   astore_1
   3:   return

  LineNumberTable:
   line 11: 0
   line 13: 3


}

這下看下可能有人一下通透了,可能有人覺得更加模煳了,怎麼編譯完後比前麵那個少那麼多,是的,就是少那麼多,因為當發生“abc” + “def”在同一行發生時,JVM在編譯時就認為這個加號是沒有用處的,編譯的時候就直接變成成

String d = "abcdef";

同理如果出現:String a = "a" + 1,編譯時候就會變成:String a = "a1";

再例如:

final String a = "a";
final String b = "ab";
String c = a + b;

在編譯時候,c部分會被編譯為:String c = "aab";但是如果a或b有任意一個不是final的,都會new一個新的對象出來;其次再補充下,如果a和b,是某個方法返回回來的,不論方法中是final類型的還是常量什麼的,都不會被在編譯時將數據編譯到常量池,因為編譯器並不會跟蹤到方法體裏麵去看你做了什麼,其次隻要是變量就是可變的,即使你認為你看到的代碼是不可變的,但是運行時是可以被切入的。

就是這麼簡單,運行時自然直接就在常量池中是一個對象了,而不需要每次訪問到這裏做一個加法操作,有引用的時候,JVM不確定你要拿引用去做什麼,所以它並不會直接將你的字符串進行編譯時的合並(其實在某些情況下JVM可以適當考慮合並,但是JVM可能是考慮到編譯時優化的算法複雜性,所以這些優化可能會放在運行時的JIT來完成,但JIT優化這部分java代碼是有一些前提條件的)


所以並不是常量池String+常量池String結果還在常量池,而是編譯時JVM就認為他們沒有必要做,直接合並了,就像JVM做if(true)和if(false)的優化一樣的道理,而前者如果是引用給出來的常量池對象,JVM在拚接過程中是通過申請StringBuilder來完成的,也就是它的結果就像普通對象一樣放在堆當中的。


好了,反過來一切都很明了了,String為什麼不可變,因為+操作是新申請了對象;+到底做了什麼,是申請了一個StringBuilder來做append操作,然後再toString成一個新的對象;如果不是new出來的字符串或者是通過.intern()得到的字符串,則是常量池中的對象;常量池中的字符串和常量池中的字符串拚接,他們的結果不一定還在常量池,如果還在常量池隻有一種可能性就是編譯時就合並了,因為運行時new出來的StringBuilder是不可能放在常量池中的,我們絕大部分字符串拚接都是有引用的,而不是直接兩個常量串來做的。


下麵回顧最後一個問題就是,既然String拚接是通過StringBuilder來完成的,那麼為什麼String的+和StringBuilder會有那麼大的差距呢?這是一個值得考慮的問題,如果String的+操作和StringBuilder是一樣的操作,那麼我們的StringBuilder就沒有多大存在的必要了,因為apend太多字符串是一件非常惡心的事情。


首先你會發現,如果在同一條代碼中(不一定是同一行代碼,因為java代碼可以相互包裝嵌套,指對於成來講基本的一條代碼),

如String a = a + b + c;這條代碼算是同一行,而System.out.println(a + b + c + String.format(d , "[%s]"));對於d就會單獨處理後,再和a + b+ c處理,然後再調用System中的靜態成員out對象中的println方法;

回到正題,對於同一條代碼中,如果發生這種加法操作(不是編譯時合並的),那麼你在通過javap命令分析時會發現,他們的結果回將其申請一個StringBuilder然後進行append,不論多少個字符串都會append,然後最後toString()操作,這就納悶了,為什麼性能差距會那麼大(在循環次數越多的時候差距會越來越大),最終沒辦法,我們用多行和循環測試,又看了下兩者之間的區別,在使用String做+操作時,如果是多條代碼或者在循環中做的話,每條代碼都會做一個新的new StringBuilder,然後最後會toString一下,也就是當兩個字符串相加時,會“最少”多申請一個StringBuilder然後再轉換為一個String(雖然是將StringBuilder中內容拷貝到一個新的String中,但是空間是兩塊),所以浪費空間比較快,而且如果字符串越長,循環的過程中就會逐步進入old,而且old中的東西也會越來越多,導致了瘋狂的GC,最後會瘋狂的Full GC,再多的內存也會很快達到Full GC,隻要你做循環;其實在常規應用中,一般你隻需要做幾行的字符串疊加也無所謂,如果能寫成一行就寫成一行,如果非要寫成多行還想要性能的話,就用StringBuilder吧;其實快並不是在多少申請了對象,因為java申請對象的速度非常快速,不存在說因為多申請了兩個對象就會導致什麼大的問題,大的問題是因為這些臨時空間所產生的垃圾,最終導致了瘋狂的GC,上述兩種情況在做多次循環的過程中本地使用代碼:-XX:+PrintGCDetails來運行,你會發現,使用String做加法,剛開始會瘋狂的YGC,過一段後會瘋狂的FullGC,最後內存溢出,而使用StringBuilder幾乎不會做GC,要做應該是做YGC,如果發生FGC一般說明這個字符串已經快把OLD區域撐滿了,也就說馬上要內存溢出了,而前者臨時對象也應該去掉的,但是它會比StringBuilder疊加次數更少的時候,發生內存溢出,那是因為對象比較大的時候,臨時對象已經在old區域,而前一個臨時對象正好是要作為後一個對象的拷貝,所以在後麵那個對象還沒有拷貝成功前,前麵那個對象的空間還不能被釋放,那麼很明顯,old區域的利用率一般到一半的時候就溢出了。


最後補充一個話題,其實StringBuilder也有一些問題,就是在動態擴容的過程中,每次增加2倍的空間,並不是在原有空間上做類似的C語言的realloc操作,而是新申請一個2倍大小的空間,將這些內容再拷貝過去;StringBuilder之所以可以動態增加是因為一個預先分配的char長度,如果沒有滿可以繼續在後麵添加內容,如果滿了就申請一個2倍的空間,然後將前麵的拷貝過去;不難說出兩個問題,所謂的動態擴容隻是邏輯上的實現,而並非真正的動態擴容,這也有它的內存安全性考慮,而String是多長,數組的長度就多長(注意:這個長度和前麵說的對象大小關係並不大,對象大小前麵有一定的介紹);另一個可以看出的問題就是動態擴容的過程中同樣會產生各種各樣的垃圾對象,其實在循環的過程中,看得往往還沒有那麼明顯,在多線程訪問多個隨機方法,每個隨機方法內部都會去做一些apend,而且都大於10的時候,臨時對象就多了;不過還好,它的臨時對象隻是char數組,而不是String對象,前麵說了,String對象相當於兩個對象,前麵那個對象的大小也是很大的;但是如果你需要考慮這樣的細節,那麼請在編寫StringBuilder的時候,預先寫好你認為它可能的最大長度,尤其是被反複調用的代碼,如StringBuilder builder = new StringBuilder(2048);一般的小對象沒有必要這樣做,而且一次申請對象如果過大可能很容易進入old區域,甚至於直接進入old區域,這是我們不想看到的;但是這種方法就要求每一位程序員都要有非常高的素質和修養,但是大多數的程序員你可能叫他寫StringBuilder就夠意思了,嗬嗬,更加不要說叫他去些意思了,那麼這個辦法並不能讓所有的程序員所接受,目前的Hotspot還未解決這個問題,但是JRockit已經有一種解決方案了,它的解決方案很好的一種方法,就是在編譯時它就能決定在這個局部方法內部你會發生多少次的append操作,那麼它的StringBuilder內部做的就不是char數組,而是一個String[],預先分配數組的長度就是和append次數一樣大小的數組,每做一次append就像數組下標增加1,並且放在對應的數組位置,並記錄下總體的長度,待這個對象發生toString操作時,此時再申請一個這個長度一樣大小的char[]空間,將數據拷貝進去,就解決了所有的臨時對象的問題,對於在增加了一次間接訪問和toString時候發生的逐個拷貝這些開銷都是可以接受的(隻要append的次數不是特別的多,一般append的次數也不可能特別多,所以利用循環測試出來的性能區別這個時候也是不靠譜的);


最後,所謂的String拚接和StringBuilder下的使用,隻要不是太大的字符串或者太多次數的拚接或者高並發訪問的代碼段做了2行代碼以上的拚接,String做加法幾乎和StringBuilder區別不大;太大的字符串產生的太大的臨時空間,太多的拚接次數是產生太多的臨時空間,同一條代碼中作String的拚接(不論拚接次數)和使用StringBuilder做append效果一致,隻是每次append結果在這行發生完成後會發生toString操作,而默認申請的StringBuilder大小默認為10,如果超過限製則翻倍,這也算是一個限製。


其餘的就沒什麼了,此文閑扯,做做實驗便知道,使用命令分析更加深入,關於動態擴展,在集合類裏麵也有類似的情況,需要注意。

最後更新:2017-04-02 06:51:59

  上一篇:go 在32位的Ubuntu 11.04中為Android NDK r6編譯FFmpeg0.8.1版-Android中使用FFmpeg媒體庫(一)
  下一篇:go 可循環顯示圖像的Android Gallery組件