【Java系列】從JVM角度解析Java核心類String的不可變特性

愛程式設計的倫老師發表於2017-07-23

凱倫說,公眾號ID: KailunTalk,努力寫出最優質的技術文章,歡迎關注探討。

1. 前言

最近看到幾個有趣的關於Java核心類String的問題。

  1. String類是如何實現其不可變的特性的,設計成不可變的好處在哪裡。
  2. 為什麼不推薦使用+號的方式去形成新的字串,推薦使用StringBuilder或者StringBuffer呢。

翻閱了網上的一些部落格和stackoverflow,結合自己的理解做一個彙總。

2. String類是如何實現不可變的

String類的一大特點,就是使用Final類修飾符。

A class can be declared final if its definition is complete and no subclasses are desired or required.

Because a final class never has any subclasses, the methods of a final class are never overridden .

Java SE 7 官方手冊中的定義如上,如果你認為這個類已經定義完全並且不需要任何子類的話,可以將這個類宣告為Final,Final類中的方法將永遠不會被重寫。

在Java中,String是被設計成一個不可變(immutable)類,一旦建立完後,字串本身是無法通過正常手段被修改的。

private final char value[];      // 一旦初始化後,引用不能被修改

public String substring(int beginIndex, int endIndex) {
        if (beginIndex < 0) {
            throw new StringIndexOutOfBoundsException(beginIndex);
        }
        if (endIndex > value.length) {
            throw new StringIndexOutOfBoundsException(endIndex);
        }
        int subLen = endIndex - beginIndex;
        if (subLen < 0) {
            throw new StringIndexOutOfBoundsException(subLen);
        }
        return ((beginIndex == 0) && (endIndex == value.length)) ? this
                : new String(value, beginIndex, subLen);
    }複製程式碼

選了substring方法來做一個代表,其他常見的涉及String操作的方法都是類似,如果你操作後的內容會和目前String中的內容不一致的話,那麼都是重新建立一個新的String類返還,不會讓你去修改內部的內容。

將String類設計成Final類,能夠避免其方法被子類重寫,從而破壞了它本身方法的實現,進而破壞了不可變的特性。

2.1 String類設計成不可變的好處

我們都不是Java語言的設計者,不知道其為何一定要設計成不可變,試著做一些猜想。

  1. 可以實現多個變數引用JVM記憶體中的同一個字串例項。見後文String Pool的介紹。
  2. 安全性,String類的用途實在太廣了,如果可以隨意修改的,是不是很恐怖。
  3. 效能,String大量運用在雜湊的處理中,由於String的不可變性,可以只計算一次雜湊值,然後快取在內部,後續直接取就好了。如果String類是可變的話,在進行雜湊處理的時候,需要進行大量的雜湊值的重新計算。

這是結合個人理解和stackoverflow上看的彙總,我們來看看Java語言的爸爸James Gosling是怎麼說的。

From a strategic point of view, they tend to more often be trouble free. And there are usually things you can do with immutables that you can't do with mutable things, such as cache the result. If you pass a string to a file open method, or if you pass a string to a constructor for a label in a user interface, in some APIs (like in lots of the Windows APIs) you pass in an array of characters. The receiver of that object really has to copy it, because they don't know anything about the storage lifetime of it. And they don't know what's happening to the object, whether it is being changed under their feet.

You end up getting almost forced to replicate the object because you don't know whether or not you get to own it. And one of the nice things about immutable objects is that the answer is, "Yeah, of course you do." Because the question of ownership, who has the right to change it, doesn't exist.

One of the things that forced Strings to be immutable was security. You have a file open method. You pass a String to it. And then it's doing all kind of authentication checks before it gets around to doing the OS call. If you manage to do something that effectively mutated the String, after the security check and before the OS call, then boom, you're in. But Strings are immutable, so that kind of attack doesn't work. That precise example is what really demanded that Strings be immutable.

這是James Gosling在2001年5月的一次訪談中,談到了不可變類和String,大意就是 他會更傾向於使用不可變類,它能夠快取結果,當你在傳參的時候,使用不可變類不需要去考慮誰可能會修改其內部的值,這個問題不存在的。如果使用可變類的話,可能需要每次記得重新拷貝出裡面的值,效能會有一定的損失。

老爺子還說了,迫使String類設計成不可變的另一個原因是安全,當你在呼叫其他方法,比如呼叫一些系統級操作之前,可能會有一系列校驗,如果是可變類的話,可能在你校驗過後,其內部的值被改變了,可能引起嚴重的系統崩潰問題,這是迫使String類設計成不可變類的重要原因。

2.2 String Pool

上文說了,設計成不可變後,可以多個變數引用JVM上同一塊地址,可以節省記憶體空間,相同的字串不用重複佔用Heap區域空間。

String test1 = "abc";
String test2 = "abc";複製程式碼

通常我們平時在使用字串是,都是通過這種方式使用,那麼JVM中的大致儲存就是如下圖所示。

兩個變數同時引用了String Pool中的abc,如果String類是可變的話,也就不能存在String Pool這樣的設計了。
在平時我們還會通過new關鍵字來生成String,那麼新建立的String是否也會和上文中的示例一樣共享同一個字串地址呢。

        String test1 = "abc";
        String test2 = "abc";
        String test3 = new String("abc");複製程式碼

答案是不會,使用new關鍵字會在堆區在建立出一個字串,所以使用new來建立字串還是很浪費記憶體的,記憶體結構如下圖所示。

2.3 不推薦使用+來拼裝字串的原因。

首先我們來看這一段程式碼,應該是之前寫程式碼比較常見的。

String test1 = "abc";
String test2 = "abc";
String test3 = test1 + test2;複製程式碼

test3通過test1和test2拼接而成,我們看一下這個過程中的位元組碼。

從以上圖我們可以看到,目前的JDK7的做法是,會通過新建StringBuilder的方式來完成這個+號的操作。這是目前的一個底層位元組碼的實現,那麼是不是沒有使用StringBuilder或者StringBuffer的必要了呢。還是有的,看下一個例子。

String test2 = "abc";
String test3 = "abc";

for (int i = 0; i < 5; i++) {
    test3 += test2;
}複製程式碼

在上述程式碼中,我們還是使用+號進行拼接,但這次我們加了一個迴圈,看一下位元組碼有什麼變化。

每次迴圈都會建立一個StringBuilder,在末尾再呼叫toString返還回去,效率很低。繼續看下一個例子,我們直接使用StringBuilder,來做拼接。

String test2 = "abc";
// 使用StringBuilder進行拼接
StringBuilder test4 = new StringBuilder("abc");
for (int i = 0; i < 5; i++) {
    test4.append(test2);
}複製程式碼

每次迴圈體中只會呼叫之前建立的StringBuilder的append方法進行拼接,效率大大提高。

至於StringBuilder 的內部實現,諸位有興趣可以自己再去看一下,本質上也是一個char陣列上的操作,和StringBuffer的區別在於,StringBuffer是有做同步處理的,而StringBuilder沒有。

3. 總結

本文主要探討了String類使用final修飾以及設計成不可變類的原因,同時對+號的使用也做了分析,提醒在日常工作中慎用。
如果有問題的話,歡迎多多留言探討,本文僅拋磚引玉,若覺得對您有幫助的話,歡迎點贊,轉發,繼續支援我的原創。

相關文章