金九銀十,收下這份 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 知識體系。技術和職場問題,請關注公眾號 [彭旭銳]私信我提問。

