Java String 面面觀

本文主要介紹Java中與字符串相關的一些內容,主要包括String類的實現及其不變性、String相關類(StringBuilderStringBuffer)的實現 以及 字符串緩存機制的用法與實現。html

String類的設計與實現

String類的核心邏輯是經過對char型數組進行封裝來實現字符串對象,但實現細節伴隨着Java版本的演進也發生過幾回變化。java

Java 6

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];
    /** The offset is the first index of the storage that is used. */
    private final int offset;
    /** The count is the number of characters in the String. */
    private final int count;
    /** Cache the hash code for the string */
    private int hash; // Default to 0
}
複製代碼

Java 6中,String類有四個成員變量:char型數組value、偏移量 offset、字符數量 count、哈希值 hashvalue數組用來存儲字符序列, offsetcount 兩個屬性用來定位字符串在value數組中的位置,hash屬性用來緩存字符串的hashCodegit

使用offsetcount來定位value數組的目的是,能夠高效、快速地共享value數組,例如substring()方法返回的子字符串是經過記錄offsetcount來實現與原字符串共享value數組的,而不是從新拷貝一份。substring()方法實現以下:github

String(int offset, int count, char value[]) {
	this.value = value;    // 直接複用原數組
	this.offset = offset;
	this.count = count;
}
public String substring(int beginIndex, int endIndex) {
    // ...... 省略一些邊界檢查的代碼 ......
    return ((beginIndex == 0) && (endIndex == count)) ? this :
        new String(offset + beginIndex, endIndex - beginIndex, value);
}
複製代碼

可是這種方式卻頗有可能會致使內存泄漏。例如在以下代碼中:面試

String bigStr = new String(new char[100000]);
String subStr = bigStr.substring(0,2);
bigStr = null;
複製代碼

bigStr被設置爲null以後,其中的value數組卻仍然被subStr所引用,致使垃圾回收器沒法將其回收,結果雖然咱們實際上僅僅須要2個字符的空間,可是實際卻佔用了100000個字符的空間。數組

Java 6中,若是想要避免這種內存泄漏狀況的發生,可使用下面的方式:緩存

String subStr = bigStr.substring(0,2) + "";
// 或者
String subStr = new String(bigStr.substring(0,2));
複製代碼

在語句執行完以後,substring方法返回的匿名String對象因爲沒有被別的對象引用,因此可以被垃圾回收器回收,不會繼續引用bigStr中的value數組,從而避免了內存泄漏。安全

Java 7 & Java 8

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
}
複製代碼

Java 7-Java 8中,JavaString 類作了一些改變。String 類中再也不有 offsetcount 兩個成員變量了。substring()方法也再也不共享 value數組,而是從指定位置從新拷貝一份value數組,從而解決了使用該方法可能致使的內存泄漏問題。substring()方法實現以下:markdown

public String(char value[], int offset, int count) {
    // ...... 省略一些邊界檢查的代碼 ......

    // 從原數組拷貝
    this.value = Arrays.copyOfRange(value, offset, offset+count);   
}
public String substring(int beginIndex, int endIndex) {
    // ...... 省略一些邊界檢查的代碼 ......
    return ((beginIndex == 0) && (endIndex == value.length)) ? this
            : new String(value, beginIndex, subLen);
}
複製代碼

Java 9

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;
    /** Cache the hash code for the string */
    private int hash; // Default to 0
}
複製代碼

爲了節省內存空間,Java 9中對String的實現方式作了優化,value成員變量從char[]類型改成了byte[]類型,同時新增了一個coder成員變量。咱們知道Javachar類型佔用的是兩個字節,對於只佔用一個字節的字符(例如,a-zA-Z)就顯得有點浪費,因此Java 9中將char[]改成byte[]來存儲字符序列,而新屬性 coder 的做用就是用來表示value數組中存儲的是雙字節編碼的字符仍是單字節編碼的字符。coder 屬性能夠有 01 兩個值,0 表明 Latin-1(單字節編碼),1 表明 UTF-16(雙字節編碼)。在建立字符串的時候若是判斷全部字符均可以用單字節來編碼,則使用Latin-1來編碼以壓縮空間,不然使用UTF-16編碼。主要的構造函數實現以下:網絡

