1. 程式人生 > >Notes 20180311 : String第三講_深入了解String

Notes 20180311 : String第三講_深入了解String

VM 十分 time 相等 逗號 而在 全面 hang strong

  很多前輩我可能對於我的這節文章很困惑,覺得String這個東西還有什麽需要特別了解的嗎?其實不然,String是一個使用十分頻繁的工具類,不可避免地我們也會遇到一些陷阱,深入了解String對於我們避免陷阱,甚至優化操作是很有必要的。本節我們主要講解"碼點與代碼單元"、“不可變的String”、“無意識的遞歸”、“重載+”。

1.碼點與代碼單元

  Java字符串是由字符序列組成的。而前面我們介紹char數據類型的時候也講到過,char數據類型是一個采用UTF-16編碼表示Unicode碼點的代碼單元大多數的常用Unicode字符使用一個代碼單元就可以表示,而輔助字符需要一對代碼單元表示。

更多Unicode的內容可以參見Knowledge Point 20180305 Java程序員詳述編碼Unicode

1.1 字符串“長度”

  String中提供了一個方法length(),該方法將返回采用UTF-16編碼表示的給定字符串所需要的代碼單元數量。註意是代碼單元數量,而不是字符串的長度(我們通常所理解的字符串長度是字符串中字符個數,這裏得到的並不是這種結果);除了length()外,String還提供了另一個關於長度的方法codePointCount(int beginIndex, int endIndex),該方法返回此 String指定文本範圍內的Unicode代碼點數。在這裏我們要搞清楚代碼單元和代碼點數的區別,代碼點:是指一個編碼表中的某個字符對應的代碼值,也就是Unicode編碼表中每個字符對應的數值

代碼單元是指表示一個代碼點所需的最小單位,在Java中使用的是char數據類型,一個char表示一個代碼單元,這也就是為什麽下面的代碼會編譯報錯,??是一個輔助字符,需要用兩個代碼單元表示,所以這裏不能使用char類型來表示。

//        char ch = ‘??‘;會提示無效的字符,因為char只能表示基本的

看下面的例子:

String greeting = "Hello";
System.out.println("字符串greeting的代碼單元長度:" + greeting.length());//字符串greeting的代碼單元長度:5
System.out.println(
"字符串greeting的碼點數量:" + greeting.codePointCount(0, greeting.length()));//字符串greeting的碼點數量:5

  上面的代碼並沒有什麽晦澀難懂的地方,我們使用的都是常用的Unicode字符,它們使用一個代碼單元(2個字節)就可以表示,所以字符的碼點數量和代碼單元的數量是一致的,但是我們不要忘了Unicode中是存在輔助字符的,輔助字符是一個字符占用兩個代碼單元,下面我們來看另一個例子:

String str = "?? is the set of octonions";
System.out.println("字符串str的代碼單元長度"+ str.length());//字符串str的代碼單元長度26
System.out.println("字符串str的碼點數量:" + str.codePointCount(0, str.length()));//字符串str的碼點數量:25
System.out.println("str獲取指定代碼單元的碼點:" + str.codePointAt(1));//56646

  通過這段代碼,我們很容易就看出了兩個方法的區別了,length()返回String中的代碼單元,底層是通過字符數組的長度來獲取。而codePointCount()返回了代碼點數量,底層是Character的一個方法。由於使用了一個輔助字符,所以明顯的代碼單元是比代碼點數量多1的。在最後一句我們獲取索引1處的碼點,得到的也並非是“空格”,空格的Unicode是32,所以這裏返回的並不是一個空格,而是輔助字符的第二個代碼單元。

