Categories
程式開發

Java String 面面觀


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

String類的設計與實現

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

Java 6

public final class String implements java.io.Serializable, Comparable, 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、哈希值hash。 value數組用來存儲字符序列, offset 和count 兩個屬性用來定位字符串在value數組中的位置,hash屬性用來緩存字符串的hashCode。

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

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, 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中,Java 對String 類做了一些改變。 String 類中不再有offset 和count 兩個成員變量了。 substring()方法也不再共享value數組,而是從指定位置重新拷貝一份value數組,從而解決了使用該方法可能導致的內存洩漏問題。 substring()方法實現如下:

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, 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成員變量。 我們知道Java中char類型占用的是兩個字節,對於只佔用一個字節的字符(例如,az,AZ)就顯得有點浪費,所以Java 9中將char[]改為byte[]來存儲字符序列,而新屬性coder 的作用就是用來表示value數組中存儲的是雙字節編碼的字符還是單字節編碼的字符。 coder 屬性可以有0 和1 兩個值,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修飾。 這保證了:

String類由final修飾,所以無法通過繼承String類改變其語義;所有的屬性都是聲明為private的, 所以無法在String外部直接訪問或修改其屬性;除了hash屬性之外的其他屬性都是用final修飾,表示這些屬性在初始化賦值後不可以再修改。

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

hash屬性不是final的原因是:String的hashCode並不需要在創建字符串時立即計算並賦值,而是在hashCode()方法被調用時才需要進行計算。

為什麼String類要設計為不可變的?

保證String 對象的安全性。 String被廣泛用作JDK中作為參數、返回值,例如網絡連接,打開文件,類加載,等等。 如果String 對像是可變的,那麼String 對象將可能被惡意修改,引發安全問題。 線程安全。 String類的不可變性天然地保證了其線程安全的特性。 保證了String對象的hashCode的不變性。 String類的不可變性,保證了其hashCode值能夠在第一次計算後進行緩存,之後無需重複計算。 這使得String對像很適合用作HashMap等容器的Key,並且相比其他對象效率更高。 實現字符串常量池。 Java為字符串對象設計了字符串常量池來共享字符串,節省內存空間。 如果字符串是可變的,那麼字符串對象便無法共享。 因為如果改變了其中一個對象的值,那麼其他對象的值也會相應發生變化。

與String類相關的類

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

StringBuffer / StringBuilder的實現

StringBuffer和StringBuilder都是繼承自AbstractStringBuilder,AbstractStringBuilder利用可變的char數組(Java 9之後改為為byte數組)來實現對字符串的各種修改操作。 StringBuffer和StringBuilder都是調用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 MAX_ARRAY_SIZE) ? minCapacity : MAX_ARRAY_SIZE; }

当然AbstractStringBuilder也提供了trimToSize方法去释放多余的空间:

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

String对象的缓存机制

因为String对象的使用广泛,Java为String对象设计了缓存机制,以提升时间和空间上的效率。在JVM的运行时数据区中存在一个字符串常量池(String Pool),在这个常量池中维护了所有已经缓存的String对象,当我们说一个String对象被缓存(interned)了,就是指它进入了字符串常量池。

我们通过解答下面三个问题来理解String对象的缓存机制:

哪些String对象会被缓存进字符串常量池?String对象被缓存在哪里,如何组织起来的?String对象是什么时候进入字符串常量池的?

说明: 如未特殊指明,本文中提及的JVM实现均指的是Oracle的HotSpot 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)](https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.5),屬於方法區的一部分。 運行時常量池是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對象的表達式,可以簡單理解為常量表達式就是在編譯期間就能確定值的表達式。 字符串常量表達式就是表示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常量表達式“)。

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()方法的註釋來看下其具體的緩存機制:

調用intern方法時,如果池已經包含等於equals(Object)方法確定的此String對象的字符串,則返回池中的字符串。 否則,將此String對象添加到池中並返回對該String對象的引用。對於任何兩個字符串s和t,s.intern()== t.intern()為true當且僅當s .equals(t)是正確的。

簡單翻譯一下:

當調用intern 方法時,如果常量池中已經包含相同內容的字符串(字符串內容相同由equals (Object) 方法確定,對於String 對象來說,也就是字符序列相同),則返回常量池中的字符串對象。 否則,將此String 對象將添加到常量池中,並返回此String 對象的引用。 因此,對於任意兩個字符串s 和t,當且僅當s.equals
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."":()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_String和CONSTANT_Utf8。 CONSTANT_String類型的常量用於表示String類型的常量對象,其內容只是一個常量池的索引值index,index處的成員必須是CONSTANT_Utf8類型。 而CONSTANT_Utf8類型的常量用於存儲真正的字符串內容。

例如,上面的常量池中的第2、3項是CONSTANT_String類型,存儲的索引分別為24、25,常量池中第24、25項就是CONSTANT_Utf8,存儲的值分別為"123123","123456"。

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

ldc # // 首先将常量池中指定索引位置的String对象压入栈中
astore_ // 然后从栈中弹出刚刚存入的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文件的常量池中只有一個值為123456的CONSTANT_Utf8常量項以及一個對應的CONSTANT_String常量項。

2. 類加載

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

對於一些基本類型的常量項,例如CONSTANT_Integer_info,CONSTANT_Float_info,CONSTANT_Long_info,CONSTANT_Double_info,在類加載階段會將class文件常量池中的值轉化為運行時常量池中的值,分別對應C++中的int,float,long ,double類型;對於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指令執行時相應位置的常量已經解析過了,直接壓入棧中即可。

總結一下:

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

緩存關鍵源碼分析

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

ldc指令中解析String常量:先從字符串常量池中查找是否有相同內容的String對象,如果有則將其壓入棧中,如果沒有,則創建新對象加入字符串常量池並壓入棧中。 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 6和Java 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

解釋:

第三行,因為a被定義為常量,所以"123" + a + "567"是一個常量表達式,在編譯期會被編譯為"1234567",所以會在字符串常量池中創建"1234567" ,s1指向字符串常量池中的"1234567";第四行,b被定義為變量,"123"和"567"是字符串字面量,所以首先在字符串常量池中創建"123"和" 567",然後通過StringBuilder隱式拼接在堆中創建"1234567",s2指向堆中的"1234567";第五行,"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

解釋:

第一行,"123"是一個字符串字面量,所以首先在字符串常量池中創建了一個"123"對象,然後使用String的構造函數在堆中創建了一個"123"對象,s1指向堆中的"123";第二行,因為字符串常量池中已經有了"123",所以s2指向字符串常量池中的"123";第三行,同樣因為字符串常量池中已經有了"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 6和Java 7中結果是不同的。

在Java 6中:

false
false
true

解釋:

第一行,"123"和"456"是字符串字面量,所以首先在字符串常量池中創建"123"和"456",+操作符通過StringBuilder隱式拼接在堆中創建"123456", s1指向堆中的"123456";第二行,將"123456"緩存到字符串常量池中,因為Java 6中字符串常量池中的對像是在永久代創建的,所以會在字符串常量池(永久代)創建一個"123456",此時在堆中和永久代中各有一個"123456",s2指向字符串常量池(永久代)中的"123456";第三行,"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

解釋:

第一行,"123"和"456"是字符串字面量,所以首先在字符串常量池中創建"123"和"456",+操作符通過StringBuilder隱式拼接在堆中創建"123456", s1指向堆中的"123456";第二行,"123456"是字符串字面量,此時字符串常量池中不存在"123456",所以在字符串常量池中創建"123456", s2指向字符串常量池中的"123456";第三行,因為此時字符串常量池中已經存在"123456",所以s3指向字符串常量池中的"123456"。

參考

Java substring()方法內存洩漏問題和修復java-字符串類中的子字符串方法導致內存洩漏JLS-3.10.5。 字符串文字JLS-15.28常量表達式Java 6、7和8中的String.intern –字符串池(Java 中new String("字面量") 中"字面量" 是何時進入字符串常量池的? - 木女孩的回答- 知乎深入解析String#internJLS-5.4.3。 解析度請別再拿“String s = new String("xyz");創建了多少個String實例”來面試了吧JVM內部探秘JVM內部結構(翻譯)Java虛擬機原理圖解