String(char[] value, int off, int len, Void sig) {
    if (len == 0) {
        this.value = "".value;
        this.coder = "".coder;
        return;
    }
    if (COMPACT_STRINGS) {
        byte[] val = StringUTF16.compress(value, off, len);  // 嘗試壓縮字符串,使用單字節編碼存儲
        if (val != null) {   // 壓縮成功,可使用單字節編碼存儲
            this.value = val;
            this.coder = LATIN1;
            return;
        }
    }
    // 不然,使用雙字節編碼存儲
    this.coder = UTF16;
    this.value = StringUTF16.toBytes(value, off, len);
}
複製代碼

String類的不變性

咱們注意到String類是用final修飾的;全部的屬性都是聲明爲private的;而且除了hash屬性以外的其餘屬性也都是用final修飾。這保證了:

  1. String類由final修飾,因此沒法經過繼承String類改變其語義;
  2. 全部的屬性都是聲明爲private的, 因此沒法在String外部直接訪問或修改其屬性;
  3. 除了hash屬性以外的其餘屬性都是用final修飾,表示這些屬性在初始化賦值後不能夠再修改。

上述的定義共同實現了String類一個重要的特性 —— 不變性,即 String 對象一旦建立成功,就不能再對它進行任何修改。String提供的方法substring()concat()replace()等方法返回值都是新建立的String對象,而不是原來的String對象。

hash屬性不是final的緣由是:StringhashCode並不須要在建立字符串時當即計算並賦值,而是在hashCode()方法被調用時才須要進行計算。

爲何String類要設計爲不可變的?

  1. 保證 String 對象的安全性。String被普遍用做JDK中做爲參數、返回值,例如網絡鏈接,打開文件,類加載,等等。若是 String 對象是可變的,那麼 String 對象將可能被惡意修改,引起安全問題。
  2. 線程安全。String類的不可變性自然地保證了其線程安全的特性。
  3. 保證了String對象的hashCode的不變性。String類的不可變性,保證了其hashCode值可以在第一次計算後進行緩存,以後無需重複計算。這使得String對象很適合用做HashMap等容器的Key,而且相比其餘對象效率更高。
  4. 實現字符串常量池Java爲字符串對象設計了字符串常量池來共享字符串,節省內存空間。若是字符串是可變的,那麼字符串對象便沒法共享。由於若是改變了其中一個對象的值,那麼其餘對象的值也會相應發生變化。

與String類相關的類

除了String類以外,還有兩個與String類相關的的類:StringBufferStringBuilder,這兩個類能夠看做是String類的可變版本,提供了對字符串修改的各類方法。二者的區別在於StringBuffer是線程安全的而StringBuilder不是線程安全的。

StringBuffer / StringBuilder的實現

StringBufferStringBuilder都是繼承自AbstractStringBuilderAbstractStringBuilder利用可變的char數組(Java 9以後改成爲byte數組)來實現對字符串的各類修改操做。StringBufferStringBuilder都是調用AbstractStringBuilder中的方法來操做字符串, 二者區別在於StringBuffer類中對字符串修改的方法都加了synchronized修飾,而StringBuilder沒有,因此StringBuffer是線程安全的,而StringBuilder並不是線程安全的。

咱們以Java 8爲例,看一下AbstractStringBuilder類的實現:

abstract class AbstractStringBuilder implements Appendable, CharSequence {
    /** The value is used for character storage. */
    char[] value;
    /** The count is the number of characters used. */
    int count;
}
複製代碼

value數組用來存儲字符序列,count則用來存儲value數組中已經使用的字符數量,字符串真實的內容是value數組中[0,count)之間的字符序列,而[count,length)之間是未使用的空間。須要count屬性記錄已使用空間的緣由是,AbstractStringBuilder中的value數組並非每次修改都會從新申請,而是會提早預分配必定的多餘空間,以此來減小從新分配數組空間的次數。(這種作法相似於ArrayList的實現)。

