从字符串到常量池,一文看懂String类设计
从一道面试题开始
看到这个标题,你肯定以为我又要讲这道面试题了
// 这行代码创建了几个对象? String s3 = new String("1");
这道题就算你没做过也肯定看到,总所周知,它创建了两个对象,一个位于堆上,一个位于常量池中。
这个答案粗看起来是没有任何问题的,但是仔细思考确经不起推敲。
如果你觉得我说的不对的话,那么可以思考下面这两个问题
1.你说它创建了两个对象,那么这两个对象分别是怎样创建的呢?我们回顾下Java创建对象的方式,一共就这么几种
- 使用new关键字创建对象
- 使用反射创建对象(包括Class类的newInstance方法,以及Constructor类的newInstance方法)
- 使用clone复制一个对象
- 反序列化得到一个对象
你说它创建了两个对象,那你告诉我除了new出来那个对象外,另外一个对象怎么创建出来的?
2.堆跟常量池到底什么关系?不是说在JDK1.7之后(含1.7版本)常量池已经移到了堆中了吗?如果说常量池本身就位于堆中的话,那么这种一个对象在堆中,一个对象在常量池的说法还准确吗?
如果你也产生过这些疑问的话,那么请耐心看完这篇文章!要解释上面的问题首先我们得对常量池有个准确的认知。
常量池
通常来说,我们提到的常量池分为三种
- class文件中的常量池
- 运行时常量池
- 字符串常量池
对于这三种常量池,我们需要搞懂下面几个问题?
- 这个常量池在哪里?
- 这个常量池用来干什么呢?
- 这三者有什么关系?
接下来,我们带着这些问题往下看
class文件中的常量池
位置在哪?
顾名思义,class文件中的常量池当然是位于class文件中,而class文件又是位于磁盘上。
用来干什么的?
在学习class文件中的常量池前,我们首选需要对class文件的结构有一定了解
Class文件是一组以8个字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在文
件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数
据,没有空隙存在。
------------《深入理解Java虚拟机》
整个class文件的组成可以用下图来表示
对本文而言,我们只关注其中的常量池部分,常量池可以理解为class文件中资源仓库,它是class文件结构中与其它项目关联最多的数据类型,主要用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。
字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等。
符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
现在我们知道了class文件中常量池的作用:存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。很多时候知道了一个东西的概念并不能说你会了,对于程序员而言,如果你说你已经会了,那么最好的证明是你能够通过代码将其描述出来,所以,接下来,我想以一种直观的方式让大家感受到常量池的存在。通过分析一段简单代码的字节码,让大家能更好感知常量池的作用。
talk is cheap ,show me code
我们以下面这段代码为例,通过javap来查看class文件中的具体内容,代码如下:
/** * @author 程序员DMZ * @Date Create in 22:59 2020/6/15 * @公众号 微信搜索:程序员DMZ */ public class Main { public static void main(String[] args) { String name = "dmz"; } }
进入Main.java文件所在目录,执行命令:javac Main.java,那么此时会在当前目录下生成对应的Main.class文件。再执行命令:javap -v -c Main.class,此时会得到如下的解析后的字节码信息
public class com.dmz.jvm.Main minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER // 这里就是常量池了 Constant pool: #1 = Methodref #4.#20 // java/lang/Object."<init>":()V #2 = String #21 // dmz #3 = Class #22 // com/dmz/jvm/Main #4 = Class #23 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 LocalVariableTable #10 = Utf8 this #11 = Utf8 Lcom/dmz/jvm/Main; #12 = Utf8 main #13 = Utf8 ([Ljava/lang/String;)V #14 = Utf8 args #15 = Utf8 [Ljava/lang/String; #16 = Utf8 name #17 = Utf8 Ljava/lang/String; #18 = Utf8 SourceFile #19 = Utf8 Main.java #20 = NameAndType #5:#6 // "<init>":()V #21 = Utf8 dmz #22 = Utf8 com/dmz/jvm/Main #23 = Utf8 java/lang/Object // 下面是方法表 { public com.dmz.jvm.Main(); 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 7: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lcom/dmz/jvm/Main; 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 // 可以看到方法表中的指令引用了常量池中的常量,这也是为什么说常量池是资源仓库的原因 // 因为它会被class文件中的其它结构引用 0: ldc #2 // String dmz 2: astore_1 3: return LineNumberTable: line 9: 0 line 10: 3 LocalVariableTable: Start Length Slot Name Signature 0 4 0 args [Ljava/lang/String; 3 1 1 name Ljava/lang/String; } SourceFile: "Main.java"
在上面的字节码中,我们暂且关注常量池中的内容即可。主要看这两行
#2 = String #14 // dmz #14 = Utf8 dmz
如果要看懂这两行代码,我们需要对常量池中String类型常量的结构有一定了解,其结构如下:
对应到我们上面的字节码中,tag=String,index=#14,所以我们可以知道,#2是一个字面量为#14的字符串类型常量。而#14对应的字面量信息(一个Utf8类型的常量)就是dmz。
常量池作为资源仓库,最大的用处在于被class文件中的其它结构所引用,这个时候我们再将注意力放到main方法上来,对应的就是这三条指令
0: ldc #2 // String dmz 2: astore_1 3: return
ldc:这个指令的作用是将对应的常量的引用压入操作数栈,在执行ldc指令时会触发对它的符号引用进行解析,在上面例子中对应的符号引用就是#2,也就是常量池中的第二个元素(这里就能看出方法表中就引用了常量池中的资源)
astore_1:将操作数栈底元素弹出,存储到局部变量表中的1号元素
return:方法返回值为void,标志方法执行完成,将方法对应栈帧从栈中弹出
下面我用画图的方式来画出整个流程,主要分为四步
- 解析ldc指令的符号引用(#2)
- 将#2对应的常量的引用压入到操作数栈顶
- 将操作数栈的元素弹出并存储到局部变量表中
- 执行return指令,方法执行结束,弹出栈区该方法对应的栈帧
第一步:
在解析#2这个符号引用时,会先到字符串常量池中查找是否存在对应字符串实例的引用,如果有的话,那么直接返回这个字符串实例的引用,如果没有的话,会创建一个字符串实例,那么将其添加到字符串常量池中(实际上是将其引用放入到一个哈希表中),之后再返回这个字符串实例对象的引用。
到这里也能回答我们之前提出的那个问题了,一个对象是new出来的,另外一个是在解析常量池的时候JVM自动创建的
第二步:
将第一步得到的引用压入到操作数栈,此时这个字符串实例同时被操作数栈以及字符串常量池引用。
第三步:
操作数栈中的引用弹出,并赋值给局部变量表中的1号位置元素,到这一步其实执行完了String name = "dmz"这行代码。此时局部变量表中储存着一个指向堆中字符串实例的引用,并且这个字符串实例同时也被字符串常量池引用。
第四步:
这一步我就不画图了,就是方法执行完成,栈帧弹出,非常简单。
在上文中,我多次提到了字符串常量池,它到底是个什么东西呢?我们还是分为两部分讨论
1.位置在哪?
2.用来干什么的?