浅析String#intern

String#intern() 方法简述

String#intern() 方法的官方注释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {

/**
* Returns a canonical representation for the string object.
* <p>
* A pool of strings, initially empty, is maintained privately by the
* class {@code String}.
* <p>
* When the intern method is invoked, if the pool already contains a
* string equal to this {@code String} object as determined by
* the {@link #equals(Object)} method, then the string from the pool is
* returned. Otherwise, this {@code String} object is added to the
* pool and a reference to this {@code String} object is returned.
* <p>
* It follows that for any two strings {@code s} and {@code t},
* {@code s.intern() == t.intern()} is {@code true}
* if and only if {@code s.equals(t)} is {@code true}.
* <p>
* All literal strings and string-valued constant expressions are
* interned. String literals are defined in section 3.10.5 of the
* <cite>The Java&trade; Language Specification</cite>.
*
* @return a string that has the same contents as this string, but is
* guaranteed to be from a pool of unique strings.
*/
public native String intern();

}

String#intern 是一个 native 方法,注释大意为:如果常量池中存在当前字符串,就会直接返回当前字符串对象引用。如果常量池中没有此字符串,会将此字符串放入常量池中后,再将其字符串对象引用返回。

字符串常量池(String pool 或 String Literal pool)

字符串常量池,或许叫全局字符串池更容易了解它的功能,这个池子里存储的内容是:类加载(验证,准备阶段)完成之后在堆中生成字符串对象实例的引用值,因此 String pool 中存的是引用值而不是具体的实例对象,具体的实例对象存放在堆中开辟的一块空间(具体来说是在运行时常量池中)。

在 HotSpot VM 里实现 String pool 功能的是一个 StringTable 类,它是一个哈希表,里面存的是驻留字符串的引用(也就是我们常说的用双引号括起来的字符串对象实例的引用,而不是驻留字符串实例本身),因此在堆中的某些字符串实例被这个 StringTable 引用之后就等同被赋予了“驻留字符串”的身份。

这个 StringTable 在每个 HotSpot VM 的实例只有一份,被所有的类共享。

字符串常量池由一个固定容量的 hashmap 实现,每个元素包含相同 hash 值的字符串列表。

在早期的 java 6 中它是一个常量,从 Java 6u30 以后变成了可配置的,你需要通过-XX:StringTableSize=N来设置字符串常量池中 map 的大小。为了性能考虑请确保它是一个质数

在 Java 6 到 7u40 中 -XX:StringTableSize 参数的默认值是 1009。在 Java 7u40 以后默认值为 60013(java 8 仍然支持此参数设置以兼容 Java 7)。

Class 文件常量池( Class constant pool )

程序员编写好的 Java 文件需要编译成 Class 文件,再由 JVM 加载运行,注意的是,每一个类都会生成对应的一个 Class 文件,Class 文件中的存储的信息格式十分严格,Class 文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池( Constant pool table ),用于存放编译器生成的各种字面量( Literal )和符号引用( Symbolic References )。

字面量就是我们所说的常量概念,如文本字符串、被声明为 final 的常量值等。

符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用不同:直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。

1
2
3
4
5
public class Test {
public static void main(String[] args) {
String str = "木鲸鱼";
}
}

反编译上述代码,执行javap -verbose Test,查看 Class 文件编译内容结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = String #14 // 木鲸鱼
#3 = Class #15 // Test
#4 = Class #16 // java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Utf8 Code
#8 = Utf8 LineNumberTable
#9 = Utf8 main
#10 = Utf8 ([Ljava/lang/String;)V
#11 = Utf8 SourceFile
#12 = Utf8 Test.java
#13 = NameAndType #5:#6 // "<init>":()V
#14 = Utf8 木鲸鱼
#15 = Utf8 Test
#16 = Utf8 java/lang/Object
{
public Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: ldc #2 // String 木鲸鱼
2: astore_1
3: return
LineNumberTable:
line 4: 0
line 5: 3
}
SourceFile: "Test.java"

Test.java文件被编译之后,也就会生成了上述的 Class 文件内容,可以看到其中保存了"木鲸鱼"的字面量在 Class 文件常量池(Constant pool)中。

具体存储的方式是:"木鲸鱼"字面量会拆分成CONSTANT_Utf8CONSTANT_String,前者是一个指针,指向一个Symbol类型的 C++ 对象,内容是跟 Class 文件同样格式的 UTF-8 编码的字符串,后者表示的是常量的类型,它持有一个index,这个index可以指向另一个常量池(运行时常量池)中类型为CONSTANT_Utf8的常量。