value數組擴容的策略是:當對字符串進行修改時,若是當前的value數組不知足空間需求時,則會從新分配更大的value數組,分配的數組大小爲min( 原數組大小×2 + 2 , 所需的數組大小 ),更加細節的邏輯能夠參考以下代碼:

private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

private int newCapacity(int minCapacity) {
    // overflow-conscious code
    int newCapacity = (value.length << 1) + 2;    //原數組大小×2 + 2 
    if (newCapacity - minCapacity < 0) {     // 若是小於所需空間大小,擴展至所需空間大小
        newCapacity = minCapacity;
    }
    return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
        ? hugeCapacity(minCapacity)
        : newCapacity;
}

private int hugeCapacity(int minCapacity) {
    if (Integer.MAX_VALUE - minCapacity < 0) { // overflow
        throw new OutOfMemoryError();
    }
    return (minCapacity > MAX_ARRAY_SIZE)
        ? minCapacity : MAX_ARRAY_SIZE;
}
複製代碼

固然AbstractStringBuilder也提供了trimToSize方法去釋放多餘的空間:

public void trimToSize() {
    if (count < value.length) {
        value = Arrays.copyOf(value, count);
    }
}
複製代碼

String對象的緩存機制

由於String對象的使用普遍,JavaString對象設計了緩存機制,以提高時間和空間上的效率。在JVM的運行時數據區中存在一個字符串常量池String Pool),在這個常量池中維護了全部已經緩存的String對象,當咱們說一個String對象被緩存(interned)了,就是指它進入了字符串常量池

咱們經過解答下面三個問題來理解String對象的緩存機制:

  1. 哪些String對象會被緩存進字符串常量池
  2. String對象被緩存在哪裏,如何組織起來的?
  3. String對象是何時進入字符串常量池的?

說明: 如未特殊指明,本文中說起的JVM實現均指的是OracleHotSpot VM,而且不考慮 逃逸分析(escape analysis)、標量替換(scalar replacement)、無用代碼消除(dead-code elimination)等優化手段,測試代碼基於不添加任何額外JVM參數的狀況下運行。

預備知識

爲了更好的閱讀體驗,在解答上面三個問題前,但願讀者對如下知識點有簡單瞭解:

  • JVM運行時數據區
  • class文件的結構
  • JVM基於棧的字節碼解釋執行引擎
  • 類加載的過程
  • Java中的幾種常量池

爲了內容的完整性,咱們對下文涉及較多的其中兩點作簡要介紹。

類加載的過程

類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期依次爲:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個部分統稱爲鏈接(Linking)。 加載、驗證、準備、初始化和卸載這5個階段的順序是肯定的,類的加載過程必須按照這種順序循序漸進地開始,而解析階段則不必定:它在某些狀況下能夠在初始化階段以後再開始,這是爲了支持Java語言的運行時綁定(也稱爲動態綁定或晚期綁定)。

Java中的幾種常量池

1. class文件中的常量池 咱們知道java後綴的源代碼文件會被javac編譯爲class後綴的class文件(字節碼文件)。在class文件中有一部份內容是 常量池(Constant Pool) ,這個常量池中主要存儲兩大類常量:

  • 代碼中的字面量或者常量表達式的值;
  • 符號引用,包括:類和接口的全限定名、字段的名稱和描述符、方法的名稱和描述符等。

2. 運行時常量池JVM運行時數據區(Run-Time Data Areas)中,有一部分是運行時常量池(Run-Time Constant Pool),屬於方法區的一部分。運行時常量池class文件中每一個類或者接口的常量池(Constant Pool )的運行時表示形式,class文件的常量池中的內容會在類加載後進入方法區的運行時常量池

3. 字符串常量池 字符串常量池String Pool)也就是咱們上文提到的用來緩存String對象的常量池。 這個常量池是全局共享的,屬於運行時數據區的一部分。

哪些String對象會被緩存進字符串常量池?

Java中,有兩種字符串會被緩存到字符串常量池中,一種是在代碼中定義的字符串字面量或者字符串常量表達式,另外一種是程序中主動調用String.intern()方法將當前String對象緩存到字符串常量池中。下面分別對兩種方式作簡要介紹。

1. 隱式緩存 - 字符串字面量 或者 字符串常量表達式

