String 類也是java.lang 包下的一個類,算是日常編碼中最常用的一個類了,那麼本篇部落格就來詳細的介紹 String 類。

1、String 類的定義

public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {}

  和上一篇部落格所講的 Integer 類一樣,這也是一個用 final 宣告的常量類,不能被任何類所繼承,而且一旦一個String物件被建立, 包含在這個物件中的字元序列是不可改變的, 包括該類後續的所有方法都是不能修改該物件的,直至該物件被銷燬,這是我們需要特別注意的(該類的一些方法看似改變了字串,其實內部都是建立一個新的字串,下面講解方法時會介紹)。接著實現了 Serializable介面,這是一個序列化標誌介面,還實現了 Comparable 介面,用於比較兩個字串的大小(按順序比較單個字元的ASCII碼),後面會有具體方法實現;最後實現了 CharSequence 介面,表示是一個有序字元的集合,相應的方法後面也會介紹。

2、欄位屬性

 /**用來儲存字串  */
private final char value[]; /** 快取字串的雜湊碼 */
private int hash; // Default to 0 /** 實現序列化的標識 */
private static final long serialVersionUID = -6849794470754667710L;

  一個 String 字串實際上是一個 char 陣列。

3、構造方法

  String 類的構造方法很多。可以通過初始化一個字串,或者字元陣列,或者位元組陣列等等來建立一個 String 物件。

  

 String str1 = "abc";//注意這種字面量宣告的區別,文末會詳細介紹
String str2 = new String("abc");
String str3 = new String(new char[]{'a','b','c'});

4、equals(Object anObject) 方法

     public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof String) {
String anotherString = (String)anObject;
int n = value.length;
if (n == anotherString.value.length) {
char v1[] = value;
char v2[] = anotherString.value;
int i = 0;
while (n-- != 0) {
if (v1[i] != v2[i])
return false;
i++;
}
return true;
}
}
return false;
}

  String 類重寫了 equals 方法,比較的是組成字串的每一個字元是否相同,如果都相同則返回true,否則返回false。

5、hashCode() 方法

     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;
}

  String 類的 hashCode 演算法很簡單,主要就是中間的 for 迴圈,計算公式如下:

s[]*^(n-) + s[]*^(n-) + ... + s[n-]

  s 陣列即原始碼中的 val 陣列,也就是構成字串的字元陣列。這裡有個數字 31 ,為什麼選擇31作為乘積因子,而且沒有用一個常量來宣告?主要原因有兩個:

  ①、31是一個不大不小的質數,是作為 hashCode 乘子的優選質數之一。

  ②、31可以被 JVM 優化,31 * i = (i << 5) - i。因為移位運算比乘法執行更快更省效能。

  具體解釋可以參考這篇文章

6、charAt(int index) 方法

     public char charAt(int index) {
//如果傳入的索引大於字串的長度或者小於0,直接丟擲索引越界異常
if ((index < 0) || (index >= value.length)) {
throw new StringIndexOutOfBoundsException(index);
}
return value[index];//返回指定索引的單個字元
}

  我們知道一個字串是由一個字元陣列組成,這個方法是通過傳入的索引(陣列下標),返回指定索引的單個字元。

7、compareTo(String anotherString) 和 compareToIgnoreCase(String str) 方法

  我們先看看 compareTo 方法:

     public int compareTo(String anotherString) {
int len1 = value.length;
int len2 = anotherString.value.length;
int lim = Math.min(len1, len2);
char v1[] = value;
char v2[] = anotherString.value; int k = 0;
while (k < lim) {
char c1 = v1[k];
char c2 = v2[k];
if (c1 != c2) {
return c1 - c2;
}
k++;
}
return len1 - len2;
}

  原始碼也很好理解,該方法是按字母順序比較兩個字串,是基於字串中每個字元的 Unicode 值。當兩個字串某個位置的字元不同時,返回的是這一位置的字元 Unicode 值之差,當兩個字串都相同時,返回兩個字串長度之差。

  compareToIgnoreCase() 方法在 compareTo 方法的基礎上忽略大小寫,我們知道大寫字母是比小寫字母的Unicode值小32的,底層實現是先都轉換成大寫比較,然後都轉換成小寫進行比較。

8、concat(String str) 方法

  該方法是將指定的字串連線到此字串的末尾。

     public String concat(String str) {
int otherLen = str.length();
if (otherLen == 0) {
return this;
}
int len = value.length;
char buf[] = Arrays.copyOf(value, len + otherLen);
str.getChars(buf, len);
return new String(buf, true);
}

  首先判斷要拼接的字串長度是否為0,如果為0,則直接返回原字串。如果不為0,則通過 Arrays 工具類(後面會詳細介紹這個工具類)的copyOf方法建立一個新的字元陣列,長度為原字串和要拼接的字串之和,前面填充原字串,後面為空。接著在通過 getChars 方法將要拼接的字串放入新字串後面為空的位置。

  注意:返回值是 new String(buf, true),也就是重新通過 new 關鍵字建立了一個新的字串,原字串是不變的。這也是前面我們說的一旦一個String物件被建立, 包含在這個物件中的字元序列是不可改變的。

9、indexOf(int ch) 和 indexOf(int ch, int fromIndex) 方法

  indexOf(int ch),引數 ch 其實是字元的 Unicode 值,這裡也可以放單個字元(預設轉成int),作用是返回指定字元第一次出現的此字串中的索引。其內部是呼叫 indexOf(int ch, int fromIndex),只不過這裡的 fromIndex =0 ,因為是從 0 開始搜尋;而 indexOf(int ch, int fromIndex) 作用也是返回首次出現的此字串內的索引,但是從指定索引處開始搜尋。

    public int indexOf(int ch) {
return indexOf(ch, 0);//從第一個字元開始搜尋
}
 public int indexOf(int ch, int fromIndex) {
final int max = value.length;//max等於字元的長度
if (fromIndex < 0) {//指定索引的位置如果小於0,預設從 0 開始搜尋
fromIndex = 0;
} else if (fromIndex >= max) {
//如果指定索引值大於等於字元的長度(因為是陣列,下標最多隻能是max-1),直接返回-1
return -1;
} if (ch < Character.MIN_SUPPLEMENTARY_CODE_POINT) {//一個char佔用兩個位元組,如果ch小於2的16次方(65536),絕大多數字符都在此範圍內
final char[] value = this.value;
for (int i = fromIndex; i < max; i++) {//for迴圈依次判斷字串每個字元是否和指定字元相等
if (value[i] == ch) {
return i;//存在相等的字元,返回第一次出現該字元的索引位置,並終止迴圈
}
}
return -1;//不存在相等的字元,則返回 -1
} else {//當字元大於 65536時,處理的少數情況,該方法會首先判斷是否是有效字元,然後依次進行比較
return indexOfSupplementary(ch, fromIndex);
}
}

10、split(String regex) 和 split(String regex, int limit) 方法

  split(String regex) 將該字串拆分為給定正則表示式的匹配。split(String regex , int limit) 也是一樣,不過對於 limit 的取值有三種情況:

  ①、limit > 0 ,則pattern(模式)應用n - 1 次

 String str = "a,b,c";
String[] c1 = str.split(",", 2);
System.out.println(c1.length);//
System.out.println(Arrays.toString(c1));//{"a","b,c"}

  ②、limit = 0 ,則pattern(模式)應用無限次並且省略末尾的空字串

 String str2 = "a,b,c,,";
String[] c2 = str2.split(",", 0);
System.out.println(c2.length);//
System.out.println(Arrays.toString(c2));//{"a","b","c"}

  ③、limit < 0 ,則pattern(模式)應用無限次

 String str2 = "a,b,c,,";
