金九銀十,收下這份 Java String 面試題

請點贊關注,你的支援對我意義重大。

🔥 Hi,我是小彭。本文已收錄到 GitHub · Android-NoteBook 中。這裡有 Android 進階成長知識體系,有志同道合的朋友,關注公眾號 [彭旭銳] 帶你建立核心競爭力。

前言

大家好,我是小彭。

過去兩年,我們在掘金平台上發布 JetPack 專欄文章,小彭也受到了大家的意見和鼓勵。最近,小彭會陸續搬運到公眾號上。

在每種程式語言里,字元串都是一個躲不開的話題,也是面試常常出現的問題。在這篇文章里,我將總結 Java 字元串中重要的知識點 & 面試題 ,如果能幫上忙,請務必點贊加關注,這真的對我非常重要。


學習路線圖:


1. C 和 Java 中字元串和字元數組的對比

1.1 記憶體表示不同

  • 在 C 語言中,字元串和字元數組相同。字元串本質上是以 \0 為結束符的字元數組字元數組,因此字元串和字元數組在本質上相同,都是一塊連續的記憶體空間,以需要轉義 \0 為結束符。C 語言是不關心 char[] 里存儲字元的編碼方式的,只有通過程式的上下文確定;
  • 在 Java 中,字元串和字元數組不同。字元串是 String 對象,而字元數組是數組對象,均不需要結束符。如果是數組對象,對象記憶體區域中有一個欄位表示數組的長度,而 String 相當於字元數組的包裝類。內部包裝了一個基於 UTF-16 BE 編碼的字元數組(從 Java 9 開始變為位元組數組)。其他字元編碼輸入的位元組流在進入 String 時都會被轉換為 UTF-16 BE 編碼。

java.lang.String

public final class String {

    private final char value[];

    private int hash;

    ...
}

1.2 char 類型的數據長度

  • 在 C 語言中,char 類型占 1 位元組,分為有符號與無符號兩種;
  • 在 Java 中,char 類型占 2 位元組,只有無符號類型。
語言 類型 存儲空間(位元組) 最小值 最大值
Java char 2 0 65535
C char(相當於signed char) 1 -128 127
C signed char 1 -128 127
C unsigned char 1 0 255

2. 為什麼 Java 9 String 內部將 char 數組改為 byte 數組?

Java String 的記憶體表示本質上是基於 UTF-16 BE 編碼的字元數組。UTF-16 是 2 個位元組或 4 個位元組的變長編碼,這意味著即使是 UniCode 字符集的拉丁字母,使用 ASCII 編碼只需要一個位元組,但是在 String 中需要兩個位元組的存儲空間。

為了優化存儲空間,從 Java 9 開始,String 內部將 char 數組改為 byte 數組,String 會判斷字元串中是否只包含拉丁字母。如果是的話則採用單位元組編碼(Latin-1),否則使用 UTF-16 編碼。

String.java (since Java 9)

private final byte coder;
static final boolean COMPACT_STRINGS;
static {
    COMPACT_STRINGS = true;
}
byte coder() {
    return COMPACT_STRINGS ? coder : UTF16;
}
@Native static final byte LATIN1 = 0;
@Native static final byte UTF16  = 1;

不同編碼實現的簡單區別如下:

編碼格式 編碼單元長度 BOM 位元組序
UTF-8-無BOM 1 ~ 4 位元組 無 大端序
UTF-8 1 ~ 4 位元組 EF BB BF 大端序
UTF-16-無BOM 2 / 4 位元組 無 大端序
UTF-16BE(默認) 2 / 4 位元組 FE FF 大端序
UTF-16LE 2 / 4 位元組 FF FE 小端序
UTF-32-無BOM 4 位元組 無 大端序
UTF-32BE(默認) 4 位元組 00 00 FE FF 大端序
UTF-32LE 4 位元組 FF EE 00 00 小端序

關於字元編碼的更多內容,見: 電腦基礎:今天一次把 Unicode 和 UTF-8 說清楚


3. String & StringBuilder & StringBuffer 的區別

3.1 效率

String 是不可變的,每次操作都會創建新的變數,而另外兩個是可變的,不需要創建新的變數;另外,StringBuffer 的每個操作方法都使用 synchronized 關鍵字保證執行緒安全,增加了更多加鎖 & 釋放鎖的時間。因此,操作效率的簡單排序為:StringBuilder > StringBuffer > String。

3.2 執行緒安全

String 不可變,所以 String 和 StringBuffer 都是執行緒安全的,而 StringBuilder 是非執行緒安全的。

類型 操作效率 執行緒安全
String 低 安全(final)
StringBuffer 中 安全(synchronized)
StringBuilder 高 非安全