之因此稱之爲隱式緩存是由於咱們並不須要主動去編寫緩存相關代碼,編譯器和JVM會幫咱們完成這部分工做。

字符串字面量 第一種會被隱式緩存的字符串是 字符串字面量字面量 是類型爲原始類型、String類型、null類型的值在源代碼中的表示形式。例如:

int i = 100;   // int 類型字面量
double f = 10.2;  // double 類型字面量
boolean b = true;   // boolean 類型字面量
String s = "hello"; // String類型字面量
Object o = null;  // null類型字面量
複製代碼

字符串字面量是由雙引號括起來的0個或者多個字符構成的。 Java會在執行過程當中爲字符串字面量建立String對象並加入字符串常量池中。例如上面代碼中的"hello"就是一個字符串字面量,在執行過程當中會先 建立一個內容爲"hello"String對象,並緩存到字符串常量池中,再將s引用指向這個String對象。

關於字符串字面量更加詳細的內容請參閱Java語言規範JLS - 3.10.5. String Literals)。

字符串常量表達式 另一種會被隱式緩存的字符串是 字符串常量表達式常量表達式指的是表示簡單類型值或String對象的表達式,能夠簡單理解爲常量表達式就是在編譯期間就能肯定值的表達式。字符串常量表達式就是表示String對象的常量表達式。例如:

int a = 1 + 2;
double d = 10 + 2.01;
boolean b = true & false;
String str1 =  "abc" + 123;

final int num = 456;
String  str2 = "abc" +456;
複製代碼

Java會在執行過程當中爲字符串常量表達式建立String對象並加入字符串常量池中。例如,上面的代碼中,會分別建立"abc123""abc456"兩個String對象,這兩個String對象會被緩存到字符串常量池中,str1會指向常量池中值爲"abc123"String對象,str2會指向常量池中值爲"abc456"String對象。

關於常量表達式更加詳細的內容請參閱Java語言規範JLS - 15.28 Constant Expressions)。

2. 主動緩存 - String.intern()方法

除了聲明爲字符串字面量/字符串常量表達式以外,經過其餘方式獲得的String對象也能夠主動加入字符串常量池中。例如:

String str = new String("123") + new String("456");
str.intern();
複製代碼

在上面的代碼中,在執行完第一句後,常量池中存在內容爲"123""456"的兩個String對象,可是不存在"123456"String對象,但在執行完str.intern();以後,內容爲"123456"String對象也加入到了字符串常量池中。

咱們經過String.intern()方法的註釋來看下其具體的緩存機制:

When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned. It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.

簡單翻譯一下:

當調用 intern 方法時,若是常量池中已經包含相同內容的字符串(字符串內容相同由 equals (Object) 方法肯定,對於 String 對象來講,也就是字符序列相同),則返回常量池中的字符串對象。不然,將此 String 對象將添加到常量池中,並返回此 String 對象的引用。 所以,對於任意兩個字符串 st,當且僅當 s.equals(t)的結果爲true時,s.intern() == t.intern()的結果爲true

String對象被緩存在哪裏,如何組織起來的?

HotSpot VM中,有一個用來記錄緩存的String對象的全局表,叫作StringTable,結構及實現方式都相似於Java中的HashMap或者HashSet,是一個使用拉鍊法解決哈希衝突的哈希表,能夠簡單理解爲HashSet<String>,注意它只存儲對String對象的引用,而不存儲String對象實例。 通常咱們說一個字符串進入了字符串常量池實際上是說在這個StringTable中保存了對它的引用,反之,若是說沒有在其中就是說StringTable中沒有對它的引用。

而真正的字符串對象實際上是保存在另外的區域中的,在Java 6字符串常量池中的String對象是存儲在永久代Java 8以前HotSpot VM方法區的實現)中的,而在Java 6以後,字符串常量池中的String對象是存儲在中的。

Java 7中將字符串常量池中的對象移動到中的緣由是在 Java 6中,字符串常量池中的對象在永久代建立,而永久代代的大小通常不會設置太大,若是大量使用字符串緩存將可能對致使永久代發生OOM異常。

