JVM-String-Pool
最近在学习JVM
的过程中,遇到了一个十分容易混淆的概念,字符串池
。
有关字符串池
的代码,运行结果始终没有和预期对应上。查询了大量资料无果后,在stackoverflow
上发布了提问,提问链接。提问后的半天内(在此感叹下国际友人的钻研精神),有两位大佬和我就此问题进行了探讨。在交流中对这个概念的理解逐渐清晰。通过将这个事情记录下来,来理清自己的思路,并分享给更多人。
由于具体情况可能在不同JVM
上表现不同,本次实验和讨论基于JDK8
,HotSpot JVM
。
问题
1 |
|
有上述两段代码,唯一的区别就是是否调用了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 |
|
错误的理解
- 在源代码中,出现了
"12"
、"21"
、"1221"
字面量。因此编译后,字节码常量池中可以观察到这三个字面量对应的CONSTANT_String_info
,这些CONSTANT_String_info
在这些指令中被使用:
1 |
|
当程序运行,类加载器加载这个类时,字节码文件中常量池被载入内存。在内存中称为运行时常量池。字符串池是运行时常量池的一部分,因此在字符串池中包含
"12"
、"21"
、"1221"
这些字符串。new String("12")
在堆中申请了一块空间,容纳新String
实例,并从字符串池中取出"12"
,并用此对新实例进行初始化。对应的字节码如下,new String("21")
也是同理。
1 |
|
+
进行字符串拼接,本质被编译器转化为了StringBuilder
实例和进行append
方法,并最后调用toString
方法,对应的字节码:
1 |
|
- 在
toString
方法中,调用了new String
,因此最后返回的s1
是"1221"
的String
实例,位于堆上。 - 根据文档,
s1.intern()
方法,查看了字符串池,发现确实存在"1221"
,返回了字符串池中的地址引用。不管如何,代码中没有利用返回值,只是将他pop
掉了,因此不会对已有的变量进行任何改动。 String s2 = "1221"
查看了字符串池,发现存在,因此将其地址引用赋值给s2
。对应字节码为:
1 |
|
==
运算符对于引用类型来说,比较的是俩者的地址。s1
的地址指向堆中,s2
的地址指向字符串池中。俩者怎么可能会相等?
疑惑
s1
和s2
到底指向哪里?- 为什么调用
intern
方法会影响代码行为,甚至没有去利用其返回值?
正解
错误出现在理解2和6中,字符串池并没有随着类加载,被载入运行时常量池。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 |
|
向字符串池中加入新的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
序列。是常量池的一部分,随类加载而加载。