String[] c2 = str2.split(",", -1);
System.out.println(c2.length);//
System.out.println(Arrays.toString(c2));//{"a","b","c","",""}

  下面我們看看底層的原始碼實現。對於 split(String regex) 沒什麼好說的,內部呼叫  split(regex, 0) 方法:

     public String[] split(String regex) {
return split(regex, 0);
}

  重點看 split(String regex, int limit) 的方法實現:

 public String[] split(String regex, int limit) {
/* 1、單個字元,且不是".$|()[{^?*+\\"其中一個
* 2、兩個字元,第一個是"\",第二個大小寫字母或者數字
*/
char ch = 0;
if (((regex.value.length == 1 &&
".$|()[{^?*+\\".indexOf(ch = regex.charAt(0)) == -1) ||
(regex.length() == 2 &&
regex.charAt(0) == '\\' &&
(((ch = regex.charAt(1))-'0')|('9'-ch)) < 0 &&
((ch-'a')|('z'-ch)) < 0 &&
((ch-'A')|('Z'-ch)) < 0)) &&
(ch < Character.MIN_HIGH_SURROGATE ||
ch > Character.MAX_LOW_SURROGATE))
{
int off = 0;
int next = 0;
boolean limited = limit > 0;//大於0,limited==true,反之limited==false
ArrayList<String> list = new ArrayList<>();
while ((next = indexOf(ch, off)) != -1) {
//當引數limit<=0 或者 集合list的長度小於 limit-1
if (!limited || list.size() < limit - 1) {
list.add(substring(off, next));
off = next + 1;
} else {//判斷最後一個list.size() == limit - 1
list.add(substring(off, value.length));
off = value.length;
break;
}
}
//如果沒有一個能匹配的,返回一個新的字串,內容和原來的一樣
if (off == 0)
return new String[]{this}; // 當 limit<=0 時,limited==false,或者集合的長度 小於 limit是,擷取新增剩下的字串
if (!limited || list.size() < limit)
list.add(substring(off, value.length)); // 當 limit == 0 時,如果末尾新增的元素為空(長度為0),則集合長度不斷減1,直到末尾不為空
int resultSize = list.size();
if (limit == 0) {
while (resultSize > 0 && list.get(resultSize - 1).length() == 0) {
resultSize--;
}
}
String[] result = new String[resultSize];
return list.subList(0, resultSize).toArray(result);
}
return Pattern.compile(regex).split(this, limit);
}

11、replace(char oldChar, char newChar) 和 String replaceAll(String regex, String replacement) 方法

  ①、replace(char oldChar, char newChar) :將原字串中所有的oldChar字元都替換成newChar字元,返回一個新的字串。

  ②、String replaceAll(String regex, String replacement):將匹配正則表示式regex的匹配項都替換成replacement字串,返回一個新的字串。

12、substring(int beginIndex) 和 substring(int beginIndex, int endIndex) 方法

  ①、substring(int beginIndex):返回一個從索引 beginIndex 開始一直到結尾的子字串。

public String substring(int beginIndex) {
if (beginIndex < 0) {//如果索引小於0,直接丟擲異常
throw new StringIndexOutOfBoundsException(beginIndex);
}
int subLen = value.length - beginIndex;//subLen等於字串長度減去索引
if (subLen < 0) {//如果subLen小於0,也是直接丟擲異常
throw new StringIndexOutOfBoundsException(subLen);
}
//1、如果索引值beginIdex == 0,直接返回原字串
//2、如果不等於0,則返回從beginIndex開始,一直到結尾
return (beginIndex == 0) ? this : new String(value, beginIndex, subLen);
}

  ②、 substring(int beginIndex, int endIndex):返回一個從索引 beginIndex 開始,到 endIndex 結尾的子字串。

13、常量池

  在前面講解建構函式的時候,我們知道最常見的兩種宣告一個字串物件的形式有兩種:

  ①、通過“字面量”的形式直接賦值

String str = "hello";

  ②、通過 new 關鍵字呼叫建構函式建立物件