String對象是何時進入字符串常量池的?

對於經過 在程序中調用String.intern()方法主動緩存進入常量池的String對象,很顯然就是在調用intern()方法的時候進入常量池的。

咱們重點來研究一下會被隱式緩存的兩種值(字符串字面量字符串常量表達式),主要是兩個問題:

  1. 咱們並無主動調用String類的構造方法,那麼它們是在什麼時候被建立?
  2. 它們是在什麼時候進入字符串常量池的?

咱們如下面的代碼爲例來分析這兩個問題:

public class Main {
    public static void main(String[] args) {
        String str1 = "123" + 123;     // 字符串常量表達式
        String str2 = "123456";         // 字面量
        String str3 = "123" + 456;   //字符串常量表達式
    }
}
複製代碼

字節碼分析

咱們對上述代碼編譯以後使用javap來觀察一下字節碼文件,爲了節省篇幅,只摘取了相關的部分:常量池表部分以及main方法信息部分:

Constant pool:
  #1 = Methodref          #5.#23         // java/lang/Object."<init>":()V
  #2 = String             #24            // 123123
  #3 = String             #25            // 123456
   // ...... 省略 ......
  #24 = Utf8               123123
  #25 = Utf8               123456
 
 // ...... 省略 ......

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=4, args_size=1
         0: ldc           #2                  // String 123123
         2: astore_1
         3: ldc           #3                  // String 123456
         5: astore_2
         6: ldc           #3                  // String 123456
         8: astore_3
         9: return
      LineNumberTable:
        line 7: 0
        line 8: 3
        line 9: 6
        line 10: 9
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  args   [Ljava/lang/String;
            3       7     1  str1   Ljava/lang/String;
            6       4     2  str2   Ljava/lang/String;
            9       1     3  str3   Ljava/lang/String;
複製代碼

常量池中,有兩種與字符串相關的常量類型,CONSTANT_StringCONSTANT_Utf8CONSTANT_String類型的常量用於表示String類型的常量對象,其內容只是一個常量池的索引值indexindex處的成員必須是CONSTANT_Utf8類型。而CONSTANT_Utf8類型的常量用於存儲真正的字符串內容。 例如,上面的常量池中的第23項是CONSTANT_String類型,存儲的索引分別爲2425,常量池中第2425項就是CONSTANT_Utf8,存儲的值分別爲"123123""123456"

class文件的方法信息中Code屬性是class文件中最爲重要的部分之一,其中包含了執行語句對應的虛擬機指令,異常表,本地變量信息等,其中LocalVariableTable是本地變量的信息,Slot能夠理解爲本地變量表中的索引位置。ldc指令的做用是從運行時常量池中提取指定索引位置的數據並壓入棧中;astore_<n>指令的做用是將一個引用類型的值從棧中彈出並保存到本地變量表的指定位置,也就是<n>指定的位置。能夠看出三條賦值語句所對應的字節碼指令其實都是相同的:

ldc           #<index>   // 首先將常量池中指定索引位置的String對象壓入棧中
astore_<n>   // 而後從棧中彈出剛剛存入的String對象保存到本地變量的指定位置
複製代碼

運行過程分析

仍是圍繞上面的代碼,咱們結合 從編譯到執行的過程 來分析一下字符串字面量字符串常量表達式建立緩存時機。

1. 編譯 首先,第一步是javac將源代碼編譯爲class文件。在源代碼編譯過程當中,咱們上文提到的兩種值 字符串字面量"123456") 和 字符串常量表達式"123" + 456)這兩類值都會存在編譯後的class文件的常量池中,常量類型爲CONSTANT_String。值得注意的兩點是:

  • 字符串常量表達式會在編譯期計算出真實值存在class文件的常量池中。例如上面源代碼中的"123" + 123這個表達式在class文件的常量池中的表現形式是123123"123" + 456這個表達式在class文件的常量池中的表現形式是123456
  • 值相同的字符串字面量或者字符串常量表達式class文件的常量池中只會存在一個常量項(CONSTANT_String類型和CONSTANT_Utf8都只有一項)。例如上面源代碼中,雖然聲明瞭兩個常量值分別爲"123456""123" + 456,可是最後class文件的常量池中只有一個值爲123456CONSTANT_Utf8常量項以及一個對應的CONSTANT_String常量項。