4. 為什麼 String 設計為不可變類?

4.1 如何讓 String 不可變?

《Effective Java》中 可變性最小化原則,闡述了不可變類的規則:

  • 1、不對外提供修改對象狀態的任何方法;
  • 2、保證類不會被擴展(聲明為 final 類或 private 構造器);
  • 3、聲明所有域為 final;
  • 4、聲明所有域為 private;
  • 5、確保對於任何可變性組件的互斥訪問。

以上規則 String 均滿足。

4.2 為什麼 String 要設計為不可變?

  • 1、不可變類 String 可以避免修改後無法定位散列表鍵值對: 假設 String 是可變類,當我們在 HashMap 中構建起一個以 String 為 Key 的鍵值對時,此時對 String 進行修改,那麼通過修改後的 String 是無法匹配到剛才構建過的鍵值對的,因為修改後的 hashCode 可能是變化的。而不可變類可以規避這個問題。
  • 2、執行緒安全: 不可變對象本質是執行緒安全,不需要同步;

提示: 反射可以破壞 String 的不可變性。


5. String + 的實現原理

String + 操作符是編譯器語法糖,編譯後會被替換為 StringBuilder#append(...) 語句,例如:

示常式序

// 源碼:

String string = null;
for (String str : strings) {
    string += str;
}
return string;

// 編譯產物:

String string = null;
for(String str : strings) {
    StringBuilder builder = new StringBuilder();
    builder.append(string);
    builder.append(str);
    string = builder.toString();
}

// 位元組碼:

 0 aconst_null
 1 astore_1
 2 aload_0
 3 astore_2
 4 aload_2
 5 arraylength
 6 istore_3
 7 iconst_0
 8 istore 4
10 iload 4
12 iload_3
13 if_icmpge 48 (+35)
16 aload_2
17 iload 4
19 aaload
20 astore 5
22 new #7 <java/lang/StringBuilder>
25 dup
26 invokespecial #8 <java/lang/StringBuilder.<init>>
29 aload_1
30 invokevirtual #9 <java/lang/StringBuilder.append>
33 aload 5
35 invokevirtual #9 <java/lang/StringBuilder.append>
38 invokevirtual #10 <java/lang/StringBuilder.toString>
41 astore_1
42 iinc 4 by 1
45 goto 10 (-35)
48 aload_1
49 areturn

可以看到,如果在循環里直接使用字元串 +,會生成非常多中間變數,性能非常差。應該在循環外新建一個 StringBuilder,在循環內統一操作這個對象。


6. String 對象的記憶體分配

6.1 "abc" 與 new String("abc") 的區別

  • "abc" => 虛擬機首先檢查 運行時常量池 中是否存在 "abc",如果存在則直接返回,否則在字元串常量池中創建 "abc" 對象並返回。因此,多次聲明使用的是同一個對象;
  • new String("abc") => 在編譯過程中,Javac 會將 "abc" 加入到 Class 文件常量池 中。在類載入時期,Class 文件常量池會被載入進運行時常量池。在調用 new 位元組碼指令時,虛擬機會在堆中新建一個對象,並且引用常量池中的 "abc" 對象。

6.2 String#intern() 的實現原理

如果字元串常量池中已經包含一個等於此 String 對象的字元串,則返回常量池中的這個字元串;否則,先將此 String 對象包含的字元串拷貝到常量池中,在常量池中的這個字元串。

從 JDK 1.7 開始,String#intern() 不再拷貝字元串到常量池中,而是在常量池中生成一個對原 String 對象的引用,並返回。

// 舉例:
String s = new String("1");
s.intern();
String s2 = "1";
System.out.println(s == s2);

String s3 = new String("1") + new String("1");
s3.intern();
String s4 = "11";
System.out.println(s3 == s4);

// 輸出結果為:
JDK1.6以及以下:false false
JDK1.7以及以上:false true

7. 為什麼 String#haseCode() 要使用 31 作為因子?

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

  • 原因 1 - 31 可以被編譯器優化 $31 * i = (i << 5) - i$,位運算和減法運算的效率比乘法運算高。
  • 原因 2 - 31 是一個質數: 質數是只能被 1 和自身整除的數,使用質數作為乘法因子獲得的散列值,在將來進行取模時,得到相同 index 的概率會降低,即降低了哈希衝突的概率。
  • 原因 3 - 31 是一個不大不小的質數: 質數太小容易造成散列值聚集在一個小區間,提供散列衝突概率;質數過大容易造成散列值超出 int 的取值範圍(上溢),丟失部分數值資訊,散列衝突概率不穩定。

我是小彭,帶你構建 Android 知識體系。技術和職場問題,請關注公眾號 [彭旭銳]私信我提問。

Tags: