String源碼淺析


如果問你,開發過程中用的最多的類是哪個?你可能回答是HashMap,一個原因就是HashMap的使用量的確很多,還有就是HashMap的內容在面試中經常被問起。

但是在開發過程中使用最多的類其實並不是HashMap類,而是“默默無聞”的String類。假如現在問你String類是怎么實現的?這個類為什么是不可變類?這個類為什么不能被繼承?這些問題你都能回答么。本文就從String源代碼出發,來看下String到底是怎么實現的,並詳細介紹下String類的API的用法。

String源碼結構

首先要說明的是本文的源碼是以JDK11為基准,選擇JDK11的原因是JDK11是一個LTS版本(長期支持版本),沒選擇現階段還在廣泛使用的JDK8的原因是想在看源碼的過程中學習下JDK的新特性。

還有要說下的就是:大家在看源碼時一定要注意JDK的版本,因為不同版本的實現有較大的差異。比如說String的實現在高低版本中就差異比較大。如果你是一個博客主,更加要注明代碼的版本了,不然讀者可能會很疑惑,為什么和自己之前看的不一樣。

好了,下面就言歸正傳來看下String在JDK11中的實現代碼。

 public final class String implements Serializable, Comparable<String>, CharSequence {
   @Stable
   //字節數組,存放String的內容,如果你看的是較低版本的源代碼,這個變量可能是char[]類型,這個其實是JDK9開始對String做的一個優化
   //具體是做了什么優化我們下面再講,這邊先賣個關子
   private final byte[] value;
   //也是和String壓縮優化有關,指定當前的LATIN1碼還是UTF16碼
   private final byte coder;
   //哈希值
   private int hash;
   //序列化Id
   private static final long serialVersionUID = -6849794470754667710L;
   //優化壓縮開關,默認開啟
   static final boolean COMPACT_STRINGS = true;
   private static final ObjectStreamField[] serialPersistentFields = new ObjectStreamField[0];
   public static final Comparator<String> CASE_INSENSITIVE_ORDER = new String.CaseInsensitiveComparator();
   static final byte LATIN1 = 0;
   static final byte UTF16 = 1;
   
   //... 下面部分代碼省略
 }

從實現的接口看,String類有如下特點:

  • String類被final關鍵字修飾,因此不能被繼承。
  • String的成員變量value使用final修飾,因此是不可變的,線程安全;
  • String類實現了Serializable接口,可以實現序列化。
  • String類實現了Comparable,可以比較大小。
  • String類實現了CharSequence接口,String本質是個數組,低版本中是char數組,JDK9以后優化成byte數組,從String的成員變量value就可以看出來。

這邊說一個看源代碼的小技巧:看一個類的源代碼時,我們先看下這個類實現了哪些接口,就可以大概知道這個類的主要作用功能是什么了。

JDK9對String的優化

這邊首先要講下JDK 9中對String的優化,如果你不了解這塊優化點的話,看String的代碼時會感到非常疑惑。

背景知識

在Java中,一個字節char占用兩個字節的內存空間。在低版本的JDK中,String的內部默認維護的是一個char[]數組,也就是說一個字符串中包含一個字符,這個字符串內部就包含一個相應長度的字符數組。這樣就會出現下面這種情況:

 String s = "ddd";
 String s1 = "自由之路";

上面兩個字符串內部的情況實際上是:

 char[] value = ['d','d','d'];
 char[] value1 = ['自','由','之','路'];

對於字符串s,我們發現其中每個字符其實都是可以用一個字節表示的,而現在使用兩個字符的char類型來表示,明顯就浪費了一倍的內存空間。

而且根據統計,在實際程序運行中,字符串中包含的字符大多都是可以用一個字節表示的字符,所以優化的空間很大。優化的方式就是在String內部使用byte[]數組來表示字符串,而不是使用char[]數組。當檢測到,字符串中的所有字符在Unicode碼集中的碼值可以使用一個字節表示時,就可以節省一半的空間。

時間換空間的方式

JDK6 中的Compressed Strings

其實在JDK6中就對String類做過類似的優化:在Java 6引入了Compressed Strings,對於one byte per character的字符串使用byte[],對於two bytes per character的字符串繼續使用char[]。

使用-XX:+UseCompressedStrings來開啟上面的優化。不過由於開啟這個特性后會造成一些不可知的異常,這個特性在java7中被廢棄了,然后在java8被移除。