2. 類加載JVM運行時,加載Main類時,JVM會根據 class文件的常量池 建立 運行時常量池class文件的常量池 中的內容會在類加載時進入方法區的 運行時常量池。對於class文件的常量池中的符號引用,會在類加載的解析(resolve)階段,會將其轉化爲真正的值。但在HotSpot中,符號引用的解析並不必定是在類加載時當即執行的,而是推遲到第一次執行相關指令(即引用了符號引用的指令,JLS - 5.4.3. Resolution )時纔會去真正進行解析,這就作延遲解析/惰性解析"lazy" or "late" resolution)。

  • 對於一些基本類型的常量項,例如CONSTANT_Integer_infoCONSTANT_Float_infoCONSTANT_Long_infoCONSTANT_Double_info,在類加載階段會將class文件常量池中的值轉化爲運行時常量池中的值,分別對應C++中的intfloatlongdouble類型;
  • 對於CONSTANT_Utf8類型的常量項,在類加載的解析階段被轉化爲Symbol對象(HotSpot VM層面的一個C++對象)。同時HotSpot使用SymbolTable(結構與StringTable相似)來緩存Symbol對象,因此在類加載完成後,SymbolTable中應該有全部的CONSTANT_Utf8常量對應的Symbol對象;
  • 而對於CONSTANT_String類型的常量項,由於其內容是一個符號引用(指向CONSTANT_Utf8類型常量的索引值),因此須要進行解析,在類加載的解析階段會將其轉化爲java.lang.String對象對應的oop(能夠理解爲Java對象在HotSpot VM層面的表示),並使用StringTable來進行緩存。可是CONSTANT_String類型的常量,屬於上文提到的延遲解析的範疇,也就是在類加載時並不會當即執行解析,而是等到第一次執行相關指令時(通常來講是ldc指令)纔會真正解析。

3. 執行指令 上面提到,JVM會在第一次執行相關指令的時候去執行真正的解析,對於上文給出的代碼,觀察字節碼能夠發現,ldc指令中使用到了符號引用,因此在執行ldc指令時,須要進行解析操做。那麼ldc指令到底作了什麼呢?

ldc指令會從運行時常量池中查找指定index對應的常量項,並將其壓入棧中。若是該項還未解析,則須要先進行解析,將符號引用轉化爲具體的值,而後再將其壓入棧中。若是這個未解析的項是String類型的常量,則先從字符串常量池中查找是否已經有了相同內容的String對象,若是有則直接將字符串常量池中的該對象壓入棧中;若是沒有,則會建立一個新的String對象加入字符串常量池中,並將建立的新對象壓入棧中。可見,若是代碼中聲明多個相同內容的字符串字面量或者字符串常量表達式,那麼只會在第一次執行ldc指令時建立一個String對象,後續相同的ldc指令執行時相應位置的常量已經解析過了,直接壓入棧中便可。

總結一下:

  1. 在編譯階段,源碼中字符串字面量或者字符串常量表達式轉化爲了class文件的常量池中的CONSTANT_String常量項。
  2. 在類加載階段,class文件常量池中的CONSTANT_String常量項被存入了運行時常量池中,但保存的內容仍然是一個符號引用,未進行解析。
  3. 在指令執行階段,當第一次執行ldc指令時,運行時常量池中的CONSTANT_String項還未解析,會真正執行解析,解析過程當中會建立String對象並加入字符串常量池。

緩存關鍵源碼分析

能夠看到,其實ldc指令在解析String類型常量的時候與String.intern()方法的邏輯很類似:

  1. ldc指令中解析String常量:先從字符串常量池中查找是否有相同內容的String對象,若是有則將其壓入棧中,若是沒有,則建立新對象加入字符串常量池並壓入棧中。
  2. String.intern()方法:先從字符串常量池中查找是否有相同內容的String對象,若是有則返回該對象引用,若是沒有,則將自身加入字符串常量池並返回。

