深入研究Java String

jshanet發表於2019-02-27

開始寫 Java 一年來,一直都是遇到什麼問題再去解決,還沒有主動的深入的去學習過 Java 語言的特性和深入閱讀 JDK 的原始碼。既然決定今後靠 Java
吃飯,還是得花些心思在上面,放棄一些打遊戲的時間,系統深入的去學習。

Java String 是 Java 程式設計中最常用的類之一,也是 JDK 提供的最基礎的類。所以我決定先從 String 類入手,深入的研究一番來開個好頭。

類定義與類成員

開啟 JDK 中的 String 原始碼,最先應當關注 String 類的定義。

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence
複製程式碼

不可繼承與不可變

寫過 Java 的人都知道, 當 final 關鍵字修飾類時,代表此類不可繼承。所以 String 類是不能被外部繼承。這時候我們可能會好奇,String 的設計者
為什麼要把它設計成不可繼承的呢。我在知乎上找到了相關的問題和討論
我覺得首位的回答已經說的很明白了。String 做為 Java 的最基礎的引用資料型別,最重要的一點就是不可變性,所以使用 final 就是為了禁止繼承
破壞了 String 的不可變的性質

實現類的不可變性,不光是用 final 修飾類這麼簡單,從原始碼中可以看到,String 實際上是對一個字元陣列的封裝,而字元陣列是私有的,並且沒有提供
任何可以修改字元陣列的方法,所以一旦初始化完成, String 物件便無法被修改。

序列化

從上面的類定義中我們看到了 String 實現了序列化的介面 Serializable,所以 String 是支援序列化和反序列化的。
什麼是Java物件的序列化?相信很多和我一樣的 Java 菜鳥都有這樣疑問。深入分析Java的序列化與反序列化這篇文章中的這一段話
解釋的很好。

Java平臺允許我們在記憶體中建立可複用的Java物件,但一般情況下,
只有當JVM處於執行時,這些物件才可能存在,
即,這些物件的生命週期不會比JVM的生命週期更長。但在現實應用中,
就可能要求在JVM停止執行之後能夠儲存(持久化)指定的物件,並在將來重新讀取被儲存的物件。
Java物件序列化就能夠幫助我們實現該功能。
使用Java物件序列化,在儲存物件時,會把其狀態儲存為一組位元組,在未來,再將這些位元組組裝成物件。
必須注意地是,物件序列化儲存的是物件的”狀態”,即它的成員變數。由此可知,物件序列化不會關注類中的靜態變數。
除了在持久化物件時會用到物件序列化之外,當使用RMI(遠端方法呼叫),或在網路中傳遞物件時,都會用到物件序列化。
Java序列化API為處理物件序列化提供了一個標準機制,該API簡單易用。

在 String 原始碼中,我們也可以看到支援序列化的類成員定義。

    /** use serialVersionUID from JDK 1.0.2 for interoperability */
    private static final long serialVersionUID = -6849794470754667710L;

    /**
     * Class String is special cased within the Serialization Stream Protocol.
     *
     * A String instance is written into an ObjectOutputStream according to
     * <a href="{@docRoot}/../platform/serialization/spec/output.html">
     * Object Serialization Specification, Section 6.2, "Stream Elements"</a>
     */
    private static final ObjectStreamField[] serialPersistentFields =
        new ObjectStreamField[0];
複製程式碼

serialVersionUID 是一個序列化版本號,Java 通過這個 UID 來判定反序列化時的位元組流與本地類的一致性,如果相同則認為一致,
可以進行反序列化,如果不同就會丟擲異常。

serialPersistentFields 這個定義則比上一個少見許多,大概猜到是與序列化時的類成員有關係。為了弄懂這個欄位的意義,我 google 百度齊上,也
僅僅只找到了 JDK 文件對類 ObjectStreamField的一丁點描述, A description of a Serializable field from a Serializable class. An array of ObjectStreamFields is used to declare the Serializable fields of a class. 大意是這個類用來描述序列化類的一個序列化欄位,
如果定義一個此類的陣列則可以宣告類需要被序列化的欄位。但是還是沒有找到這個類的具體用法和作用是怎樣的。後來我仔細看了一下這個欄位的定義,
serialVersionUID 應該是同樣通過具體欄位名來定義各種規則的,然後我直接搜尋了關鍵字 serialPersistentFields,終於找到了它的具體作用。
即,預設序列化自定義包括關鍵字 transient 和靜態欄位名 serialPersistentFieldstransient 用於指定哪個欄位不被預設序列化,
serialPersistentFields 用於指定哪些欄位需要被預設序列化。如果同時定義了 serialPersistentFieldstransienttransient 會被忽略。

我自己也測試了一下,確實是這個效果。

知道了 serialPersistentFields 的作用以後,問題又來了,既然這個靜態欄位是用來定義參與序列化的類成員的,那為什麼在 String 中這個陣列的長度定義為0?
經過一番搜尋查詢資料以後,還是沒有找到一個明確的解釋,期待如果有大佬看到能解答一下。

可排序

String 類還實現了 Comparable 介面,Comparable<T>介面只有一個方法 public int compareTo(T o),實現了這個介面就意味著該類支援排序,
即可用 Collections.sortArrays.sort 等方法對該類的物件列表或陣列進行排序。

在 String 中我們還可以看到這樣一個靜態變數,

 public static final Comparator<String> CASE_INSENSITIVE_ORDER
                                         = new CaseInsensitiveComparator();
private static class CaseInsensitiveComparator
            implements Comparator<String>, java.io.Serializable {
        // use serialVersionUID from JDK 1.2.2 for interoperability
        private static final long serialVersionUID = 8575799808933029326L;

        public int compare(String s1, String s2) {
            int n1 = s1.length();
            int n2 = s2.length();
            int min = Math.min(n1, n2);
            for (int i = 0; i < min; i++) {
                char c1 = s1.charAt(i);
                char c2 = s2.charAt(i);
                if (c1 != c2) {
                    c1 = Character.toUpperCase(c1);
                    c2 = Character.toUpperCase(c2);
                    if (c1 != c2) {
                        c1 = Character.toLowerCase(c1);
                        c2 = Character.toLowerCase(c2);
                        if (c1 != c2) {
                            // No overflow because of numeric promotion
                            return c1 - c2;
                        }
                    }
                }
            }
            return n1 - n2;
        }

        /** Replaces the de-serialized object. */
        private Object readResolve() { return CASE_INSENSITIVE_ORDER; }
    }
複製程式碼

從上面的原始碼中可以看出,這個靜態成員是一個實現了 Comparator 介面的類的例項,而實現這個類的作用是比較兩個忽略大小寫的 String 的大小。

那麼 ComparableComparator 有什麼區別和聯絡呢?同時 String 又為什麼要兩個都實現一遍呢?

第一個問題這裡就不展開了,總結一下就是,Comparable 是類的內部實現,一個類能且只能實現一次,而 Comparator 則是外部實現,可以通過不改變
類本身的情況下,為類增加更多的排序功能。
所以我們也可以為 String 實現一個 Comparator使用,具體可以參考Comparable與Comparator的區別這篇文章。

String 實現了兩種比較方法的意圖,實際上是一目瞭然的。實現 Comparable 介面為類提供了標準的排序方案,同時為了滿足大多數排序需求的忽略大小寫排序的情況,
String 再提供一個 Comparator 到公共靜態類成員中。如果還有其他的需求,那就只能我們自己實現了。

類方法

String 的方法大致可以分為以下幾類。

  • 構造方法
  • 功能方法
  • 工廠方法
  • intern方法

關於 String 的方法的解析,這篇文章已經解析的夠好了,所以我這裡也不再重複的說一遍了。不過
最後的 intern 方法值得我們去研究。

intern方法

字串常量池

String 做為 Java 的基礎型別之一,可以使用字面量的形式去建立物件,例如 String s = "hello"。當然也可以使用 new 去建立 String 的物件,
但是幾乎很少看到這樣的寫法,久而久之我便習慣了第一種寫法,但是卻不知道背後大有學問。下面一段程式碼可以看出他們的區別。

public class StringConstPool {
    public static void main(String[] args) {
        String s1 = "hello world";
        String s2 = new String("hello world");
        String s3 = "hello world";
        String s4 = new String("hello world");
        String s5 = "hello " + "world";
        String s6 = "hel" + "lo world";
        String s7 = "hello";
        String s8 = s7 + " world";
        
        System.out.println("s1 == s2: " + String.valueOf(s1 == s2) );
        System.out.println("s1.equals(s2): " + String.valueOf(s1.equals(s2)));
        System.out.println("s1 == s3: " + String.valueOf(s1 == s3));
        System.out.println("s1.equals(s3): " + String.valueOf(s1.equals(s3)));
        System.out.println("s2 == s4: " + String.valueOf(s2 == s4));
        System.out.println("s2.equals(s4): " + String.valueOf(s2.equals(s4)));
        System.out.println("s5 == s6: " + String.valueOf(s5 == s6));
        System.out.println("s1 == s8: " + String.valueOf(s1 == s8));
    }
}
/* output
s1 == s2: false
s1.equals(s2): true
s1 == s3: true
s1.equals(s3): true
s2 == s4: false
s2.equls(s4): true
s5 == s6: true
s1 == s8: false
 */

複製程式碼

從這段程式碼的輸出可以看到,equals 比較的結果都是 true,這是因為 String 的 equals 比較的值( Object 物件的預設 equals 實現是比較引用,
String 對此方法進行了重寫)。== 比較的是兩個物件的引用,如果引用相同則返回 true,否則返回 falses1==s2: falses2==s4: false
說明了 new 一個物件一定會生成一個新的引用返回。s1==s3: true 則證明了使用字面量建立物件同樣的字面量會得到同樣的引用。

s5 == s6 實際上和 s1 == s3 在 JVM 眼裡是一樣的情況,因為早在編譯階段,這種常量的簡單運算就已經完成了。我們可以使用 javap 反編譯一下 class 檔案去檢視
編譯後的情況。

➜ ~ javap -c StringConstPool.class
Compiled from "StringConstPool.java"
public class io.github.jshanet.thinkinginjava.constpool.StringConstPool {
  public io.github.jshanet.thinkinginjava.constpool.StringConstPool();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String hello world
       2: astore_1
       3: return
}
複製程式碼

看不懂彙編也沒關係,因為註釋已經很清楚了……

s1 == s8 的情況就略複雜,s8 是通過變數的運算而得,所以無法在編譯時直接算出其值。而 Java 又不能過載運算子,所以我們在 JDK 的原始碼裡也
找不到相關的線索。萬事不絕反編譯,我們再通過反編譯看看實際上編譯器對此是否有影響。

public class io.github.jshanet.thinkinginjava.constpool.StringConstPool {
  public io.github.jshanet.thinkinginjava.constpool.StringConstPool();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // String hello
       2: astore_1
       3: new           #3                  // class java/lang/StringBuilder
       6: dup
       7: invokespecial #4                  // Method java/lang/StringBuilder."<init>":()V
      10: aload_1
      11: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      14: ldc           #6                  // String  world
      16: invokevirtual #5                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      19: invokevirtual #7                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      22: astore_2
      23: return
}
複製程式碼

通過反編譯的結果可以發現,String 的變數運算實際上在編譯後是由 StringBuilder 實現的,s8 = s7 + " world" 的程式碼等價於
(new StringBuilder(s7)).append(" world").toString()Stringbuilder 是可變的類,通過 append 方法 和 toString 將兩個 String 物件聚合
成一個新的 String 物件,所以到這裡就不難理解為什麼 s1 == s8 : false 了。

之所以會有以上的效果,是因為有字串常量池的存在。字串物件的分配和其他物件一樣是要付出時間和空間代價,而字串又是程式中最常用的物件,JVM
為了提高效能和減少記憶體佔用,引入了字串的常量池,在使用字面量建立物件時, JVM 首先會去檢查常量池,如果池中有現成的物件就直接返回它的引用,如果
沒有就建立一個物件,並放到池裡。因為字串不可變的特性,所以 JVM 不用擔心多個變數引用同一個物件會改變物件的狀態。同時執行時例項建立的全域性
字串常量池中有一個表,總是為池中的每個字串物件維護一個引用,所以這些物件不會被 GC 。

intern 方法的作用

上面說了很多都沒有涉及到主題 intern 方法,那麼 intern 方法到作用到底是什麼呢?首先檢視一下原始碼。

    /**
     * Returns a canonical representation for the string object.
     * <p>
     * A pool of strings, initially empty, is maintained privately by the
     * class {@code String}.
     * <p>
     * When the intern method is invoked, if the pool already contains a
     * string equal to this {@code String} object as determined by
     * the {@link #equals(Object)} method, then the string from the pool is
     * returned. Otherwise, this {@code String} object is added to the
     * pool and a reference to this {@code String} object is returned.
     * <p>
     * It follows that for any two strings {@code s} and {@code t},
     * {@code s.intern() == t.intern()} is {@code true}
     * if and only if {@code s.equals(t)} is {@code true}.
     * <p>
     * All literal strings and string-valued constant expressions are
     * interned. String literals are defined in section 3.10.5 of the
     * <cite>The Java&trade; Language Specification</cite>.
     *
     * @return  a string that has the same contents as this string, but is
     *          guaranteed to be from a pool of unique strings.
     */
    public native String intern();
複製程式碼

Oracle JDK 中,intern 方法被 native 關鍵字修飾並且沒有實現,這意味著這部分到實現是隱藏起來了。從註釋中看到,這個方法的作用是如果常量池
中存在當前字串,就會直接返回當前字串,如果常量池中沒有此字串,會將此字串放入常量池中後再返回。通過註釋的介紹已經可以明白這個方法的作用了,
再用幾個例子證明一下。

public class StringConstPool {
    public static void main(String[] args) {
        String s1 = "hello";
        String s2 = new String("hello");
        String s3 = s2.intern();
        System.out.println("s1 == s2: " + String.valueOf(s1 == s2));
        System.out.println("s1 == s3: " + String.valueOf(s1 == s3));
    }
}
/* output
s1 == s2: false
s1 == s3: true
*/
複製程式碼

這裡就很容易的瞭解 intern 實際上就是把普通的字串物件也關聯到常量池中。

當然 intern 的實現原理和最佳實踐等也是需要理解學習的,美團技術團隊的這篇深入解析String#intern
很深入也很詳細,推薦閱讀。

相關文章