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