實際在HotSpot內部實現上,ldc指令 與 String.intern()對應的native方法 調用了相同的內部方法。咱們以OpenJDK 8的源代碼爲例,簡單分析一下其過程,代碼以下(源碼位置:src/share/vm/classfile/SymbolTable.cpp):

// String.intern()方法會調用這個方法
// 參數 "oop string"表明調用intern()方法的String對象
oop StringTable::intern(oop string, TRAPS)
{
  if (string == NULL) return NULL;
  ResourceMark rm(THREAD);
  int length;
  Handle h_string (THREAD, string);
  jchar* chars = java_lang_String::as_unicode_string(string, length, CHECK_NULL);    // 將String對象轉化爲字符序列
  oop result = intern(h_string, chars, length, CHECK_NULL);
  return result;
}

// ldc指令執行時會調用這個方法
// 參數 "Symbol* symbol" 是 運行時常量池 中 ldc指令的參數(索引位置)對應位置的Symbol對象
oop StringTable::intern(Symbol* symbol, TRAPS) {
  if (symbol == NULL) return NULL;
  ResourceMark rm(THREAD);
  int length;
  jchar* chars = symbol->as_unicode(length);   // 將Symbol對象轉化爲字符序列
  Handle string;
  oop result = intern(string, chars, length, CHECK_NULL);
  return result;
}

// 上面兩個方法都會調用這個方法
oop StringTable::intern(Handle string_or_null, jchar* name, int len, TRAPS) {
  // 嘗試從字符串常量池中尋找
  unsigned int hashValue = hash_string(name, len);
  int index = the_table()->hash_to_index(hashValue);
  oop found_string = the_table()->lookup(index, name, len, hashValue);

  // 若是找到了直接返回
  if (found_string != NULL) {
    ensure_string_alive(found_string);
    return found_string;
  }

   // ...... 省略部分代碼 ......
   
  Handle string;
  // 嘗試複用原字符串,若是沒法複用,則會建立新字符串
  // JDK 6中這裏的實現有一些不一樣,只有string_or_null已經存在於永久代中才會複用
  if (!string_or_null.is_null()) {
    string = string_or_null;
  } else {
    string = java_lang_String::create_from_unicode(name, len, CHECK_NULL);
  }

  //...... 省略部分代碼 ......

  oop added_or_found;
  {
    MutexLocker ml(StringTable_lock, THREAD);
    // 添加字符串到 StringTable 中
    added_or_found = the_table()->basic_add(index, string, name, len,
                                  hashValue, CHECK_NULL);
  }
  ensure_string_alive(added_or_found);
  return added_or_found;
}
複製代碼

案例分析

說明:由於在Java 6以後字符串常量池永久代移到了中,可能在一些代碼上Java 6與以後的版本表現不一致。因此下面的代碼都使用Java 6Java 7分別進行測試,若是未特殊說明,表示在兩個版本上結果相同,若是不一樣,會單獨指出。


final int a = 4;
int b = 4;
String s1 = "123" + a + "567";
String s2 = "123" + b + "567";
String s3 = "1234567";
System.out.println(s1 == s2);
System.out.println(s1 == s3);
System.out.println(s2 == s3);
複製代碼

結果:

false
true
false
複製代碼

解釋:

  1. 第三行,由於a被定義爲常量,因此"123" + a + "567"是一個常量表達式,在編譯期會被編譯爲"1234567",因此會在字符串常量池中建立"1234567"s1指向字符串常量池中的"1234567"
  2. 第四行,b被定義爲變量,"123""567"字符串字面量,因此首先在字符串常量池中建立"123""567",而後經過StringBuilder隱式拼接在堆中建立"1234567"s2指向堆中的"1234567"
  3. 第五行,"1234567"是一個字符串字面量,由於此時字符串常量池中已經存在了"1234567",因此s3指向字符串字符串常量池中的"1234567"

String s1 = new String("123");
String s2 = s1.intern();
String s3 = "123";
System.out.println(s1 == s2); 
System.out.println(s1 == s3); 
System.out.println(s2 == s3);
複製代碼

結果:

false
false
true
複製代碼