重要概念说明:

Class 文件常量池中不仅仅存储着字符串的字面量,它可以存储着 14 种常量类型的项目表,比如上面反编译结果中的CONSTANT_Methodref_infoCONSTANT_Class_infoCONSTANT_NameAndType_infoCONSTANT_String_infoCONSTANT_Utf8_info都是常量类型表,分别存储着不同的信息,它们之间的关系如图:

这里再多说一下和字符串字面量相关的两个常量类型表:

symbol 对象的符号引用,在未解析的时候状态为CONSTANT_UnresolvedString,解析成功之后将转变为CONSTANT_internString

1
2
3
4
CONSTANT_String_info {  
u1 tag; // 常量池项目类型标志,其中 CONSTANT_String_info 的 tag 为:8
u2 index; // 指向字符串字面量的索引
}

CONSTANT_Utf8_info字符串字面量属于 Class 常量池项目中的字符串项目类型

1
2
3
4
5
CONSTANT_Utf8_info {  
u1 tag; // 常量池项目类型标志,其中 CONSTANT_Utf8_info 的 tag 为:1
u1 length; // UTF-8编码的字符串长度(字节单位)
u2 bytes_length; // UTF-8编码的字符串长度(字节单位)
}

从上面反编译的结果可以看出:常量池中的第 4 项是CONSTANT_String_info类型的信息,指向了常量池中的第 14 项的项目内容,即类型为CONSTANT_Utf8_info的字面量内容。

运行时常量池(runtime constant pool)

当 Java 文件被编译成 Class 文件之后,其文件中就会生成上面所说的 Class 文件常量池,那么这个 Class 文件常量池是用来干什么的呢?

JVM 在执行某个类的时候,必须经过加载、链接、初始化,而链接又包括验证、准备、解析三个阶段。当某个类加载到 JVM 内存之后,JVM 就会将这个类中的 Class 文件常量池中的内容存放到运行时常量池中。由此可知,运行时常量池也是每个类都有一个。

上个概念已经解释:Class 文件常量池中存的是字面量和符号引用,也就是说 Class 文件常量池存的并不是对象的实例,而是对象的符号引用值。当被加载完成字面量和符号引用经过解析(resolve)之后,也就是把符号引用替换为直接引用,Class 文件常量池中字面量内容才正式被 JVM 使用,解析完成之后就会去查询全局字符串池(上述 HotSpot VM 中的 StringTable),以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

讲到这里,不知道读者有没有意识到上述两个常量池的明显区别:Class 文件常量池是在编译之后就已经确定好了内容及内存大小,因此它是静态的常量池,而运行时常量池相对于 Class文件常量池 的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也允许新的常量放入池中,这种特性被开发人员利用比较多的就是 String 对象的 intern() 方法。

在 JDK 7 中,运行时常量池已经在 Java 堆上分配内存,执行 String#intern() 方法时,JVM 会检查当前常量池中是否存在调用 intern() 方法的 String 对象是否,如果常量池已经存在该字符串,则直接返回字符串引用,否则复制该字符串对象的引用到常量池中并返回,所以在 JDK 7 中,可以重新考虑使用intern方法,减少 String 对象所占的内存空间。

运行时常量池和 Class 文件常量池 及 全局常量池的关系

以下引用 1,引用 2 摘自:《请问,jvm实现读取class文件常量池信息是怎样呢?》http://hllvm.group.iteye.com/group/topic/26412 ;引用 3 摘自:《运行时常量池与Class文件常量池的区别》http://hllvm.group.iteye.com/group/topic/40008

各个类型的常量是混在一起放在常量池(运行时常量池)里的,跟 Class 文件里的基本上一样。

最不同的是在这个运行时常量池里,symbol 是在类之间共享的;而在 Class 文件的常量池里每个 Class 文件都有自己的一份 symbol 内容,没共享。

以上述表述中可以得知:Class 文件常量池中的字面量内容(symbol 内容),会在类加载完毕之后,就会进入该类的运行时常量池中,并且这份 symbol 内容是全局通用的。

这些 Utf8 常量在 HotSpot VM 里以 symbolOopDesc 对象(下面简称 symbol 对象)来表现;它们可以通过一个全局的 SymbolTable 对象找到。注意:constantPool 对象并不“包含”这些 symbol 对象,而只是引用着它们而已;或者说,constantPool 对象只存了对 symbol 对象的引用,而没有存它们的内容。

