JVM-String-Pool

最近在学习JVM的过程中,遇到了一个十分容易混淆的概念,字符串池。 有关字符串池的代码,运行结果始终没有和预期对应上。查询了大量资料无果后,在stackoverflow上发布了提问,提问链接。提问后的半天内(在此感叹下国际友人的钻研精神),有两位大佬和我就此问题进行了探讨。在交流中对这个概念的理解逐渐清晰。通过将这个事情记录下来,来理清自己的思路,并分享给更多人。

由于具体情况可能在不同JVM上表现不同,本次实验和讨论基于JDK8HotSpot JVM

问题

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
String s1 = new String("12") + new String("21");
s1.intern();
String s2 = "1221";
System.out.println(s1 == s2); // true
}

public static void main(String[] args) {
String s1 = new String("12") + new String("21");
// s1.intern();
String s2 = "1221";
System.out.println(s1 == s2); // false
}

有上述两段代码,唯一的区别就是是否调用了s1.intern(),而产生了不同的输出结果。

intern是一个native方法,在Java8源代码中的注释如下:

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.

这段代码的字节码如下:

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
 0 new #2 <java/lang/StringBuilder>
3 dup
4 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
7 new #4 <java/lang/String>
10 dup
11 ldc #5 <12>
13 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
16 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
19 new #4 <java/lang/String>
22 dup
23 ldc #8 <21>
25 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
28 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
31 invokevirtual #9 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
34 astore_1
35 aload_1
36 invokevirtual #10 <java/lang/String.intern : ()Ljava/lang/String;>
39 pop
40 ldc #11 <1221>
42 astore_2
43 getstatic #12 <java/lang/System.out : Ljava/io/PrintStream;>
46 aload_1
47 aload_2
48 if_acmpne 55 (+7)
51 iconst_1
52 goto 56 (+4)
55 iconst_0
56 invokevirtual #13 <java/io/PrintStream.println : (Z)V>
59 return

错误的理解

  1. 在源代码中,出现了"12""21""1221"字面量。因此编译后,字节码常量池中可以观察到这三个字面量对应的CONSTANT_String_info,这些CONSTANT_String_info在这些指令中被使用:
1
2
3
11 ldc #5 <12>
23 ldc #8 <21>
40 ldc #11 <1221>
  1. 当程序运行,类加载器加载这个类时,字节码文件中常量池被载入内存。在内存中称为运行时常量池。字符串池是运行时常量池的一部分,因此在字符串池中包含"12""21""1221"这些字符串。

  2. new String("12")在堆中申请了一块空间,容纳新String实例,并从字符串池中取出"12",并用此对新实例进行初始化。对应的字节码如下,new String("21")也是同理。

1
2
3
 7 new #4 <java/lang/String>
11 ldc #5 <12>
13 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V>
  1. +进行字符串拼接,本质被编译器转化为了StringBuilder实例和进行append方法,并最后调用toString方法,对应的字节码:
1
2
3
4
0 new #2 <java/lang/StringBuilder>
4 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
16 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
31 invokevirtual #9 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
  1. toString方法中,调用了new String,因此最后返回的s1"1221"String实例,位于堆上。
  2. 根据文档,s1.intern()方法,查看了字符串池,发现确实存在"1221",返回了字符串池中的地址引用。不管如何,代码中没有利用返回值,只是将他pop掉了,因此不会对已有的变量进行任何改动。
  3. String s2 = "1221"查看了字符串池,发现存在,因此将其地址引用赋值给s2。对应字节码为:
1
2
40 ldc #11 <1221>
42 astore_2
  1. ==运算符对于引用类型来说,比较的是俩者的地址。s1的地址指向堆中,s2的地址指向字符串池中。俩者怎么可能会相等?

疑惑

  1. s1s2到底指向哪里?
  2. 为什么调用intern方法会影响代码行为,甚至没有去利用其返回值?

正解

错误出现在理解26中,字符串池并没有随着类加载,被载入运行时常量池。s1.intern()是首次将"1221"加入到字符串池中,而后运行的String s2 = "1221",会根据字符串池中是否存在"1221"改变行为。

为了更好解释这个问题,首先对涉及的关键概念进行定义。

关键概念

  • 常量池:字节码中的一种存储结构,用来存储源代码中用到的常数、字符串,类、字段、方法、接口、参数类型等。位于硬盘上的字节码文件中。具体见官方文档
  • 运行时常量池:程序运行时,内存中的常量池。在类加载时,会将常量池数据加载到JVM方法区,形成运行时常量池。具体见官方文档
  • CONSTANT_String_info:常量池中的一种数据结构,储存了源代码中字符串字面量对应的Unicode序列,具体见官方文档
  • 字符串池:JDK8堆中的一块内存区域,用于存取用到过的String实例。源码中String.intern()函数的官方文档