解釋:

  1. 第一行,"123"是一個字符串字面量,因此首先在字符串常量池中建立了一個"123"對象,而後使用String的構造函數在堆中建立了一個"123"對象,s1指向堆中的"123"
  2. 第二行,由於字符串常量池中已經有了"123",因此s2指向字符串常量池中的"123"
  3. 第三行,一樣由於字符串常量池中已經有了"123",因此s3指向字符串常量池中的"123"

String s1 = String.valueOf("123");
String s2 = s1.intern();
String s3 = "123";
System.out.println(s1 == s2); 
System.out.println(s1 == s3); 
System.out.println(s2 == s3); 
複製代碼

結果:

true
true
true
複製代碼

解釋:與上一種狀況的區別在於,String.valueOf()方法在參數爲String對象的時候會直接將參數做爲返回值,不會在堆上建立新對象,因此s1也指向字符串常量池中的"123",三個變量指向同一個對象。


String s1 = new String("123") + new String("456"); 
String s2 = s1.intern();
String s3 = "123456";
System.out.println(s1 == s2); 
System.out.println(s1 == s3); 
System.out.println(s2 == s3);
複製代碼

上面的代碼在Java 6Java 7中結果是不一樣的。 在Java 6中:

false
false
true
複製代碼

解釋:

  1. 第一行,"123""456"字符串字面量,因此首先在字符串常量池中建立"123""456"+操做符經過StringBuilder隱式拼接在堆中建立"123456"s1指向堆中的"123456"
  2. 第二行,將"123456"緩存到字符串常量池中,由於Java 6字符串常量池中的對象是在永久代建立的,因此會在字符串常量池(永久代)建立一個"123456",此時在堆中和永久代中各有一個"123456"s2指向字符串常量池(永久代)中的"123456"
  3. 第三行,"123456"字符串字面量,由於此時字符串常量池(永久代)中已經存在"123456",因此s3指向字符串常量池(永久代)中的"123456"

Java 7中:

true
true
true
複製代碼

解釋:與Java 6的區別在於,由於Java 7字符串常量池中的對象是在上建立的,因此當執行第二行String s2 = s1.intern();時不會再建立新的String對象,而是直接將s1的引用添加到StringTable中,因此三個對象都指向常量池中的"123456",也就是第一行中在堆中建立的對象。

Java 7下,s1 == s2結果爲true也可以用來佐證咱們上面延遲解析的過程。咱們假設若是"123456"不是延遲解析的,而是類加載的時候解析完成並進入常量池的,s1.intern()的返回值應該是常量池中存在的"123456",而不會將s1指向的堆中的"123456"對象加入常量池,因此結果應該是s2不等於s1而等於s3


String s1 = new String("123") + new String("456");
String s2 = "123456";
String s3 = s1.intern();
System.out.println(s1 == s2); 
System.out.println(s1 == s3); 
System.out.println(s2 == s3);
複製代碼

結果:

false
false
true
複製代碼

解釋:

  1. 第一行,"123""456"字符串字面量,因此首先在字符串常量池中建立"123""456"+操做符經過StringBuilder隱式拼接在堆中建立"123456"s1指向堆中的"123456"
  2. 第二行,"123456"是字符串字面量,此時字符串常量池中不存在"123456",因此在字符串常量池中建立"123456"s2指向字符串常量池中的"123456"
  3. 第三行,由於此時字符串常量池中已經存在"123456",因此s3指向字符串常量池中的"123456"

參考

  1. Java substring() method memory leak issue and fix
  2. java - substring method in String class causes memory leak - Stack Overflow
  3. JLS - 3.10.5. String Literals
  4. JLS - 15.28 Constant Expressions
  5. String.intern in Java 6, 7 and 8 – string pooling
  6. (Java 中new String("字面量") 中 "字面量" 是什麼時候進入字符串常量池的? - 木女孩的回答 - 知乎
  7. 深刻解析String#intern
  8. JLS - 5.4.3. Resolution
  9. 請別再拿「String s = new String("xyz");建立了多少個String實例」來面試了吧
  10. JVM Internals
  11. 探祕JVM內部結構(翻譯)
  12. Java虛擬機原理圖解
相關文章
相關標籤/搜索