String str = new String("hello");

  那麼這兩種宣告方式有什麼區別呢?在講解之前,我們先介紹 JDK1.7(不包括1.7)以前的 JVM 的記憶體分佈:

  

  ①、程式計數器:也稱為 PC 暫存器,儲存的是程式當前執行的指令的地址(也可以說儲存下一條指令的所在儲存單元的地址),當CPU需要執行指令時,需要從程式計數器中得到當前需要執行的指令所在儲存單元的地址,然後根據得到的地址獲取到指令,在得到指令之後,程式計數器便自動加1或者根據轉移指標得到下一條指令的地址,如此迴圈,直至執行完所有的指令。執行緒私有

  ②、虛擬機器棧:基本資料型別、物件的引用都存放在這。執行緒私有

  ③、本地方法棧:虛擬機器棧是為執行Java方法服務的,而本地方法棧則是為執行本地方法(Native Method)服務的。在JVM規範中,並沒有對本地方法棧的具體實現方法以及資料結構作強制規定,虛擬機器可以自由實現它。在HotSopt虛擬機器中直接就把本地方法棧和虛擬機器棧合二為一。

  ④、方法區:儲存了每個類的資訊(包括類的名稱、方法資訊、欄位資訊)、靜態變數、常量以及編譯器編譯後的程式碼等。注意:在Class檔案中除了類的欄位、方法、介面等描述資訊外,還有一項資訊是常量池,用來儲存編譯期間生成的字面量和符號引用。

  ⑤、堆:用來儲存物件本身的以及陣列(當然,陣列引用是存放在Java棧中的)。

  在 JDK1.7 以後,方法區的常量池被移除放到堆中了,如下:

  

  常量池:Java執行時會維護一個String Pool(String池), 也叫“字串緩衝區”。String池用來存放執行時中產生的各種字串,並且池中的字串的內容不重複。

  ①、字面量建立字串或者純字串(常量)拼接字串會先在字串池中找,看是否有相等的物件,沒有的話就在字串池建立該物件;有的話則直接用池中的引用,避免重複建立物件。

  ②、new關鍵字建立時,直接在堆中建立一個新物件,變數所引用的都是這個新物件的地址,但是如果通過new關鍵字建立的字串內容在常量池中存在了,那麼會由堆在指向常量池的對應字元;但是反過來,如果通過new關鍵字建立的字串物件在常量池中沒有,那麼通過new關鍵詞建立的字串物件是不會額外在常量池中維護的。

  ③、使用包含變量表達式來建立String物件,則不僅會檢查維護字串池,還會在堆區建立這個物件,最後是指向堆記憶體的物件。

 String str1 = "hello";
String str2 = "hello";
String str3 = new String("hello");
System.out.println(str1==str2);//true
System.out.println(str1==str3);//fasle
System.out.println(str2==str3);//fasle
System.out.println(str1.equals(str2));//true
System.out.println(str1.equals(str3));//true
System.out.println(str2.equals(str3));//true

  對於上面的情況,首先 String str1 = "hello",會先到常量池中檢查是否有“hello”的存在,發現是沒有的,於是在常量池中建立“hello”物件,並將常量池中的引用賦值給str1;第二個字面量 String str2 = "hello",在常量池中檢測到該物件了,直接將引用賦值給str2;第三個是通過new關鍵字建立的物件,常量池中有了該物件了,不用在常量池中建立,然後在堆中建立該物件後,將堆中物件的引用賦值給str3,再將該物件指向常量池。如下圖所示:

  

  注意:看上圖紅色的箭頭,通過 new 關鍵字建立的字串物件,如果常量池中存在了,會將堆中建立的物件指向常量池的引用。我們可以通過文章末尾介紹的intern()方法來驗證。

  使用包含變量表達式建立物件:

 String str1 = "hello";
String str2 = "helloworld";
String str3 = str1+"world";//編譯器不能確定為常量(會在堆區建立一個String物件)
String str4 = "hello"+"world";//編譯器確定為常量,直接到常量池中引用 System.out.println(str2==str3);//fasle
System.out.println(str2==str4);//true
System.out.println(str3==str4);//fasle

  str3 由於含有變數str1,編譯器不能確定是常量,會在堆區中建立一個String物件。而str4是兩個常量相加,直接引用常量池中的物件即可。

14、intern() 方法

  這是一個本地方法:

public native String intern();

  當呼叫intern方法時,如果池中已經包含一個與該String確定的字串相同equals(Object)的字串,則返回該字串。否則,將此String物件新增到池中,並返回此物件的引用。

  這句話什麼意思呢?就是說呼叫一個String物件的intern()方法,如果常量池中有該物件了,直接返回該字串的引用(存在堆中就返回堆中,存在池中就返回池中),如果沒有,則將該物件新增到池中,並返回池中的引用。

 String str1 = "hello";//字面量 只會在常量池中建立物件
String str2 = str1.intern();
System.out.println(str1==str2);//true String str3 = new String("world");//new 關鍵字只會在堆中建立物件
String str4 = str3.intern();
System.out.println(str3 == str4);//false String str5 = str1 + str2;//變數拼接的字串,會在常量池中和堆中都建立物件
String str6 = str5.intern();//這裡由於池中已經有物件了,直接返回的是物件本身,也就是堆中的物件
System.out.println(str5 == str6);//true String str7 = "hello1" + "world1";//常量拼接的字串,只會在常量池中建立物件
String str8 = str7.intern();
System.out.println(str7 == str8);//true

15、String 真的不可變嗎?

  前面我們介紹了,String 類是用 final 關鍵字修飾的,所以我們認為其是不可變物件。但是真的不可變嗎?

  每個字串都是由許多單個字元組成的,我們知道其原始碼是由 char[] value 字元陣列構成。

 public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[]; /** Cache the hash code for the string */
private int hash; // Default to 0

  value 被 final 修飾,只能保證引用不被改變,但是 value 所指向的堆中的陣列,才是真實的資料,只要能夠操作堆中的陣列,依舊能改變資料。而且 value 是基本型別構成,那麼一定是可變的,即使被宣告為 private,我們也可以通過反射來改變。

 String str = "vae";
//列印原字串
System.out.println(str);//vae
//獲取String類中的value欄位
Field fieldStr = String.class.getDeclaredField("value");
//因為value是private宣告的,這裡修改其訪問許可權
fieldStr.setAccessible(true);
//獲取str物件上的value屬性的值
char[] value = (char[]) fieldStr.get(str);
//將第一個字元修改為 V(小寫改大寫)
value[0] = 'V';
//列印修改之後的字串
System.out.println(str);//Vae

  通過前後兩次列印的結果,我們可以看到 String 被改變了,但是在程式碼裡,幾乎不會使用反射的機制去操作 String 字串,所以,我們會認為 String 型別是不可變的。

  那麼,String 類為什麼要這樣設計成不可變呢?我們可以從效能以及安全方面來考慮:

  • 安全

    • 引發安全問題,譬如,資料庫的使用者名稱、密碼都是以字串的形式傳入來獲得資料庫的連線,或者在socket程式設計中,主機名和埠都是以字串的形式傳入。因為字串是不可變的,所以它的值是不可改變的,否則黑客們可以鑽到空子,改變字串指向的物件的值,造成安全漏洞。

    • 保證執行緒安全,在併發場景下,多個執行緒同時讀寫資源時,會引競態條件,由於 String 是不可變的,不會引發執行緒的問題而保證了執行緒。

    • HashCode,當 String 被創建出來的時候,hashcode也會隨之被快取,hashcode的計算與value有關,若 String 可變,那麼 hashcode 也會隨之變化,針對於 Map、Set 等容器,他們的鍵值需要保證唯一性和一致性,因此,String 的不可變性使其比其他物件更適合當容器的鍵值。

  • 效能

    • 當字串是不可變時,字串常量池才有意義。字串常量池的出現,可以減少建立相同字面量的字串,讓不同的引用指向池中同一個字串,為執行時節約很多的堆記憶體。若字串可變,字串常量池失去意義,基於常量池的String.intern()方法也失效,每次建立新的 String 將在堆內開闢出新的空間,佔據更多的記憶體。

參考文件:

https://docs.oracle.com/javase/8/docs/api/java/lang/String.html

https://segmentfault.com/a/1190000009914328