A pool of strings, initially empty, is maintained privately by the class String.

  • ldc #5,将序号为5的常量从运行时常量池中推到操作数栈上,具体见官方文档。在使用到通过字面量表示的字符串,都会先检查字符串池中是否存在对应的string实例。如果存在,则入栈其引用地址;如果不存在,则在字符串池内创建string实例并入栈其地址。

错误原因

错误来自于误解了字符串池和常量池(此后不加区分地使用常量池和运行时常量池)间的关系。

虽然通常称其为字符串常量池,但其和常量池并不存在属于关系。因此并不会随着类的加载而载入。在JDK6时,字符串池和常量池都位于永久代,好似之间存在一些关系。但在JDK8中,字符串池被移到堆中。与其说是常量池的一部分,更不如说是String类的一部分。可以理解为String类的一个私有的成员变量,虽然在String源码中无法观察到。字符串池中的String实例在被创建后,无法改变实例中的字节数组字段。如果对已有的String实例进行改变的操作,会生成一个新的String实例,表现出常量的特性,所以通常称其为字符串常量池。但为了避免混淆字符串池和常量池,本文尽量使用字符串池,而不是字符串常量池

与其容易混淆的另一个概念是常量池中的CONSTANT_String_info。里面以Unicode序列储存着字符串字面量,并且会随着类加载,载入到运行时常量池中。但其和字符串池有着本质区别:CONSTANT_String_info只储存了Unicode序列,而字符串池储存了String实例。String实例不仅包含了Unicode序列,还包含了其他成员属性,如hash。并且String类绑定了许多方法,这些方法无法在CONSTANT_String_info上执行。可以通过以CONSTANT_String_info中的Unicode序列为参数,执行String初始化函数,生成对应的String实例。

具体行为

根据文档,字符串池初始为空,并会在字符串第一次被使用时被加入到字符串池中。以后再用到相同的字符串,会复用字符串池已经存在的对象。所以详细描述的ldc #5执行过程应该是这样的:

1.取出运行时常量池中的索引为5的Unicode序列

2.根据此序列,在字符串常量池中寻找是否存在对应的String实例

3.如果存在,则将其引用入栈

4.如果不存在,则在字符串中创建一个新的String实例,以Unicode序列为参数,执行初始化,并将新创建的String实例的引用入栈。

new关键字,则会在堆(字符串池外)中创建一个新的String实例。如果通过字面量形式赋值,如new String("12"),则会现在字符串池中创建实例,再以字符串池中的实例为参数,调用参数类型为String的初始化函数。执行完后,内存中存在两个一模一样的String实例,一个在堆中,一个在字符串池中。这点可以从字节码指令中看出:

1
2
3
4
 7 new #4 <java/lang/String>
10 dup
11 ldc #5 <12> //字符串池中不存在,因此会创建一个String`实例
13 invokespecial #6 <java/lang/String.<init> : (Ljava/lang/String;)V> //调用参数为String的初始化函数

向字符串池中加入新的String实例通常有两个方法。ldc一个字符串池中不存在的常量,或者调用一个字符串池中不存在的string实例的intern方法。第二中方法相对少见,因为这需要在堆上,不通过字面量的形式创建一个string实例。这通常需要通过字符串操作来做到。

对于一个字符串池中不存在的string实例,对其调用intern方法会将其引用添加到字符串池中,而不是重新在字符串池中创建一个新的相同的实例。此为JDK8 Hotspot的行为,这个行为可能会因JDK版本和JVM具体实现而异。例如在JDK6就会重新在字符串池中创建一个新的相同的实例。

因此回答上文提到的两个问题:

1.s1指向堆中的"1221" String实例,s2根据字符串池中储存的引用,最终也指向了堆中的"1221" String实例。所以在JDK8中,内存中存在两处"1221"序列,一处在常量池中,一处在堆上。字符串池由于存储的是引用,因此没有"1221"序列。

2.因为s1没有通过字面量,而是字符串拼接的形式创建了"1221" String实例,在这种情况下,字符串池中不存在"1221",调用intern方法会影响字符串池,从而影响到ldc指令获取到的地址。

总结

避免混淆字符串池、常量池、CONSTANT_String_info的概念和之间的关系。

字符串池是一个位于堆上的,用于管理String实例的数据结构,可以被视为String类的一个私有成员变量。

常量池是字节码文件中的存储结构。运行时常量池是在类加载过程中,将常量池加载到方法区/元空间形成的内存区域。

CONSTANT_String_info是常量池/运行时常量池中的存储结构,储存了源代码中用到的字符串字面量的Unicode序列。是常量池的一部分,随类加载而加载。


JVM-String-Pool
http://dracoyus.github.io/2023/07/04/JVM-String-Pool/
作者
DracoYu
发布于
2023年7月4日
许可协议