Java基礎之【深入講解String類】

String 的不可變性


📝 String 不可變的原理

我們先來看一下 String 的源碼是怎麼存儲數據的

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /**
     * The value is used for character storage.
     */
    private final char[] value;

從 String 的源碼我們可以看出:

  • 1、String 是一個 final 類,這就意味着 String 是不能被繼承的,通過這種方式防止出現:程序員通過繼承重寫 String 類的方法的手段來使得String類成爲“可變的”的情況。

  • 2、數組 value 是String的底層數組,用於存儲字符串的內容,而且是 private final

但數組是引用類型,只能限制引用不改變而已,也就是說數組元素的值是可以改變的,而且String 有一個可以傳入數組的構造方法,那麼我們可不可以通過修改外部 char 數組元素的方式來“修改” String 的內容呢?比如下面的例子:

public static void main(String[] args) {
    char[] arr = new char[]{'a','b','c','d'};       
    String str = new String(arr);       
    arr[3]='x';     
    System.out.println("str:" + str);
    System.out.println("arr[]:" + Arrays.toString(arr));
}
>>>>>>>>
str: abcd
arr[]: [a, b, c, x]

結果好像與我們所想不一樣:字符串 str 使用數組 arr 來構造一個對象,當數組 arr 修改其元素值後,字符串 str 並沒有跟着改變。那我們就去源碼裏看一下這個構造方法是怎麼處理的:

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

原來 String 在使用外部 char 數組構造對象時,是重新複製了一份外部 char 數組,從而不會讓外部 char 數組的改變影響到 String 對象。


📝 String 的改變即創建

從上面的分析我們知道,我們是無法從外部修改String對象的,那麼可不可能使用String提供的方法,因爲有不少方法看起來是可以改變String對象的,如 replace()、replaceAll()、substring() 等。我們以 substring() 爲例,看一下源碼:

 public String substring(int beginIndex, int endIndex) {
    //........
    return ((beginIndex == 0) && (endIndex == value.length)) ? this
            : new String(value, beginIndex, subLen);
}

從源碼可以看出,如果不是切割整個字符串的話,就會新建一個對象。也就是說,只要與原字符串不相等,就會新建一個String對象。


📝 緩存 Hashcode

Java中經常會用到字符串的哈希碼(hashcode)。例如,在HashMap中,字符串的不可變能保證其hashcode永遠保持一致,這樣就可以避免一些不必要的麻煩。這也就意味着每次在使用一個字符串的hashcode的時候不用重新計算一次,這樣更加高效。

在 String 類中,有以下代碼:

private int hash;  //this is used to cache hash code.

以上代碼中hash變量中就保存了一個String對象的hashcode,因爲String類不可變,所以一旦對象被創建,該hash值也無法改變。所以,每次想要使用該對象的hashcode的時候,直接返回即可.


字符串拼接

其實,所有的所謂字符串拼接,都是重新生成了一個新的字符串,那麼,在Java中,到底如何進行字符串拼接呢?字符串拼接有很多種方式,這裏簡單介紹幾種比較常用的

📖 使用 “+” 拼接字符串

我們先來看一個例子:

public class Test {
    public static void main(String[] args) {
    	// 創建4個字符串:創建方式不同但字符串內容一樣
        String s = "Hello Java";
        String s2 = "Hello" + " Java";
        String s3 = s2 + "";
        String s4 = new String("Hello Java");

        // 對這4個字符串使用相等比較
        System.out.println("s == s2: " + (s == s2));
        System.out.println("s == s3: " + (s == s3));
        System.out.println("s == s4: " + (s == s4));
    }
}
>>>>>
s == s2: true
s == s3: false
s == s4: false

爲什麼結果是這樣子的?我們來慢慢分析一下,首先,我們要知道編譯器有個優點:在編譯期間會儘可能地優化代碼,所以能由編譯器完成的計算,就不會等到運行時計算,如常量表達式的計算就是在編譯期間完成的。所以,s2 的結果其實在編譯期間就已經計算出來了,與 s 的值是一樣,所以兩者相等,即都屬於字面常量,在類加載時創建並維護在字符串常量池中。但 s3 的表達式中含有變量 s2,只能是運行時才能執行計算,也就是說,在運行時才計算結果,在堆中創建對象,自然與 s 不相等。而 s4 使用new直接在堆中創建對象,更不可能相等。

那在運行期間,是如何完成String的+號拼接操作的呢,要知道String對象可是不可改變的對象。我們反編譯上面例子的 class 文件來看一下原因:

public class Test
{
    public Test()
    {
    }
    public static void main(String args[])
    {
        String s = "Hello Java";
        String s2 = "Hello Java";  //已經得到計算結果
        String s3 = (new StringBuilder(String.valueOf(s2))).toString();
        String s4 = new String("Hello Java");

        // + 號處理成了 StringBuilder.append() 方法
        System.out.println((new StringBuilder("s == s2 ")).append(s == s2).toString());
        System.out.println((new StringBuilder("s == s3 ")).append(s == s3).toString());
        System.out.println((new StringBuilder("s == s4 ")).append(s == s4).toString());
    }
}

可以看出,編譯器將 + 號處理成了StringBuilder.append()方法。也就是說,在運行期間,鏈接字符串的計算都是通過 創建 StringBuilder 對象,調用 append() 方法來完成的

📖 使用 concat 拼接字符串

除了使用+拼接字符串之外,還可以使用String類中的方法concat方法來拼接字符串。如:

String s1 = "Hello";
String s2 = "Java";
String s3 = s1.concat(" ").concat(s2);

我們再來看一下 concat 方法的源代碼,看一下這個方法又是如何實現的:

public String concat(String str) {
   int otherLen = str.length();
   if (otherLen == 0) {
       return this;
   }
   int len = value.length;
   char buf[] = Arrays.copyOf(value, len + otherLen);
   str.getChars(buf, len);
   return new String(buf, true);
}

從源碼可以看出,concat 的原理是創建了一個字符數組,長度是已有字符串的長度和待拼接字符串的長度之和,再把兩個字符串的值複製到新的字符數組中,並使用這個字符數組創建一個新的 String 對象並返回。所以,經過 concat 方法,其實是 new 了一個新的 String 對象,這也驗證了前面我們說的字符串的不變性原理。


StringBuffer 和 StringBuilder

接下來我們看看 StringBuilder 和 StringBuffer 的實現原理。和 String 類似,StringBuilder 類也封裝了一個字符數組,定義如下:

char[] value;  // The value is used for character storage.

但與 String 不同的是,它並不是 final 的,所以他是可以修改的,並且 StringBuilder 的字符數組不一定所有位置都已經被使用,因爲它有一個實例變量,用來表示數組中已經使用的字符個數,定義如下:

int count;  // The count is the number of characters used.

其append源碼如下:

public StringBuilder append(String str) {
    super.append(str);
    return this;
}

該類繼承了 AbstractStringBuilder 類,我們進去看下其 append 方法的源碼:

public AbstractStringBuilder append(String str) {
    if (str == null) {
        return appendNull();
    }
    int len = str.length();
    ensureCapacityInternal(count + len);
    putStringAt(count, str);
    count += len;
    return this;
}

append 會直接拷貝字符到內部的字符數組中,並且使用 ensureCapacityInternal() 來檢查字符數組容量是否足夠,如果字符數組容量不夠,則會進行擴展。

StringBuffer 和 StringBuilder 類似,都是繼承於 AbstractStringBuilder,最大的區別就是 StringBuffer 是線程安全的,我們可以看一下 StringBuffer 的 append 方法的源碼:

public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

該方法使用 synchronized 進行聲明,說明是一個線程安全的方法,而 StringBuilder 則不是線程安全的。

字符串拼接效率比較

字符串的拼接方式比較多:使用+號拼接、使用 concat() 方法拼接、使用 StringBuilder 或 StringBuffer 拼接、使用 StringUtils.join 拼接等等。當同一時刻頻繁進行大量拼接時(比如 for 循環中循環次數很大),他們的拼接效率從高到低排序爲:

StringBuilder >> StringBuffer >> concat >> + >> StringUtils.join

StringBuffer 是在 StringBuilder 的基礎上做了同步處理,所以會在耗時上比 StringBuilder 多一點,這個很好理解。但問題是,使用 + 進行拼接字符串的原理也是使用 StringBuilder 的呀,爲什麼效率會差這麼遠的?來看一下下面的例子:

String str = "";
for (int i = 0; i < 500; i++) {
    String temp = String.valueOf(i);
    str += temp;
}

反編譯後代碼如下

String str = "";
for (int i = 0; i < 500; i++) {
    String temp = String.valueOf(i);
    str = (new StringBuilder()).append(str).sppend(temp).toString();
}

可以看到使用 + 來拼接,每次都是 new 了一個 StringBuilder,然後再把 String 轉成 StringBuilder,再進行 append 操作,如果頻繁的新建對象當然要耗費很多時間了,不僅僅會耗費時間,頻繁的創建對象,還會造成內存資源的浪費。

所以:循環體內,字符串的連接方式,使用StringBuilder 的 append 方法進行擴展。而不要使用 +


總結

下面總結一下這篇文章的主要內容

  • String 是不可變的,一旦創建將不可修改,所有看似修改 String 的方法都是通過創建返回新的 String 來處理的
  • 常用的字符串拼接方式有五種,分別是使用 +、使用concat、使用 StringBuilder、使用 StringBuffer 以及使用 StringUtils.join
  • 由於字符串拼接過程中會創建新的對象,所以如果要在一個循環體中進行字符串拼接,就要考慮內存問題和效率問題
  • 使用 StringBuilder 的方式是效率最高的。因爲 StringBuilder 天生就是設計來定義可變字符串和字符串的變化操作的

使用的基本原則是:

  • 如果不是在循環體中進行字符串拼接的話,直接使用 + 就可以了
  • 如果在併發場景中進行字符串拼接的話,要使用 StringBuffer 來代替 StringBuilder
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章