1.2 String中對於碼點的操作方法

  String中給我們提供了很多用於操作碼點的方法,我們在上一節中已經認識了,這節我們詳細羅列一下:

  1. int   codePointAt(int index)  返回指定索引處的字符(Unicode代碼點)。 IndexOutOfBoundsException
  2. int codePointBefore(int index)  返回指定索引之前的字符(Unicode代碼點)。 IndexOutOfBoundsException
  3. int codePointCount(int beginIndex, int endIndex)  返回此 String指定文本範圍內的Unicode代碼點數IndexOutOfBoundsException
  4. int offsetByCodePoints(int index, int codePointOffset) 返回此 String內的指數,與 index codePointOffset代碼點。IndexOutOfBoundsException

  上面幾個方法都存在索引越界的異常【底層是數組,所以存在這種隱患,在操作時應該註意參數越界的情況】,這裏所有的參數是代碼單元。前面三個方法我們已經認識過,這裏就只講解一下第四個方法:“這個函數的第二個參數是以第一個參數為標準後移的代碼單元(註意是代碼單元,不是代碼點)的數量。返回該代碼點在字符串中的代碼單元索引。”

String str2 = "??is the set ??is the set of octonions of octonions";
System.out.println(str2.offsetByCodePoints(7, 7));//15     以第7個代碼點為標準後移7個代碼點後是i,在字符串中的代碼單元位置為15
System.out.println(str2.codePointAt(15));//105
String str3 = "i";
System.out.println(str3.codePointAt(0));//10

  看完上面的,我們再來看一下另外兩個方法:

System.out.println(str2.codePointAt(0));//120134
        System.out.println(str2.codePointAt(1));//56646
        System.out.println(str2.codePointBefore(2));//120134
        System.out.println(str2.codePointBefore(1));//55349

  codePointAt(int index)該方法會返回該代碼單元的碼點數,但是該方法會向後尋找,但是不能向前尋找,所以在操作輔助字符的時候,我們發現如果查詢的是輔助字符的第一個代碼單元,那麽返回的是該輔助字符的碼點數,這是因為該方法向後尋找和第二個代碼單元合並成了一個完整的輔助字符。但如果查看的輔助字符的第二個代碼單元,那麽就只能返回第二個代碼單元的碼點數了。String應對該方法,也提供了一個向前查詢的方法codePointBefore該方法會查詢給定代碼單元前的碼點數但是如果給定代碼單元是普通字符,那麽不管該代碼單元前面是普通字符還是輔助字符,都可以完整顯示該碼點數。如果給定代碼單元是輔助字符且是輔助字符的第二個代碼單元,那麽就只會返回該輔助字符的第一個代碼單元了。

1.3 String關於碼點的練習操作

1.3.1 獲取碼點數組和代碼單元數組

  給定一個字符串,將該字符串返回一個由碼點數構成的int數組和代碼單元構成的int數組:

    @Test
    public void test1(){
        String str1 = "??is the set ??is";
        System.out.println(Arrays.toString(codePoint(str1)));
    }
    /**
     * 碼點數組
     * @param str
     */
    public int[] codePoint(String str){
        int[] arr = new int[str.codePointCount(0, str.length())];
        int cp = 0;
        int j = 0;
        for (int i = 0; i < str.length();) {
            cp = str.codePointAt(i);
            if(Character.isSupplementaryCodePoint(cp)){
                arr[j] = cp;
                i += 2;
            }else{
                arr[j] = cp;
          i++; }
j++; } return arr; }

技術分享圖片

  上面我們看到使用到了Character的一個靜態方法isSupplementaryCodePoint(int index),該方法的作用“確定指定字符(Unicode代碼點)是否在 supplementary character範圍內,即檢查該碼點是否是輔助字符的碼點”,

