java基礎:String — 原始碼分析(一)

Hiway發表於2018-12-16

其他更多java基礎文章:
java基礎學習(目錄)


距離上次寫文章已經好一段時間了,主要是工作忙起來,看書的時間就少了,看String的進度就斷斷續續,在讀原始碼的過程中,我搜了幾篇很有學習價值的文章,放在下面,可以在閱讀完本文之後閱讀一下,有些地方我可能講的不夠清楚,下面文章裡的大神講的更仔細。

學習資料:
String類API中文
深入解析String#intern
Java 中new String("字面量") 中 "字面量" 是何時進入字串常量池的?
new一個String物件的時候,如果常量池沒有相應的字面量真的會去它那裡建立一個嗎?我表示懷疑。

String的方法

String的底層是由char陣列構成的

private final char value[];

複製程式碼

由於底層char陣列是final的,所以String物件是不可變的。

String的構造方法

我們先講一下主要的幾種構造方法: 1. 引數為String型別

public String(String original) {
    this.value = original.value;
    this.hash = original.hash;
}

複製程式碼

這裡將直接將源 String 中的 value 和 hash 兩個屬性直接賦值給目標 String。因為 String 一旦定義之後是不可以改變的,所以也就不用擔心改變源 String 的值會影響到目標 String 的值。

2. 引數為字元陣列

public String(char value[]) {
    this.value = Arrays.copyOf(value, value.length);
}
public String(char value[], int offset, int count)

複製程式碼

這裡值得注意的是:當我們使用字元陣列建立 String 的時候,會用到 Arrays.copyOf 方法或 Arrays.copyOfRange 方法。這兩個方法是將原有的字元陣列中的內容逐一的複製到 String 中的字元陣列中。會建立一個新的字串物件,隨後修改的字元陣列不影響新建立的字串。

3.引數為位元組陣列
在 Java 中,String 例項中儲存有一個 char[] 字元陣列,char[] 字元陣列是以 unicode 碼來儲存的,String 和 char 為記憶體形式。

byte 是網路傳輸或儲存的序列化形式,所以在很多傳輸和儲存的過程中需要將 byte[] 陣列和 String 進行相互轉化。所以 String 提供了一系列過載的構造方法來將一個字元陣列轉化成 String,提到 byte[] 和 String 之間的相互轉換就不得不關注編碼問題。

String(byte[] bytes, Charset charset)

複製程式碼

該構造方法是指通過 charset 來解碼指定的 byte 陣列,將其解碼成 unicode 的 char[] 陣列,構造成新的 String。

這裡的 bytes 位元組流是使用 charset 進行編碼的,想要將他轉換成 unicode 的 char[] 陣列,而又保證不出現亂碼,那就要指定其解碼方式

同樣的,使用位元組陣列來構造 String 也有很多種形式,按照是否指定解碼方式分的話可以分為兩種:

public String(byte bytes[]){
  this(bytes, 0, bytes.length);
}
public String(byte bytes[], int offset, int length){
    checkBounds(bytes, offset, length);
    this.value = StringCoding.decode(bytes, offset, length);
}

複製程式碼

如果我們在使用 byte[] 構造 String 的時候,使用的是下面這四種構造方法(帶有 charsetName 或者 charset 引數)的一種的話,那麼就會使用 StringCoding.decode 方法進行解碼,使用的解碼的字符集就是我們指定的 charsetName 或者 charset。

String(byte bytes[])
String(byte bytes[], int offset, int length)
String(byte bytes[], Charset charset)
String(byte bytes[], String charsetName)
String(byte bytes[], int offset, int length, Charset charset)
String(byte bytes[], int offset, int length, String charsetName)

複製程式碼

我們在使用 byte[] 構造 String 的時候,如果沒有指明解碼使用的字符集的話,那麼 StringCoding 的 decode 方法首先呼叫系統的預設編碼格式,如果沒有指定編碼格式則預設使用 ISO-8859-1 編碼格式進行編碼操作。主要體現程式碼如下:

static char[] decode(byte[] ba, int off, int len){
    String csn = Charset.defaultCharset().name();
    try{ //use char set name decode() variant which provide scaching.
         return decode(csn, ba, off, len);
    } catch(UnsupportedEncodingException x){
        warnUnsupportedCharset(csn);
    }

    try{
       return decode("ISO-8859-1", ba, off, len);  } 
    catch(UnsupportedEncodingException x){
       //If this code is hit during VM initiali zation, MessageUtils is the only way we will be able to get any kind of error message.
       MessageUtils.err("ISO-8859-1 char set not available: " + x.toString());
       // If we can not find ISO-8859-1 (are quired encoding) then things are seriously wrong with the installation.
       System.exit(1);
       return null;
    }
}

複製程式碼

4.引數為StringBuilder或StringBuffer

public String(StringBuffer buffer) {
        synchronized(buffer) {
            this.value = Arrays.copyOf(buffer.getValue(), buffer.length());
        }
    }

public String(StringBuilder builder) {
        this.value = Arrays.copyOf(builder.getValue(), builder.length());
    }

複製程式碼

基本不用,用StringBuffer.toString方法。

4. 特殊的protected構造方法

    String(char[] value, boolean share) {
        // assert share : "unshared not supported";
        this.value = value;
    }

複製程式碼

從程式碼中我們可以看出,該方法和 String(char[] value) 有兩點區別:

  • 第一個區別:該方法多了一個引數:boolean share,其實這個引數在方法體中根本沒被使用。註釋說目前不支援 false,只使用 true。那可以斷定,加入這個 share 的只是為了區分於 String(char[] value) 方法,不加這個引數就沒辦法定義這個函式,只有引數是不同才能進行過載。

  • 第二個區別:具體的方法實現不同。我們前面提到過 String(char[] value) 方法在建立 String 的時候會用到 Arrays 的 copyOf 方法將 value 中的內容逐一複製到 String 當中,而這個 String(char[] value, boolean share) 方法則是直接將 value 的引用賦值給 String 的 value。那麼也就是說,這個方法構造出來的 String 和引數傳過來的 char[] value 共享同一個陣列。

為什麼 Java 會提供這樣一個方法呢?

  • 效能好:這個很簡單,一個是直接給陣列賦值(相當於直接將 String 的 value 的指標指向char[]陣列),一個是逐一拷貝,當然是直接賦值快了。

  • 節約記憶體:該方法之所以設定為 protected,是因為一旦該方法設定為公有,在外面可以訪問的話,如果構造方法沒有對 arr 進行拷貝,那麼其他人就可以在字串外部修改該陣列,由於它們引用的是同一個陣列,因此對 arr 的修改就相當於修改了字串,那就破壞了字串的不可變性。

  • 安全的:對於呼叫他的方法來說,由於無論是原字串還是新字串,其 value 陣列本身都是 String 物件的私有屬性,從外部是無法訪問的,因此對兩個字串來說都很安全。

在 Java 7 之前有很多 String 裡面的方法都使用上面說的那種“效能好的、節約記憶體的、安全”的建構函式。 比如:substringreplaceconcatvalueOf等方法,實際上它們使用的是 public String(char[], ture) 方法來實現。

但是在 Java 7 中,substring 已經不再使用這種“優秀”的方法了

public String substring(int beginIndex, int endIndex){
  if(beginIndex < 0){
    throw new StringIndexOutOfBoundsException(beginIndex);
  }
  if(endIndex > value.length){
    throw new StringIndexOutOfBoundsException(endIndex);
  }
  intsubLen = endIndex-beginIndex;
  if(subLen < 0){
    throw new StringIndexOutOfBoundsException(subLen);
  }
  return ((beginIndex == 0) && (endIndex == value.length)) ? this  : newString(value, beginIndex, subLen);
}

複製程式碼

為什麼呢? 雖然這種方法有很多優點,但是他有一個致命的缺點,對於 sun 公司的程式設計師來說是一個零容忍的 bug,那就是他很有可能造成記憶體洩露。

看一個例子,假設一個方法從某個地方(檔案、資料庫或網路)取得了一個很長的字串,然後對其進行解析並提取其中的一小段內容,這種情況經常發生在網頁抓取或進行日誌分析的時候。

下面是示例程式碼:

String aLongString = "...averylongstring...";
String aPart = data.substring(20, 40);
return aPart;

複製程式碼

在這裡 aLongString 只是臨時的,真正有用的是 aPart,其長度只有 20 個字元,但是它的內部陣列卻是從 aLongString 那裡共享的,因此雖然 aLongString 本身可以被回收,但它的內部陣列卻不能釋放。這就導致了記憶體洩漏。如果一個程式中這種情況經常發生有可能會導致嚴重的後果,如記憶體溢位,或效能下降。

新的實現雖然損失了效能,而且浪費了一些儲存空間,但卻保證了字串的內部陣列可以和字串物件一起被回收,從而防止發生記憶體洩漏,因此新的 substring 比原來的更健壯。

其他方法

length() 返回字串長度
isEmpty() 返回字串是否為空
charAt(int index) 返回字串中第(index+1)個字元(陣列索引)
char[] toCharArray() 轉化成字元陣列
trim()去掉兩端空格
toUpperCase()轉化為大寫
toLowerCase()轉化為小寫
boolean matches(String regex) 判斷字串是否匹配給定的regex正規表示式
boolean contains(CharSequence s) 判斷字串是否包含字元序列 s
String[] split(String regex, int limit) 按照字元 regex將字串分成 limit 份
String[] split(String regex) 按照字元 regex 將字串分段

複製程式碼

詳細可檢視String類API中文翻譯

需要注意

String concat(String str) 拼接字串
String replace(char oldChar, char newChar) 將字串中的
oldChar 字元換成 newChar 字元

複製程式碼

以上兩個方法都使用了 String(char[] value, boolean share) concat 方法和 replace 方法,他們不會導致元陣列中有大量空間不被使用,因為他們一個是拼接字串,一個是替換字串內容,不會將字元陣列的長度變得很短,所以使用了共享的 char[] 字元陣列來優化。

getBytes

在建立 String 的時候,可以使用 byte[] 陣列,將一個位元組陣列轉換成字串,同樣,我們可以將一個字串轉換成位元組陣列,那麼 String 提供了很多過載的 getBytes 方法。

public byte[] getBytes(){
  return StringCoding.encode(value, 0, value.length);
}

複製程式碼

但是,值得注意的是,在使用這些方法的時候一定要注意編碼問題。比如: String s = "你好,世界!"; byte[] bytes = s.getBytes(); 這段程式碼在不同的平臺上執行得到結果是不一樣的。由於沒有指定編碼方式,所以在該方法對字串進行編碼的時候就會使用系統的預設編碼方式。

在中文作業系統中可能會使用 GBK 或者 GB2312 進行編碼,在英文作業系統中有可能使用 iso-8859-1 進行編碼。這樣寫出來的程式碼就和機器環境有很強的關聯性了,為了避免不必要的麻煩,要指定編碼方式。

public byte[] getBytes(String charsetName) throws UnsupportedEncodingException{
  if (charsetName == null) throw new NullPointerException();
  return StringCoding.encode(charsetName, value, 0, value.length);
}

複製程式碼

比較方法

boolean equals(Object anObject); 比較物件
boolean contentEquals(String Buffersb); 與字串比較內容
boolean contentEquals(Char Sequencecs); 與字元比較內容
boolean equalsIgnoreCase(String anotherString);忽略大小寫比較字串物件
int compareTo(String anotherString); 比較字串
int compareToIgnoreCase(String str); 忽略大小寫比較字串
boolean regionMatches(int toffset, String other, int ooffset, int len)區域性匹配
boolean regionMatches(boolean ignoreCase, int toffset, String other, int ooffset, int len) 可忽略大小寫區域性匹配

複製程式碼

字串有一系列方法用於比較兩個字串的關係。 前四個返回 boolean 的方法很容易理解,前三個比較就是比較 String 和要比較的目標物件的字元陣列的內容,一樣就返回 true, 不一樣就返回false,核心程式碼如下:

int n = value.length; 
while (n-- ! = 0) {
  if (v1[i] != v2[i])
    return false;
    i++;
}

複製程式碼

v1 v2 分別代表 String 的字元陣列和目標物件的字元陣列。 第四個和前三個唯一的區別就是他會將兩個字元陣列的內容都使用 toUpperCase 方法轉換成大寫再進行比較,以此來忽略大小寫進行比較。相同則返回 true,不想同則返回 false

equals方法:

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

複製程式碼

通過原始碼的程式碼,我們可以瞭解它比較的流程:字串相同:地址相同;地址不同,但是內容相同 這是一種提高效率的方法,也就是將比較快速的部分(地址,比較物件型別)放在前面比較,速度慢的部分(比較字元陣列)放在後面執行。

StringBuffer 需要考慮執行緒安全問題,加鎖之後再呼叫

contentEquals()方法

public boolean contentEquals(CharSequence cs) {
        // Argument is a StringBuffer, StringBuilder
        if (cs instanceof AbstractStringBuilder) {
            if (cs instanceof StringBuffer) {
                synchronized(cs) {
                   return nonSyncContentEquals((AbstractStringBuilder)cs);
                }
            } else {
                return nonSyncContentEquals((AbstractStringBuilder)cs);
            }
        }
        // Argument is a String
        if (cs instanceof String) {
            return equals(cs);
        }
        // Argument is a generic CharSequence
        char v1[] = value;
        int n = v1.length;
        if (n != cs.length()) {
            return false;
        }
        for (int i = 0; i < n; i++) {
            if (v1[i] != cs.charAt(i)) {
                return false;
            }
        }
        return true;
    }

複製程式碼

public boolean contentEquals(StringBuffer sb);實際呼叫了contentEquals(CharSequence cs)方法; AbstractStringBuilder和String都是介面CharSequence的實現,通過判斷輸入是AbstractStringBuilder還是String的例項,執行不同的方法;

下面這個是 equalsIgnoreCase 程式碼的實現:

 public boolean equalsIgnoreCase(String anotherString) {
 return (this == anotherString) ? true : (anotherString != null) && (anotherString.value.length == value.length) && regionMatches(true, 0, anotherString, 0, value.length);
 }

複製程式碼

看到這段程式碼,眼前為之一亮。使用一個三目運算子和 && 操作代替了多個 if 語句。

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

複製程式碼

hashCode 的實現其實就是使用數學公式:

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

為什麼要使用這個公式,就是在儲存資料計算 hash 地址的時候,我們希望儘量減少有同樣的 hash 地址。如果使用相同 hash 地址的資料過多,那麼這些資料所組成的 hash 鏈就更長,從而降低了查詢效率。 所以在選擇係數的時候要選擇儘量長的係數並且讓乘法儘量不要溢位的係數,因為如果計算出來的 hash 地址越大,所謂的“衝突”就越少,查詢起來效率也會提高。

選擇31作為因子的原因: 為什麼 String hashCode 方法選擇數字31作為乘子

substring
前面我們介紹過,java 7 中的 substring 方法使用 String(value, beginIndex, subLen) 方法建立一個新的 String 並返回,這個方法會將原來的 char[] 中的值逐一複製到新的 String 中,兩個陣列並不是共享的,雖然這樣做損失一些效能,但是有效地避免了記憶體洩露。

replaceFirst、replaceAll、replace區別

String replaceFirst(String regex, String replacement)
String replaceAll(String regex, String replacement)
String replace(Char Sequencetarget, Char Sequencereplacement)

public String replace(char oldChar, char newChar){
  if(oldChar != newChar){
    int len = value.length;
    int i = -1;
    char[] val = value; /*avoid get field opcode*/
    while (++i < len){
      if (val[i] == oldChar){
        break;
      }
    }
    if( i < len ){
      char buf[] = new char[len];
      for (intj=0; j<i; j++){
        buf[j] = val[j];
      }
      while (i < len){
        char c = val[i];
        buf[i] = (c == oldChar) ? newChar : c;
        i++;
      }
      return new String(buf,true);
    }
   }
  return this;
}

複製程式碼

replace 的引數可以是 char 或者 CharSequence,即可以支援字元的替換, 也支援字串的替換。當引數為CharSequence時,實際呼叫的是replaceAll方法,所以replace方法是全部替換。 replaceAll 和 replaceFirst 的引數是 regex,即基於規則表示式的替換。區別是一個全部替換,一個只替換第一個。

intern()方法

public native String intern(); 

複製程式碼

intern方法是Native呼叫,它的作用是在方法區中的常量池裡尋找等值的物件,如果沒有找到則在常量池中存放當前字串的引用並返回該引用,否則直接返回常量池中已存在的String物件引用。

這個方法將會在下一章專門講

相關文章