? 盡人事,聽天命。博主東南大學碩士在讀,熱愛健身和籃球,樂於分享技術相關的所見所得,關注公眾號 @ 飛天小牛肉,第一時間獲取文章更新,成長的路上我們一起進步
? 本文已收錄於 「CS-Wiki」Gitee 官方推薦專案,現已累計 1.5k+ star,致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習
? 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 400+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中
字串操作毫無疑問是計算機程式設計中最常見的行為之一,在 Java 大展拳腳的 Web 系統中更是如此。
全文脈絡思維導圖如下:
1. 三劍客之首:不可變的 String
概述
Java 沒有內建的字串型別, 而是在標準 Java 類庫中提供了一個預定義類 String
。每個用雙引號括起來的字串都是 String
類的一個例項:
String e = ""; // 空串
String str = "hello";
看一下 String
的原始碼,在 Java 8 中,String
內部是使用 char
陣列來儲存資料的。
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
}
可以看到,
String
類是被final
修飾的,因此String
類不允許被繼承。
而在 Java 9
之後,String
類的實現改用 byte
陣列儲存字串,同時使用 coder
來標識使用了哪種編碼。
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final byte[] value;
/** The identifier of the encoding used to encode the bytes in {@code value}. */
private final byte coder;
}
不過,無論是 Java 8 還是 Java 9,用來儲存資料的 char 或者 byte 陣列 value
都一直是被宣告為 final
的,這意味著 value
陣列初始化之後就不能再引用其它陣列了。並且 String
內部沒有改變 value
陣列的方法,因此我們就說 String
是不可變的。
所謂不可變,就如同數字 3 永遠是數字 3 —樣,字串 “hello” 永遠包含字元 h、e、1、1 和 o 的程式碼單元序列, 不能修改其中的任何一個字元。當然, 可以修改字串變數 str, 讓它引用另外一個字串, 這就如同可以將存放 3 的數值變數改成存放 4 一樣。
我們看個例子:
String str = "asdf";
String x = str.toUpperCase();
toUpperCase
用來將字串全部轉為大寫字元,進入 toUpperCase
的原始碼我們發現,這個看起來會修改 String
值的方法,實際上最後是建立了一個全新的 String
物件,而最初的 String
物件則絲毫未動。
空串與 Null
空串 ""
很好理解,就是長度為 0 的字串。可以呼叫以下程式碼檢查一個字串是否為空:
if(str.length() == 0){
// todo
}
或者
if(str.equals("")){
// todo
}
空串是一個 Java 物件, 有自己的串長度( 0 ) 和內容(空),也就是 value
陣列為空。
String
變數還可以存放一個特殊的值, 名為 null
,這表示目前沒有任何物件與該變數關聯。要檢查一個字串是否為 null
,可如下判斷:
if(str == null){
// todo
}
有時要檢查一個字串既不是 null
也不為空串,這種情況下就需要使用以下條件:
if(str != null && str.length() != 0){
// todo
}
有同學就會覺得,這麼簡單的條件判斷還用你說?沒錯,這雖然簡單,但仍然有個小坑,就是我們必須首先檢查 str 是否為 null
,因為如果在一個 null
值上呼叫方法,編譯器會報錯。
字串拼接
上面既然說到 String
是不可變的,我們來看段程式碼,為什麼這裡的字串 a 卻發生了改變?
String a = "hello";
String b = "world";
a = a + b; // a = "helloworld"
實際上,在使用 +
進行字串拼接的時候,JVM 是初始化了一個 StringBuilder
來進行拼接的。相當於編譯後的程式碼如下:
String a = "hello";
String b = "world";
StringBuilder builder = new StringBuilder();
builder.append(a);
builder.append(b);
a = builder.toString();
關於 StringBuilder
下文會詳細講解,大家現在只需要知道 StringBuilder
是可變的字串型別就 OK 了。我們看下 builder.toString()
的原始碼:
顯然,toString
方法同樣是生成了一個新的 String
物件,而不是在舊字串的內容上做更改,相當於把舊字串的引用指向的新的String
物件。這也就是字串 a
發生變化的原因。
另外,我們還需要了解一個特性,當將一個字串與一個非字串的值進行拼接時,後者被自動轉換成字串(任何一個 Java 物件都可以轉換成字串)。例如:
int age = 13;
String rating = "PG" + age; // rating = "PG13"
這種特性通常用在輸出語句中。例如:
int a = 12;
System.out.println("a = " + a);
結合上面這兩特性,我們來看個小問題,空串和 null
拼接的結果是啥?
String str = null;
str = str + "";
System.out.println(str);
答案是 null
大家應該都能猜出來,但為什麼是 null
呢?上文說過,使用 +
進行拼接實際上是會轉換為 StringBuilder
使用 append
方法進行拼接,編譯後的程式碼如下:
String str = null;
str = str + "";
StringBuilder builder = new StringBuilder();
builder.append(str);
builder.append("");
str = builder.toString();
看下 append
的原始碼:
可以看出,當傳入的字串是 null
時,會呼叫 appendNull
方法,而這個方法會返回 null
。
檢測字串是否相等
可以使用 equals
方法檢測兩個字串是否相等。比如:
String str = "hello";
System.out.println("hello".equals(str)); // true
equals
其實是 Object
類中的一個方法,所有的類都繼承於 Object
類。講解 equals
方法之前,我們先來回顧一下運算子 ==
的用法,它存在兩種使用情況:
- 對於基本資料型別來說,
==
比較的是值是否相同; - 對於引用資料型別來說,
==
比較的是記憶體地址是否相同。
舉個例子:
String str1 = new String("hello");
String str2 = new String("hello");
System.out.println(str1 == str2); // false
對 Java 中資料儲存區域仍然不明白的可以先回去看看第一章《萬物皆物件》。對於上述程式碼,str1 和 str2 採用建構函式 new String()
的方式新建了兩個不同字串,以 String str1 = new String("hello");
為例,new 出來的物件存放在堆記憶體中,用一個引用 str1 來指向這個物件的地址,而這個物件的引用 str1 存放在棧記憶體中。str1 和 str2 是兩個不同的物件,地址不同,因此 ==
比較的結果也就為 false
。
而實際上,Object
類中的原始 equals
方法內部呼叫的還是運算子 ==
,判斷的是兩個物件是否具有相同的引用(地址),和 ==
的效果是一樣的:
也就是說,如果你新建的類沒有覆蓋 equals
方法,那麼這個方法比較的就是物件的地址。而 String
方法覆蓋了 equals
方法,我們來看下原始碼:
可以看出,String
重寫的 equals
方法比較的是物件的內容,而非地址。
總結下 equals()
的兩種使用情況:
- 情況 1:類沒有覆蓋
equals()
方法。則通過equals()
比較該類的兩個物件時,等價於通過==
比較這兩個物件(比較的是地址)。 - 情況 2:類覆蓋了
equals()
方法。一般來說,我們都覆蓋equals()
方法來判斷兩個物件的內容是否相等,比如String
類就是這樣做的。當然,你也可以不這樣做。
舉個例子:
String a = new String("ab"); // a 為一個字串引用
String b = new String("ab"); // b 為另一個字串引用,這倆物件的內容一樣
if (a.equals(b)) // true
System.out.println("aEQb");
if (a == b) // false,不是同一個物件,地址不同
System.out.println("a==b");
字串常量池
字串 String
既然作為 Java
中的一個類,那麼它和其他的物件分配一樣,需要耗費高昂的時間與空間代價,作為最基礎最常用的資料型別,大量頻繁的建立字串,將會極大程度的影響程式的效能。為此,JVM 為了提高效能和減少記憶體開銷,在例項化字串常量的時候進行了一些優化:
- 為字串開闢了一個字串常量池 String Pool,可以理解為快取區
- 建立字串常量時,首先檢查字串常量池中是否存在該字串
- 若字串常量池中存在該字串,則直接返回該引用例項,無需重新例項化;若不存在,則例項化該字串並放入池中。
舉個例子:
String str1 = "hello";
String str2 = "hello";
System.out.printl("str1 == str2" : str1 == str2 ) //true
對於上面這段程式碼,String str1 = "hello";
, 編譯器首先會在棧中建立一個變數名為 str1
的引用,然後在字串常量池中查詢有沒有值為 "hello" 的引用,如果沒找到,就在字串常量池中開闢一個地址存放 "hello" 這個字串,然後將引用 str1
指向 "hello"。
需要注意的是,字串常量池的位置在 JDK 1.7 有所變化:
- JDK 1.7 之前,字串常量池存在於常量儲存(Constant storage)中
- JDK 1.7 之後,字串常量池存在於堆記憶體(Heap)中。
另外,我們還可以使用 String
的 intern()
方法在執行過程中手動的將字串新增到 String Pool 中。具體過程是這樣的:
當一個字串呼叫 intern()
方法時,如果 String Pool 中已經存在一個字串和該字串的值相等,那麼就會返回 String Pool 中字串的引用;否則,就會在 String Pool 中新增一個新的字串,並返回這個新字串的引用。
看下面這個例子:
String str1 = new String("hello");
String str3 = str1.intern();
String str4 = str1.intern();
System.out.println(str3 == str4); // true
對於 str3 來說,str1.intern()
會先在 String Pool 中檢視是否已經存在一個字串和 str1 的值相等,沒有,於是,在 String Pool 中新增了一個新的值和 str1 相等的字串,並返回這個新字串的引用。
而對於 str4 來說,str1.intern()
在 String Pool 中找到了一個字串和 str1 的值相等,於是直接返回這個字串的引用。因此 s3 和 s4 引用的是同一個字串,也就是說它們的地址相同,所以 str3 == str4
的結果是 true
。
? 總結:
-
String str = "i"
的方式,java 虛擬機器會自動將其分配到常量池中; -
String str = new String(“i”)
則會被分到堆記憶體中。可通過 intern 方法手動加入常量池
new String("hello") 建立了幾個字串物件
下面這行程式碼到底建立了幾個字串物件?僅僅只在堆中建立了一個?
String str1 = new String("hello");
顯然不是。對於 str1 來說,new String("hello")
分兩步走:
- 首先,"hello" 屬於字串字面量,因此編譯時期會在 String Pool 中查詢有沒有值為 "hello" 的引用,如果沒找到,就在字串常量池中開闢地址空間建立一個字串物件,指向這個 "hello" 字串字面量;
- 然後,使用
new
的方式又會在堆中建立一個字串物件。
因此,使用這種方式一共會建立兩個字串物件(前提是 String Pool 中還沒有 "hello" 字串物件)。
2. 雙生子:可變的 StringBuffer 和 StringBuilder
String 字串拼接問題
有些時候, 需要由較短的字串構建字串, 例如, 按鍵或來自檔案中的單詞。採用字串拼接的方式達到此目的效率比較低。由於 String 類的物件內容不可改變,所以每當進行字串拼接時,總是會在記憶體中建立一個新的物件。既耗時, 又浪費空間。例如:
String s = "Hello";
s += "World";
這段簡單的程式碼其實總共產生了三個字串,即 "Hello"
、"World"
和 "HelloWorld"
。"Hello" 和 "World" 作為字串常量會在 String Pool 中被建立,而拼接操作 +
會 new 一個物件用來存放 "HelloWorld"。
使用 StringBuilder/ StringBuffer
類就可以避免這個問題的發生,畢竟 String 的 +
操作底層都是由 StringBuilder
實現的。StringBuilder
和 StringBuffer
擁有相同的父類:
但是,StringBuilder
不是執行緒安全的,在多執行緒環境下使用會出現資料不一致的問題,而 StringBuffer
是執行緒安全的。這是因為在 StringBuffer
類內,常用的方法都使用了synchronized
關鍵字進行同步,所以是執行緒安全的。而 StringBuilder
並沒有。這也是執行速度 StringBuilder
大於 StringBuffer
的原因了。因此,如果在單執行緒下,優先考慮使用 StringBuilder
。
初始化操作
StringBuilder
和 StringBuffer
這兩個類的 API 是相同的,這裡就以 StringBuilder
為例演示其初始化操作。
StringBuiler/StringBuffer
不能像 String
那樣直接用字串賦值,所以也不能那樣初始化。它需要通過構造方法來初始化。首先, 構建一個空的字串構建器:
StringBuilder builder = new StringBuilder();
當每次需要新增一部分內容時, 就呼叫 append
方法:
char ch = 'a';
builder.append(ch);
String str = "ert"
builder.append(str);
在需要構建字串 String
時呼叫 toString
方法, 就能得到一個 String
物件:
String mystr = builder.toString(); // aert
3. String、StringBuffer、StringBuilder 比較
可變性 | 執行緒安全 | |
---|---|---|
String | 不可變 | 因為不可變,所以是執行緒安全的 |
StringBuffer | 可變 | 執行緒安全的,因為其內部大多數方法都使用 synchronized 進行同步。其效率較低 |
StringBuilder | 可變 | 不是執行緒安全的,因為沒有使用 synchronized 進行同步,這也是其效率高於 StringBuffer 的原因。單執行緒下,優先考慮使用 StringBuilder。 |
關於 synchronized
保證執行緒安全的問題,我們後續文章再說。
? References
- 《Java 核心技術 - 卷 1 基礎知識 - 第 10 版》
- 《Thinking In Java(Java 程式設計思想)- 第 4 版》
? 關注公眾號 | 飛天小牛肉,即時獲取更新
- 博主東南大學碩士在讀,利用課餘時間運營一個公眾號『 飛天小牛肉 』,2020/12/29 日開通,專注分享計算機基礎(資料結構 + 演算法 + 計算機網路 + 資料庫 + 作業系統 + Linux)、Java 基礎和麵試指南的相關原創技術好文。本公眾號的目的就是讓大家可以快速掌握重點知識,有的放矢。希望大家多多支援哦,和小牛肉一起成長 ?
- 並推薦個人維護的開源教程類專案: CS-Wiki(Gitee 推薦專案,現已累計 1.5k+ star), 致力打造完善的後端知識體系,在技術的路上少走彎路,歡迎各位小夥伴前來交流學習 ~ ?
- 如果各位小夥伴春招秋招沒有拿得出手的專案的話,可以參考我寫的一個專案「開源社群系統 Echo」Gitee 官方推薦專案,目前已累計 400+ star,基於 SpringBoot + MyBatis + MySQL + Redis + Kafka + Elasticsearch + Spring Security + ... 並提供詳細的開發文件和配套教程。公眾號後臺回覆 Echo 可以獲取配套教程,目前尚在更新中。