代碼分析:

  我們首先要創建一個數組來存放字符串中的碼點,這個數組的長度和字符串的碼點數量一致;定義兩個變量作為碼點數和數組角標,遍歷字符串,判斷每個代碼單元是否是輔助字符,如果是輔助字符,那麽就要往前進兩位,否則往前進一位;同時將該碼點存入數組中,數組角標進1.

  上面我們使用的前進的方法來操作的,自然也是可以後退查詢的,下面我們改寫上面的代碼:

    /**
     * 碼點數組
     * @param str
     */
    public int[] codePoint(String str){
        int[] arr = new int[str.codePointCount(0, str.length())];
        int cp = 0;
        int j = arr.length-1;
        for (int i = str.length(); i > 0; ) {
            i--;
            if(Character.isSurrogate(str.charAt(i)))
                i--;
                cp = str.codePointAt(i);
                arr[j] = cp;
                j--;
            }
            
        return arr;
    }

技術分享圖片

  這裏很容易就看出來這是通過後退來操作的(--),在這個操作中又使用了Character的一個靜態方法isSurrogate(char ch),該方法用來判斷碼點是否屬於輔助字符,從最後一個代碼單元開始循環,我們知道代碼單元是從0開始的,所以在開始判斷前應該是長度先-1,否則會出現越界異常,判斷該碼點是否屬於輔助字符,如果屬於,那麽向後退1,獲取該輔助字符的碼點,將其放入數組,同時數組索引減1,因為我是讓數組索引和字符串中相應字符對應對弈從後開始填充數組。如果不是輔助字符,那麽此時獲取該代碼單元,而不用再向前退1.

  上面是獲取碼點的數組,下面看一下獲取代碼單元的數組,這比起上面就簡單了很多:

    /**
     * 代碼單元數組
     */
    public int[] codeUnit(String str){
        int[] arr = new int[str.length()];
        int cp = 0;
        int j = 0;
        for (int i = 0; i < arr.length; i++) {
            arr[j] = str.codePointAt(i);
            j++;
        }
        return arr;
    }

技術分享圖片

1.3.2 碼點和字符串的轉換

  如果給出一個字符串,你怎麽將字符串中的某個碼點轉換為Unicode中的對應數呢?給定一個碼點數,怎麽轉換為字符串呢?

    /**
     * 碼點-->碼點數
     */
    @Test
    public void numCode(){
        String str1 = "??is the set ??is";
        System.out.println("\\U+" + Integer.toHexString(str1.codePointAt(0)));
    }
    /**
     * 根據給定Unicode-->String
     */
    @Test
    public void numString(){
        String str1 = "\\U+1d546\\U+1d546";
        String[] arr = str1.split("\\\\U\\+");
        System.out.println(Arrays.toString(arr));
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < arr.length; i++) {
            if(!arr[i] .equals("")){
                int code = Integer.parseInt(arr[i], 16);
//                sb.append((char)code);  強轉會造成輔助字符的丟失
                char[] ch = Character.toChars(code);
                sb.append(ch);
            }
            
        }
        System.out.println(sb.toString());
    }

1.4 總結

  String是一種基本的引用數據類型,也是我們使用很頻繁的一種引用數據。底層是通過字符數組來實現的,String的長度取決於字符數組的長度,而字符數組的長度在於代碼單元的數量,代碼單元和碼點是截然不同的概念。我們在操作String的時候,通過索引查找到的其實就是相應的代碼單元,並不是我們認為的"字符",所以要註意,一旦String中含有輔助字符的時候,我們要切切小心.。

2.不可變的String

  本文轉載https://www.zhihu.com/question/31345592 @胖胖

  不可變的String,初聽之下好像是說字符串不可以改變,實際上這種說法,並沒錯,不過這裏我想說的是為什麽String要不可變,String是怎麽實現不可變的,什麽是不可變,下面我們一一探討一下:

  觀察String的源代碼,我們發現,String是一個被final修飾的類,如下:

public final class String
  private final char value[];
  。。。。。。。。。。
public native String intern(); }

  那麽這是什麽意思呢?String為什麽要用final修飾呢?目的何在呢?下面我們來了解一下:

  我們知道final修飾的類不能被繼承,而沒有子類的類,自然不存在重寫方法的風險。JDK中有一些類在設計之處,Java程序員為了保護類不被破壞,就將其修飾為final,拒絕因為繼承而造成的惡意對類的方法造成的破壞。這是對String不可變最基礎的解釋了。