@Rednaxelafx 提到的 SymbolTable 就是全局字符串常量池,因为 StringTable 里存储的就是 symbol 对象的引用,从 openjdk 源码中即可验证,openjdk 源码在文末附引用中。

以上述表述中可以得知:全局常量池中保存的是 symbol 对象实例的引用,一旦被保存进来,那么这个 symbol 引用维护的对象实例就有了“全局字符串常量的身份”。

Class 文件常量池只是 .class 文件中的、静态的;而运行时常量池,是在运行时将所有 Class 文件常量池中的东西加载进来?

前半句对,后半句半对。运行时常量池是把 Class 文件常量池加载进来,每个类有一个独立的。刚开始运行时常量池里的链接都是符号链接,跟在 Class 文件里一样;边运行边就会把用到的常量转换成直接链接,例如说要 Class A 调用 Foo.bar() 方法,A.class 文件里就会有对该方法的 Methodref 常量,是个符号链接(只有名字没有实体),加载到运行时常量池也还是一样是符号链接,等真的要调用该方法的时候该常量就会被 resolve 为一个直接链接(直接指向要调用的方法的实体)。

以上表述可以得知:Class 文件常量池中存储的是字面量内容,等到类加载完成之后就会将这些字面量标记未解析,存储在运行时常量池中,注意,此时类只是加载,并且加载后的内容不能直接使用,需要解析之后才能使用。因此只有在调用者要使用的时候才进行解析,变成可使用状态,并且是解析机制是调用多少就解析多少,不会用到的不会解析,因此运行时常量池是动态加载的。

创建字符串对象

首先 String 不属于 8 种基本数据类型,String 是对象。对象的默认值是 null,所以 String 对象的默认值也是 null;但它又是一种特殊的对象,有其它对象没有的一些特性;

new String() 和 new String(“”) 都是申明一个新的空字符串,是空串不是 null;

创建 String 对象有两种创建方式:

第一种,直接使用双引号直接显示声明:

1
2
3
4
5
public class Test {
public static void main(String[] args) {
String str = "木鲸鱼";
}
}

JVM 编译 Test.java 文件之后,生成一个 Test.class 文件(反编译命令:javap -verbose Test),从反编译的内容中可以看出(这里笔者就不贴图了):"木鲸鱼"这个字符串已经被编译器编译成了的文本字符串字面量,当这个类加载之后,JVM 会将 class 文件常量池中的字面量内容全部加载到这个类的运行时常量池中,等待解析,再进行后续的程序操作。

第二种,使用 new 创建 String 对象:

1
2
3
4
5
public class Test {
public static void main(String[] args) {
String str = new String("木鲸鱼");
}
}

通过反编译可以发现:"木鲸鱼"字面量存储在 Class 文件常量池中,并观察执行语句可以看出,在 str 变量赋值之前,会进行ldc指令操作(加粗标绿部分),它的作用是从常量池中拉取字符串常量值的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object."<init>":()V
#2 = Class #16 // java/lang/String
#3 = String #17 // 木鲸鱼
#4 = Methodref #2.#18 // java/lang/String."<init>":(Ljava/lang/String;)V
#5 = Class #19 // Test
#6 = Class #20 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 Test.java
#15 = NameAndType #7:#8 // "<init>":()V
#16 = Utf8 java/lang/String
#17 = Utf8 木鲸鱼
#18 = NameAndType #7:#21 // "<init>":(Ljava/lang/String;)V
#19 = Utf8 Test
#20 = Utf8 java/lang/Object
#21 = Utf8 (Ljava/lang/String;)V
{
public Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: new #2 // class java/lang/String
3: dup
4: ldc #3 // String 木鲸鱼
6: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
9: astore_1
10: return
LineNumberTable:
line 3: 0
line 4: 10
}
SourceFile: "Test.java"

ldc:Push item from run-time constant pool,从常量池中加载指定项的引用到栈。

astore_<n>:Store reference into local variable,将引用赋值给第n个局部变量。

从上面两个反编译的结果可以看出,只要是双引号引起来的字符串都会被编译器编译成字面量,待类加载完成的之后,就会加载到运行时常量池中,但是注意,没有解析之前,加载的内容是不可以使用的。

牛刀小试

「 代码一 」

1
2
3
4
5
public static void main(String[] args) {
String str = new String("木鲸鱼");
String intern = str.intern();
System.out.println(intern == str); //false
}

通过反编译的内容结果可以发现:"木鲸鱼"已经被编译到了 Class 文件中的Constant pool中,在类加载完成的时候,就把"木鲸鱼"字面量内容加载到运行时常量池中,并标注为未解析状态JVM_CONSTANT_UnresolvedString