JDK9中的Compact String

Java 9 重新采納字符串壓縮這一概念。

和JDK6不同的是:無論何時我們創建一個所有字符都能用一個字節的 LATIN-1 編碼來描述的字符串,都將在內部使用字節數組的形式存儲,且每個字符都只占用一個字節。另一方面,如果字符串中任一字符需要多於 8 比特位來表示時,該字符串的所有字符都統統使用兩個字節的 UTF-16 編碼來描述。因此基本上能如果可能,都將使用單字節來表示一個字符。

 //占用3個字節
 String ss = new String("ddd");
 //占用14個字節
 String s = "自由之路ddd";

現在的問題是:所有的字符串操作如何執行? 怎樣才能區分字符串是由 LATIN-1 還是 UTF-16 來編碼?為了處理這些問題,字符串的內部實現進行了一些調整。引入了一個 final 修飾的成員變量 coder, 由它來保存當前字符串的編碼信息。

 //所有的字符串都用byte數組存儲
 private final byte[] value; 
 //用coder標示字符串中所有的字符是不是都可以用一個字節表示,它的值只有兩個LATIN1:1,標示所有字符都可以用一個字節表示,UTF16:標示字符串中部分字符需要兩個字節表示。
 private final byte coder;
 //下面是兩個常量
 static final byte LATIN1 = 0;
 static final byte UTF16 = 1;

現在,大多數的字符串操作都將檢查 coder 變量,從而采取特定的實現:

 public int indexOf(int ch, int fromIndex) {
   return isLatin1() 
    ? StringLatin1.indexOf(value, ch, fromIndex) 
    : StringUTF16.indexOf(value, ch, fromIndex);
 } 
 
 private boolean isLatin1() {
   return COMPACT_STRINGS && coder == LATIN1;
 } 

我們再看下String的一個常用方法:

 public int length() {
   return value.length >> coder;
 }

這個方法是要計算字符串的長度,含義也很清楚。根據coder字段判斷當前的字符串中一個字符使用幾個字節表示,如果是coder等於0,也是LATIN1模式,那么所有字符都是用一個字節表示,直接返回byte[]數組的長度就可以。

如果coder等於1,那么標示字符串中所有字符都是用兩個字節表示的,計算字符串的長度需要將byte[]數組除以2。value.length >> coder就是這個意思。

因為對String做了上面的優化,所以String的很多方法在操作時都需要判斷現在的模式是LATIN1還是UTF16模式,具體的方法這邊就不一一舉例了。但是這些判斷對使用String的開發者時無感的。

當然,String的這個優化特性可以關閉,使用下面的啟動參數就可以。

 +XX:-CompactStrings

String的常用構造方法

 //構建空字符串
 public String() {
  this.value = "".value;
  this.coder = "".coder;
 }

//根據已有的字符串,創建一個新的字符串
 @HotSpotIntrinsicCandidate
 public String(String original) {
  this.value = original.value;
  this.coder = original.coder;
  this.hash = original.hash;
 }

//根據字符數組,創建字符串,創建的過程中有壓縮優化的邏輯,具體見下面的方法
 public String(char[] value) {
  this((char[])value, 0, value.length, (Void)null);
 }

String(char[] value, int off, int len, Void sig) {
  if (len == 0) {
   this.value = "".value;
   this.coder = "".coder;
  } else {
   if (COMPACT_STRINGS) {
    //如果發現這個字符數組可以壓縮,就使用LATIN1方式
    byte[] val = StringUTF16.compress(value, off, len);
    if (val != null) {
     this.value = val;
     this.coder = 0;
     return;
    }
   }
   //不能進行壓縮優化,還是使用UTF16的方式
   this.coder = 1;
   this.value = StringUTF16.toBytes(value, off, len);
  }
 }

String中還有很多構造方法,但是都會大同小異,大家可以自己看源代碼。

String常用方法總結