2.1 什麽是不可變

  String不可變很簡單,如下圖,給一個已有字符串“abcd”第二次賦值為"abcdel",不是在原內存地址上修改數據,而是重新指向一個新地址,新對象。這是String不可變最直觀的的一種理解和解釋了,我們通過一段代碼就可以看出來:

技術分享圖片

/**
     * 字符串是不可變的
     */
    @Test
    public void fun1(){
        String str1 = "abcd";
        String str2 = str1;
        System.out.println(str1 == str2);//true
        str1 = "abcdel";
        System.out.println(str1 == str2);//false
    }

  我們發現,當str1改變後,str2並沒有隨著改變,這是因為什麽呢?通過一幅圖來看一下:

技術分享圖片

  通過這種直接賦值字符串內容生成的字符串對象,會首先去字符串常量池中尋找是否有這個字符串,如果有那麽直接返回該字符串地址,如果沒有,那麽先在字符串常量池中創建該字符串,然後返回該字符串地址;上面在創建str1時就是後一種情況,而在將str1賦值給str2時,是將該字符串常量池中的地址返回給str2的,當str1改變時,由於String是不可變的,所以是重新創建了一個字符串“abcdel”,並將該字符串地址返回給str1,所以此時str1指向了abcdel,但是原有字符串上面還有指針指向它,就是str2,所以也不會被垃圾回收,我們在進行地址判斷時,也出現了false的情況。但是如果我們通過另一種方式來創建字符串會是什麽情況呢?

/**
     * 字符串不可變
     */
    @Test
    public void fun2(){
        String str1 = new String("abcd");
        String str2 = str1;
        System.out.println(str1 == str2);//true
        str1 = "abcdel";
        System.out.println(str1 == str2);//false
    }

  此時這種情況出現的結果和上面是相同的,那麽內存中的結構也相同嗎?不是的,這種情況雖然結果和上面相同,但是內存結構卻差別很大,下面再畫副圖看一下:

技術分享圖片

  這幅圖看起來和上面的很相似,唯一不同的在於我們創建String使用了new,因此而帶來的變化是在堆中創建了一個str1對象,str1和str2都指向這個對象,我們更改str1後,str1指向了字符串常量池中的“abcdel”,而str2的指向內有改變,所以我們看到的結果就如同上面所示了。通過兩幅圖我們知道了String改變時是不會在原有內容上改變的,而是從新創建了一個字符串對象,那麽這種不可變是怎麽實現的呢?

2.2 String為什麽不可變

  在前面我們貼出過String的一些源碼,我們放到這裏再看一下:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {

    private final char value[];//String本質是一個char數組,而且是通過final修飾的.

    private int hash; 
    public String() {
        this.value = "".value;
    }

    public String(String original) {
        this.value = original.value;
        this.hash = original.hash;
    }

    public String(char value[]) {
        this.value = Arrays.copyOf(value, value.length);
    }
}

  首先String類是用final關鍵字修飾,這說明String不可繼承。繼續查看發現,String類的核心字段value是個char[],而且是用final修飾的。final修飾的字段創建後就不可改變。可能認為我們講到這裏就完了,其實不然。雖然value是不可變的,但也僅限於這個引用地址不會再發生變化。這並不能否定Array數組是可變的事實。Array的數據結構看下圖:

技術分享圖片

  也就是說Array變量只是stack上的一個引用,數組的本體結構在heap堆。String類裏的value用final修飾,只是說stack裏的這個叫value的引用地址不可變。沒有說堆裏array本身數據不可變。看下面的示例:

@org.junit.Test
    public void fun1(){
        final int[] value = {1,2,3};
        int[] another = {4,5,6};
//        value = another;這裏會提示final不可改變
    }

  value用final修飾,編譯器不允許我們將value指向堆中另一個地址。但如果我直接對數組元素進行動手,那麽情況就又不同了;

    @org.junit.Test
    public void fun1(){
        final int[] value = {1,2,3};
        System.out.println(Arrays.toString(value));//[1, 2, 3]
        value[2] = 100;
        System.out.println(Arrays.toString(value));//[1, 2, 100]
    }

  或者我們使用更粗暴的反射修改也是可以的:

    @org.junit.Test
    public void fun1(){
        final int[] value = {1,2,3};
        System.out.println(Arrays.toString(value));//[1, 2, 3]//        value[2] = 100;
        Array.set(value, 2, 101);
        System.out.println(Arrays.toString(value));//[1, 2, 101]
    }

  所以說String是不可變的,關鍵是因為SUN的工程師在設計該基本工具類時,在後面所有String的方法裏很小心的沒有去動Array裏的元素,沒有暴露內部成員字段(value是private的)。private final char value[]這一句中,真正構成不可變的除了final外,還有更重要的一點就是private,private的私有訪問權限的作用比final還要重要。而且設計師還很小心地把整個String設計成final禁止繼承,避免被其他人繼承後破壞。所以String不可變的關鍵在於底層的實現,而並非單單是一個final。考研的是工程師構造數據類型,封裝數據的能力。

2.3 不可變有什麽用

  上面我們了解了什麽是不可變,也了解了不可變是如何實現的,那麽不可變在開發中有什麽作用呢?也就是優勢何在呢?最簡單的優點就是為了安全,看下面這個場景:

package cn.charsequence.string.can_not_change;

import java.lang.reflect.Array;
import java.util.Arrays;

public class Test {
    //不可變的String
    public static String appendStr(String s){
        s += "bbb";
        return s;
    }
    //可變的StringBuilder
    public static StringBuilder appendSb(StringBuilder sb){
        return sb.append("bbb");
    }
    
    public static void main(String[] args) {
        String s = new String("aaa");
        String ns = Test.appendStr(s);
        System.out.println("String aaa >>> " + s.toString());//String aaa >>> aaa
        StringBuilder sb = new StringBuilder("aaa");
        StringBuilder nsb = Test.appendSb(sb);
        System.out.println("StringBuiler >>> " + sb.toString());//StringBuiler >>> aaabbb
    }
}

  如果開發中不小心像上面的例子裏,直接在傳進來的參數上加“bbb”,因為Java對象參數傳的是引用,所以可變的StringBuiler參數就被改變了。可以看到變量sb在Test.appendSb(sb)操作之後,就變成了"aaabbb"。有的時候這可能不是我們的本意。所以String不可變的安全性就體現出來了。再看下面這個HashSet用StringBuilder做元素的場景,問題就更嚴重了,而且更隱蔽。

public static void main(String[] args) {
        HashSet<StringBuilder> hs = new HashSet<>();
        StringBuilder sb1 = new StringBuilder("aaa");
        StringBuilder sb2 = new StringBuilder("aaabbb");
        hs.add(sb1);
        hs.add(sb2);
        StringBuilder sb3 = sb1;
        sb3.append("bbb");
        System.out.println(hs);//[aaabbb, aaabbb]
    }

  StringBuilder型變量sb1和sb2分別指向了堆內的字面量“aaa”和"aaabbb"。把他們都插入一個HashSet。到這一步沒問題。但如果後面我把變量sb3也指向sb1的地址,再改變sb3的值,因為StringBuilder沒有不可變性的保護,sb3直接在原先“aaa”的地址上改。導致sb1的值也變了。這時候,HashSet上就出現了兩個相等的鍵值“aaabbb”。破壞了HashSet鍵值的唯一性。所以千萬不要用可變類型做HashMap和HashSet鍵值。

  上面我們說了String不可變的安全性,當有多個引用指向同一個內存地址時,不可變保證了安全性。除了安全性外,String的不可變也體現在了高性能上面。我們知道Java內存結構模型中提供了字符串常量池,我們通過直接賦值的方式創建字符串對象時,會先去字符串常量池中查找該字符串是否存在,如果存在直接返回該字符串地址,如果不存在則先創建後返回地址,如下面:

String one = "someString";
        String two = "someString";

技術分享圖片

  上面one和two指向同一個字符串對象,這樣可以在大量使用字符串的情況下,可以節省內存空間,提高效率。但之所以能實現這個特性,String的不可變性是最基本的一個必要條件。要是內存中字符串內容能夠改來改去,這麽做就完全沒有意義了。

  總結:String的不可變性提高了復用性,節省空間,保證了開發安全,提高了程序效率。

2.4 不可變提高效率的補充解讀

  乍一看可能會覺得小編我是不是腦子進水了,怎麽上邊剛驗證了String的不可變安全、高效,在這裏又疑惑是否提高效率。其實我在這裏想要說的是“有些時候看起來好像修改一個代碼單元要比創建一個新字符串更加簡潔。答案是也對,也不對。的確,通過拼接“Hel”和“p!”來創建一個新字符串的效率確實不高。但是,不可變字符串卻有一個優點:使字符串共享。”

  設計之初,Java的設計者認為共享帶來的高效率遠遠勝過於提取、拼接字符串所帶來的低效率。查看程序發現:很少需要修改字符串,而是往往需要對字符串進行比較(當然,也有例外情況,將來自文件或鍵盤的單個字符或較短的字符串匯集成字符串。為此Java提供了緩沖字符串用來操作)。所以應該站在不一樣的角度來看不可變的高效率,在合適的地方,采用合適的操作。

3.無意識的遞歸

  無意識的遞歸是在讀《Java編程思想》時遇到的一個知識點,覺得是有必要了解的,下面我們來認識一下:

  Java中的每個類從根本上都是繼承自Object,標準容器類自然也不例外.因此容器類都有toString()方法,並且覆寫了該方法,使得它生成的String結果能夠表達容器自身,以及容器所包含的對象.例如ArrayList.toString(),它會遍歷ArrayList中包含的所有對象,調用每個元素上的toString()方法.但如果你希望toString()打印出對象的內存地址,也許你會考慮使用this關鍵字:

package cn.charsequence.string.can_not_change;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class InfiniteRecursion {
    
    /**
     * 重寫toString方法
     */
    @Override
    public String toString() {
        // TODO Auto-generated method stub
        return " InfiniteRecursion address: " + this + "\n";
    }
    public static void main(String[] args) {
        List<InfiniteRecursion> list = new ArrayList<InfiniteRecursion>();
        for (int i = 0; i < 10; i++) {
            list.add(new InfiniteRecursion());
        }
        System.out.println(list);
    }
}

技術分享圖片

  當你創建了InfiniteRecursion對象,並將其打印出來的時候,你會得到一串非常長的異常.如果你將該InfiniteRecursion對象存入一個ArrayList中,然後打印該ArrayList,你也會得到同樣的異常.其實,當如下代碼運行時:

return " InfiniteRecursion address: " + this + "\n";

  這裏發生了自動類型轉換.由InfiniteRecursion類型轉換成String類型.因為編譯器看到一個String對象後面跟著一個”+”,而再後面的對象不是String,於是編譯器試著將this轉換成一個String.它怎麽轉換呢,正是通過this上的toString()方法,於是就發生了遞歸調用.

  如果你真的想要打印出對象的內存地址,應該調用Object.toString()方法,這才是負責此任務的方法,所以你不該使用this,而是應該調用super.toString()方法.改變上面toString方法代碼:

/**
     * 重寫toString方法
     */
    @Override
    public String toString() {
        // TODO Auto-generated method stub
//        return " InfiniteRecursion address: " + this + "\n";
        return " InfiniteRecursion address: " + super.toString() + "\n";
    }

技術分享圖片

4. 重載“+”與StringBuilder 

  String對象是不可變的,你可以給一個String對象加任意多的別名.改變String時會創建一個新的String,原String並不會發生變化,所以指向它的任何引用都不可能改變原有的值,因此,也就不會對其他的引用有什麽影響(例如兩個別名指向同一個引用,一個別名有了改變這個引用的操作,那麽不可變性就保證了另一個別名引用的安全).

  不可變性會帶來一定的效率問題.為String對象重載的”+”操作符就是一個例子.重載的意思是,一個操作符在應用於特定的類時,被賦予了特殊的意義(用於String的”+”與”+=”是Java中僅有的兩個重載過的操作符,而Java並不允許程序員重載任何操作符).+在數學中用來兩個數的相加,在字符串中用來連接String:

package cn.string.two;

public class Concatenation {
    public static void main(String[] args) {
        String mango = "mango";
        String s = "abc" + mango + "def" + 47;
        System.out.println(s);
    }
}

技術分享圖片

  可以想象一下,這段代碼可能是這樣工作的:String可能有一個append()方法,它會生成一個新的String對象,以包含”abc”與mango連接後的字符串.然後,該對象再與”def”相連,生成另一個新的String對象,依次類推.這種工作方式當然也行得通,但是為了生成最終的String,此方式會產生一大堆需要垃圾回收的中間對象.我猜想,Java設計師一開始就是這麽做的(這也是軟件設計中的一個教訓:除非你用代碼將系統實現,並讓它動起來,否則你無法真正了解它會有什麽問題),然後他們發現其性能相當糟糕.想看看以上代碼到底是如何工作的嗎,可以用JDK自帶的工具javap來反編譯以上代碼.命令如下:

技術分享圖片

這裏的-c標誌表示將生成JVM字節碼.我剔除掉了不感興趣的部分,然後作了一點點修改,於是有了以下的字節碼:

技術分享圖片

  如果有匯編語言的經驗,以上代碼一定看著眼熟,其中的dup與invokevirtural語句相當於Java虛擬機上的匯編語句.即使你完全不了解匯編語言也無需擔心,需要註意的重點是:編譯器自動引入了java.lang.StringBuilder類.雖然我們在源代碼中並沒有使用StringBuilder類,但是編譯器卻自作主張地使用了它,因為它更高效.

  在這個例子中,編譯器創建了一個StringBuilder對象,用以構造最終的String,並為每個字符串調用一次StringBuilder的append()方法,總計四次.最後調用toString()生成結果,並存在s(使用的命令為astore_2)

  現在,也許你會覺得可以隨意使用String對象,反正編譯器會為你自動地優化性能.可是在這之前,讓我們更深入地看看編譯器能為我們優化到什麽程度.下面的程序采用兩種方式生成一個String:方法一使用了多個String對象,方法二在代碼中使用了StringBuilder.

package cn.stringPractise.Commonoperation;
public class WhitherStringBuilder {
    public static void main(String[] args) {
        String[] str = {"長安古道馬遲遲","高柳亂蟬嘶","夕陽島外","秋風原上","目斷四天垂",
                "歸雲一去無蹤跡","何處是前期","狎興生疏","酒徒蕭索","不似去年時。"};
        System.out.println(implicit(str));
        System.out.println(explicit(str));
    }
    public static String implicit(String[] fields){
        String result = "";
        for (int i = 0; i < fields.length; i++) {
            result += fields[i];
        }
        return result;
    }
    public static String explicit(String[] fields){
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < fields.length; i++) {
            sb.append(fields[i]);
        }
        return sb.toString();
    }
}
public static void main(String[] args) {
        String[] str = {"長安古道馬遲遲","高柳亂蟬嘶","夕陽島外","秋風原上","目斷四天垂",
                "歸雲一去無蹤跡","何處是前期","狎興生疏","酒徒蕭索","不似去年時。"};
        String[] str1 = new String[20000];
        for (int i = 0; i < 20000; i++) {
            str1[i] = Integer.toString(i);
        }
        long start = System.currentTimeMillis();
//        System.out.println(implicit(str1));
        implicit(str1);
        long end = System.currentTimeMillis();
        System.out.println(end-start);
        start = System.currentTimeMillis();
        explicit(str1);
//        System.out.println(explicit(str1));
        end = System.currentTimeMillis();
        System.out.println(end-start);
    }

技術分享圖片

技術分享圖片

現在運行javap -c WitherStringBuilder,可以看到兩個方法對應的(簡化過的)字節碼.首先是implicit()方法:

技術分享圖片

  註意從第8行到第35行構成了一個循環體.第8行:對堆棧中的操作數進行”大於或等於的整數比較運算”,循環結束時跳到第38行.第35行:返回循環體的起始點(第5行).要註意的重點是:StringBuilder是在循環體內構成的,這意味著每經過一次循環,就會創建一個新的StringBuilder對象.

  下面是explicit()方法對應的字節碼:

技術分享圖片

  可以看到,不僅循環部分的代碼更簡短、更簡單,而且它只生成了一個StringBuilder對象。顯式的創建StringBuilder還允許你預先為其指定大小.如果你已經知道最終的字符串大概有多長,那預先指定StringBuilder的大小可以避免多長重新分配緩沖.

  因此,當你為一個類編寫toString()方法時,如果字符串操作比較簡單,那就可以信賴編譯器,它會為你合理地構造最終的字符串結果.但是,如果你要在toString()方法中使用循環,那麽最好自己創建一個StringBuilder對象,用它來構造最終的結果.參考一下示例:

package cn.stringPractise.Commonoperation;
import java.util.Random;

public class UsingStringBuilder {
    public static Random rand = new Random(47);
    @Override
    public String toString() {
        StringBuilder builder = new StringBuilder("[");
        for (int i = 0; i < 25; i++) {
            builder.append(rand.nextInt(100));
            builder.append(",");
        }
        builder.delete(builder.length()/2, builder.length());
        builder.append("]");
        return builder.toString();
    }
    
    public static void main(String[] args) {
        UsingStringBuilder usingStringBuilder = new UsingStringBuilder();
        System.out.println(usingStringBuilder);
    }
}

技術分享圖片

    public void println(Object x) {
        String s = String.valueOf(x);
        synchronized (this) {
            print(s);
            newLine();
        }
    }
public static String valueOf(Object obj) {
        return (obj == null) ? "null" : obj.toString();
    }

  最終的結果是用append()語句一點點拼接起來的.如果你想走捷徑,例如append(a+”:”+c),那編譯器就會掉入陷阱,從而為你另外創建一個StringBuilder對象處理括號內的字符串操作.如果拿不準該用哪種方式,隨時可以用javap來分析你的程序.

  StringBuilder提供了豐富而全面的方法,包括insert()、repleace()、substring()甚至reverse(),但是最常用的還是append()和toString().還有delete()方法,上面的例子中我們用它刪除最後一個逗號與空格,以便添加右括號.

  StringBuilder是Java SE5引入的,在這之前Java用的是StringBuffer.後者是線程安全的,因此開銷也會大些,所以在Java SE5/6中,字符串操作應該還會更快一點.關於緩沖字符串我們在介紹完String後會統一再詳細介紹。

4.1 重載“+”流程簡略

  兩個字段在進行“+”操作時,那麽究竟是怎麽操作呢?我們自己書寫一段代碼,debug可以看到,在操作時會首先調用String,valueOf()方法,如果該字段是null,那麽返回null,否則調用toString方法,將其轉變為String,進行StringBuilder。append()操作。

Notes 20180311 : String第三講_深入了解String