执行第一行代码,在栈中的 str 变量在 new操作之前,使用了ldc指令,ldc指令是去全局字符串常量池中查询并获取结果,此时查询之前需要注意,这时的"木鲸鱼"JVM_CONSTANT_String状态是unresolved(未解析的状态),此时的全局常量池没有此字符串的直接引用,只有"木鲸鱼"字面量的 symbol 对象的引用,于是 JVM 根据"木鲸鱼"的字面量内容中 symbol 对象创建出"木鲸鱼"的 String 对象,并把这个 symbol 符号引用(C++层面的符号引用,所有虚拟机都认识的,不能被虚拟机直接引用)直接转换成直接引用(不用虚拟机的直接引用的值的实现不同),并把这个直接引用返回给查询者,当成功获取到结果之后,就会把"木鲸鱼"字面量的项目类将会改为"JVM_CONSTANT_internString",因此解析完毕。

进一步证明一下:

「 代码二 」

1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
String str1 = "木鲸鱼";
String str2 = new String("木鲸鱼");
String str3 = str1.intern();
String str4 = str2.intern();
System.out.println(str1 == str2); // false
System.out.println(str1 == str3); // true
System.out.println(str3 == str4); // true
}

str1 上来就 ldc 了,所以常量池中驻留的引用一定是 “木鲸鱼” 的引用。

「 代码三 」

1
2
3
4
5
6
7
8
public static void main(String[] args) {
String str1 = new String("木鲸鱼");
String str2 = "木鲸鱼";
String intern = str1.intern();
System.out.println(intern == str1); //false
System.out.println(intern == str2); //true
System.out.println(str1 == str2); //false
}

第一行代码的执行过程不在赘述,直接看第二行代码执行过程: str2 变量赋值之前,ldc 指令需要从全局字符串常量池中拉取结果,因为在第一行的代码执行完成之后,"木鲸鱼"已经被解析并驻留了,因此不会再创建新的 String 对象,而是直接去全局常量池中查询,发现能获取到直接引用,因此不再将 symbol 对象转换成直接引用了(解析)。

「 代码四 」

1
2
3
4
5
public static void main(String[] args) {
String str = new String("鲸") + new String("鱼");
String intern = str.intern();
System.out.println(intern == str); //true
}

上面这个结果,看起来很正常,因为 Class 文件常量池中没有"鲸鱼",所以 str 变量在 intern 的时候,JVM 会先去全局字符串常量池中找一下,看有没有这个"鲸鱼"字符串,发现没有,就将 str 创建的"鲸鱼"对象的堆地址并驻留到全局常量池中,也就是此时,str 有了全局字符串常量的“身份”。

注意:String str = new String("鲸") + new String("鱼");执行语句的过程是:先创建new StringBuilder()对象保存到 str_0 变量中,再创建两个隐性的变量 str_1 和 str_2 分别引用new String("鲸")new String("鱼"),再将 str_1 和 str_2 局部变量当作参数值,带入 append() 方法进行字符串拼接,拼接完成之后 str_0 变量的值为new StringBuilder("鲸鱼")对象堆地址引用,类的类型为 StringBuilder,使用 toString() 方法进行类型转换,这个 toString() 会创建新的 String 对象。(反编译的内容就不放了,读者自己动手尝试吧)

「 代码五 」

先使用字符串赋值再 intern() 。

1
2
3
4
5
6
7
8
public static void main(String[] args) {
String str1 = new String("鲸") + new String("鱼");
String str2 = "鲸鱼";
String intern = str1.intern();
System.out.println(intern == str1); //false
System.out.println(intern == str2); //true
System.out.println(str1 == str2); //false
}

str2 先执行的ldc指令,先去全局常量池中查询,发现没有,于是驻留了自己。

「 代码六 」

在代码四的基础上,将 3、4 行代码互换位置:先 intern() 再直接使用字符串赋值。

1
2
3
4
5
6
7
8
public static void main(String[] args) {
String str1 = new String("鲸") + new String("鱼");
String intern = str1.intern();
String str2 = "鲸鱼";
System.out.println(intern == str1); //true
System.out.println(intern == str2); //true
System.out.println(str1 == str2); //true
}

str1 先去全局常量池中查询,发现没有,于是驻留了自己。

updated updated 2021-09-15 2021-09-15
本文结束感谢阅读

本文标题:浅析String#intern

本文作者:木鲸鱼

微信公号:木鲸鱼 | woodwhales

原始链接:https://woodwhales.cn/2021/09/15/081/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

0%