這邊總結下String的常用方法,一些比較簡單的方法就不具體講了。我們挑選一些比較重要的方法,具體講下他們的使用方法。

  • codePointAt(int index):返回下標是index的字符在Unicode碼集中的碼點值;
  • codePoints():返回字符串中每個字符在Unicode碼集中的碼點值;
  • compareToIgnoreCase(String other):忽略大小寫比較字符大小;
  • concat(String other):字符串拼接函數;
  • equalsIgnoreCase(String other):忽略大小寫比較字符串;
  • format:字符串格式化函數,比較有用;
  • getBytes(String charSet):獲取字符串在特定編碼下的字節數組;
  • indexOf(String s):返回字符串s的下標,不存在返回-1;
  • intren():作用是檢測常量池中是否有當前字符串,有的話就返回常量池中的對像,沒有的話就將當前對像放入常量池。
  • isBlank():如果字符串為空或只包含空白字符,則返回true,否則返回false,JDK11新加的API;
  • length():返回字符長度;
  • lines():從字符串返回按行分割的Stream,行分割福包括:n ,r 和rn,stream包含了按順序分割的行,行分隔符被移除了,這個方法會類似split(),但性能更好;這個也是JDK11新加的API
  • matchs(String regex):和某個正則是否匹配;
  • regionMatches(int firstStart, String other, int otherStart, int len):當某個字符串調用該方法時,表示從當前字符串的firstStart位置開始,取一個長度為len的子串;然后從另一個字符串other的otherStart位置開始也取一個長度為len的子串,然后比較這兩個子串是否相同,如果這兩個子串相同則返回true,否則返回false。
  • repeat():返回一個字符串,其內容是字符串重復n次后的結果,JDK11新加入的函數;
  • String[] split(String regex, int limit):分割字符串,注意limit參數的使用,下面會詳細講;
  • startsWith(String prefix, int toffset):判斷字符串是否以prefix打頭;
  • replace(char oldChar, char newChar):使用newChar替換所有的oldChar,不是基於正則表達式的;
  • replace(CharSequence target, CharSequence replacement):替換所有,基於正則表達式的;
  • replaceFirst(String regex, String replacement):替換regex匹配的第一個字符串,基於正則表達式;
  • replaceAll(String regex, String replacement):替換regex匹配的所有字符串,基於正則表達式;
  • strip() :去除字符串前后的“全角和半角”空白字符,這個函數在JDK中11才引入,注意和trim的區別,關於全角和半角的區別,可以參考這篇文章,還提供了stripLeading()和stripTrailing(),可以分別去掉頭部或尾部的空格;
  • subString(int fromIndex):從指定位置開始截取到字符串結尾部分的子串;
  • subString(int fromIndex,int endIndex):截取字符串指定下標的子串;
  • toCharArray():轉換成字符數組;
  • toUpperCase(Locale locale) :小寫轉換成大寫;
  • toLowerCase(Locale locale):大寫轉換成小寫;
  • trim():去除字符串前后的空白字符(空格、tab鍵、換行符等,具體的話是去除ascll碼小於32的字符),注意trim和strip的區別;
  • valueof系列方法:將其他類型的數據轉換成String類型,比如將bool、int和long等類型轉換成String類型。

concat字符串拼接函數

concat函數是字符串拼接函數,介紹這個函數並不是因為這個函數比較重要或者實現比較復雜。而是因為通過這個函數的源代碼我們可以看出很多String的特性。

 public String concat(String str) {
  //如果被拼接的字符串的長度是0,直接返回自己
  int olen = str.length();
  if (olen == 0) {
   return this;
  } else {
   byte[] buf;
   //如果當前字符串和被拼接的字符串的編碼模式相同,都是LATIN1或者都是UTF16
   if (this.coder() == str.coder()) {
    byte[] val = this.value;
    buf = str.value;
    //計算出新字符串所需字節的長度
    int len = val.length + buf.length;
    byte[] buf = Arrays.copyOf(val, len);
    //使用系統函數拷貝
    System.arraycopy(buf, 0, buf, val.length, buf.length);
    //根據新的字節數組生成一個新的字符串
    return new String(buf, this.coder);
   } else {
    //當前字符串和被拼接的字符串的編碼模式不同,那么必須使用UTF16的編碼模式
    int len = this.length();
    buf = StringUTF16.newBytesFor(len + olen);
    this.getBytes(buf, 0, (byte)1);
    str.getBytes(buf, len, (byte)1);
    return new String(buf, (byte)1);
   }
  }
 }

format函數

String的format方法是一個很有用的方法,可以用來對字符串、數字、日期和時間等進行格式化。

//對整數格式化,4位顯示,不足4位補0
//超過4位,還是原樣顯示
int num = 999;
String str = String.format("%04d", num);
System.out.println(str);

//對日期進行格式化
String format = String.format("%tF", new Date());
System.out.println(format);

format方法還有很多用法,大家可以自己查詢使用。

regionMatches

該方法的定義如下:

regionMatches(int firstStart, String other, int otherStart, int len)

當某個字符串調用該方法時,表示從當前字符串的firstStart位置開始,取一個長度為len的子串;然后從另一個字符串other的otherStart位置開始也取一個長度為len的子串,然后比較這兩個子串是否相同,如果這兩個子串相同則返回true,否則返回false。

該方法還有另一種重載:

str.regionMatches(boolean ignoreCase, int firstStart, String other, int otherStart, int len)

可以看到只是多了一個boolean類型的參數,用來確定比較時是否忽略大小寫,當ignoreCase為true表示忽略大小寫。

split函數

String的split函數我們平時也經常使用,但是估計很多人都沒有注意這個函數的第二個參數:limit

public String[] split(String regex, int limit)

首先,split方法的作用是根據給定的regex去分割字符串,將分割完成的字符數組返回。其中limit參數的作用是:

  • 當limit>0時,limit代表最后的數組長度,同時一共會分割limit-1次,最后沒有切割完成的直接放在一起;

  • 當limit=0時(默認值),會盡量多去分割,並且如果分割完的字符數組末尾是空字符串,會去除這個空字符串;

  • 當limit<0時,會盡量多去分割,但不會去掉末尾的空字符串。

下面舉個列子:

String s1 = "博客園|CSDN||";

String[] split1 = s1.split("\\|", 2);
System.out.println("split1 length:" + split1.length);
System.out.println("split1 content:" + Arrays.toString(split1));
String[] split2 = s1.split("\\|", 0);
System.out.println("split2 length:" + split2.length);
System.out.println("split2 content:" + Arrays.toString(split2));
String[] split3 = s1.split("\\|", -1);
System.out.println("split3 length:" + split3.length);
System.out.println("split3 content:" + Arrays.toString(split3));

System.out.println("---換一個復雜點的字符串---");
s1 = "|博客園||CSDN|自由之路ddd|";

split1 = s1.split("\\|", 2);
System.out.println("split1 length:" + split1.length);
System.out.println("split1 content:" + Arrays.toString(split1));
split2 = s1.split("\\|", 0);
System.out.println("split2 length:" + split2.length);
System.out.println("split2 content:" + Arrays.toString(split2));
split3 = s1.split("\\|", -1);
System.out.println("split3 length:" + split3.length);
System.out.println("split3 content:" + Arrays.toString(split3));

下面是輸出結果,對照着這個結果大家就應該能明白split方法的使用了

split1 length:2
split1 content:[博客園, CSDN|自由之路ddd|]
split2 length:3
split2 content:[博客園, CSDN, 自由之路ddd]
split3 length:4
split3 content:[博客園, CSDN, 自由之路ddd, ]
---換一個復雜點的字符串---
split1 length:2
split1 content:[, 博客園||CSDN|自由之路ddd|]
split2 length:5
split2 content:[, 博客園, , CSDN, 自由之路ddd]
split3 length:6
split3 content:[, 博客園, , CSDN, 自由之路ddd, ]

再舉個JDK中的列子:

The input "boo:and:foo", for example, yields the following results with these parameters:

Regex     Limit     Result    
: 2 { "boo", "and:foo" }
: 5 { "boo", "and", "foo" }
: -2 { "boo", "and", "foo" }
o 5 { "b", "", ":and:f", "", "" }
o -2 { "b", "", ":and:f", "", "" }
o 0 { "b", "", ":and:f" }

總結

  • String類被final關鍵字修飾,因此不能被繼承;
  • String的成員變量value使用final修飾,因此是不可變的,線程安全;
  • String中的方法對字符串的操作都會生成一個新的String對象,如果你需要一個可修改的字符串,應該使用 StringBuffer 或者 StringBuilder。否則會有大量時間浪費在垃圾回收上,因為每次試圖修改都有新的string對象被創建出來;
  • JDK9開始對String進行了優化,內部徹底使用byte[]數組來代替char數組。

參考

公眾號推薦

歡迎大家關注我的微信公眾號「程序員自由之路」


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM