前言
这道题是我之前的面试题文章《Java 基础高频面试题(2021年最新版)》里的第10题,今天通过字节码和流程图来跟大家详解一下完整的执行过程。
同时也会涉及一些字符串常量池的相关知识,这块内容网上现在的说法有太多错误了。
答案
首先直接说答案,一个比较合理的答案是:一个或者两个字符串对象,通常这个也是面试官想要听到的答案。
首先,new string 这边由于 new 关键字,所以这边肯定会在堆中新建一个字符串对象。
其次,如果字符串常量池中不存在 jionghui(equals比较)这个字符串,则会在字符串常量池中创建一个字符串对象。
注意:这边说的在字符串常量池创建对象,最终对象还是在堆中创建,字符串常量池只放引用。
例子1:String str1 = new String("jionghui")
本例子按照字符串常量池中不存在 jionghui 字符串来说。
该代码编译后其字节码如下:
0 new #2 <java/lang/String> 3 dup 4 ldc #3 <jionghui> 6 invokespecial #4 <java/lang/String.<init> : (Ljava/lang/String;)V> 9 astore_1 10 return
接下来我们解释下这些字节码
1)#2、#3、#4
字节码中这些带# 号的数字,是我们常量池里面的符号引用,这些符号引用会在类加载的解析阶段被解析为直接引用,直接引用可以理解为就是对象在内存中的地址。
这些符号引用对应的内容在后面已经给你列出来了。
#2 这边对应的是 java.lang.String 的 Class 类
#3 对应的 jionghui 字符串
#4 对应的 String 的初始化方法
2)new
new 关键字就是新建对象的意思,这边相当于会新建一个String 对象,但是此时还未初始化,是一个空对象。同时,这个字节码会将创建的对象的引用存放到操作数栈的栈顶。
执行完该指令后的结构:
3)dup
复制的意思,这边就是复制一份栈顶的元素。
这边在栈顶复制一个 String 的引用是因为后续调用 String 的初始化方法会消耗掉栈里的一个引用,所以这边提前复制一份出来,最后才有引用可以赋值给局部变量表的str1。
执行完该指令后的结构:
4)ldc
将int、float或String型常量值从常量池中推送至栈顶,这个地方 ldc 指令会附带另外一个功能:触发符号引用解析为直接引用。
我们上文说过符号引用会在解析阶段被解析成直接引用,但是有一些特例。字符串对象就是一个特例,字符串对象不会在解析阶段就将符号引用解析成直接引用,而是等到某个“合适的时机”才去解析,这边的 ldc 就是这个时机。
PS:下面的例子5会验证这个说法。
因此ldc 在这边会做两件事:
1、判断符号引用是否已经解析成了直接引用,如果没有,则会进行解析:判断 jionghui 字符串是否已经在字符串常量池存在,如果存在则将符号引用解析成字符串常量池的引用;如果不存在,则会在字符串常量池中创建一个jionghui 字符串对象,然后同样将符号引用解析成字符串常量池的引用。
2、将对应的字符串常量池推送到栈顶。
执行完该指令后的结构:
5)invokespecial
调用超类构造方法,实例初始化方法,私有方法。
在这边用于调用String 的初始化方法,我们上面通过 new 关键词创建的是个空对象,还未进行初始化。
这边初始化会使用到我们栈顶的两个元素,一个元素指向我们要初始化的对象,另一个元素指向我们初始化使用的参数。
这边初始化完毕后,这个空字符串对象会被初始化成 jionghui 字符串对象。
执行完该指令后的结构:
6)astore_1
将栈顶引用元素存到指定本地变量。
这边最后将栈顶的这个引用存放到本地变量表找那个的 str1 变量。
执行完该指令后的结构:
执行完毕后,最终如上图所示。可以看到最终就是创建了两个对象,一个是是通过new string 创建出来的这个对象,它的引用被复赋值给str1,另外一个是在常量池里创建的字符串对象。
例子2:String str2 = "jionghui"
这个例子就是例子1的简版,去掉了 new String 的过程,其他基本一样。
执行结束的内存结构如下图所示:
例子3:String str3 = "jiong" + "hui";
该例子在编译后,这2个字符串会被自动合并成 jionghui,所以最终跟例子2完全一样,编译后的字节码都是完全一样的。
执行结束的内存结构如下图所示:
例子4:String str4 = new String("jiong") + "hui"
核心流程如下:
1)双引号修饰的字面量 jiong 和 hui 分别会在字符串常量池中创建字符串对象
2)new String 关键字会再创建一个 jiong 字符串对象
3)最后这个字符串拼接,这个地方不看字节码的话很难看出究竟是怎么拼接的,通过字节码一下子就看出来了,这边是通过 StringBuilder 来进行字符串的拼接操作,先创建了一个StringBuilder,然后 append("jiong"),然后 append("hui"),最后执行toString 返回,这边 toString 底层是通过 new String 方法返回,所以最终这边拼接也会创建一个新的字符串。
字节码如下:用到的命令都是上文提过的。
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 <jiong> 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 ldc #8 <hui> 21 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;> 24 invokevirtual #9 <java/lang/StringBuilder.toString : ()Ljava/lang/String;> 27 astore_1 28 return
执行结束的内存结构如下图所示:
例子5:intern 测试1
String str5 = new String("1") + new String("1"); str5.intern(); String str6 = "11"; System.out.println(str5 == str6);
intern:如果字符串常量池中存在当前字符串, 则返回常量池中的字符串引用。否则, 将该字符串放入常量池,然后返回该字符串对象的引用。
intern 在 JDK6 和JDK7 及之后的版本有些不同,这边会简单说下不同的地方。
JDK7下的核心流程如下:
1)双引号修饰的字面量 1 会在字符串常量池中创建字符串对象,这边有2个字面量1,但是只会创建1次,另一个直接复用
2)两个 new String 创建了2个字符串对象 1
3)字符串拼接通过 StringBuilder 创建出1个新的字符串对象11,并将引用赋值给 str5
4)str5 调用 intern 方法,检查到字符串常量池还没有字符串11,则将字符串对象放入常量池,此时字符串常量池中的 11 就是 str5 指向的字符串对象
5)双引号修饰的字面量 11 检查到字符串常量池中已经存在字符串 11,则直接使用字符串常量池中的对象,所以 str6 被赋值为字符串常量池中的对象引用,也就是str5的引用
6)输出结果为 true
执行结束的内存结构如下图所示:
而JDK6 下的流程有什么不同呢,主要在于 JDK6 版本还存在永久代的概念,字符串常量池指向的字符串对象在JDK6 中是在永久代创建的,JDK7才被移动到堆中。
所以当执行str5.intern 时,发现永久代中没有字符串11,则会在永久代创建字符串对象11,后续的 str6 也是指向永久代的字符串对象。所以,此时str5 和 str6 指向的不同对象。
因此,JDK6的输出结果为 false。
执行结束的内存结构如下图所示:
验证字符串对象在运行中在解析符号引用
这个例子还能验证我们上面说的:字符串的符号引用在运行阶段才被解析成直接引用的说法。
我们假设字符串的符号引用也是在类加载的解析阶段就解析成直接引用了,那么这个例子的流程如下(JDK7及之后版本):
1)解析阶段,双引号修饰的字面量 1 和 11 会在字符串常量池中创建字符串对象
2)两个 new String 创建了2个字符串对象 1
3)字符串拼接通过 StringBuilder 创建出1个新的字符串对象11,并将引用赋值给 str5
4)str5 调用 intern 方法,检查到字符串常量池存在字符串11,则不做任何操作
5)str6 被赋值为字符串常量池中的对象引用,此时str6 和 str5 指向的是不同的字符串对象
6)输出结果为 false
本例在JDK7及之后版本的输出结果为 true,验证了我们的说法。
字符串常量池中的字符串对象使用懒加载在 JVM 源码中是有明确注释的,同时 R 大也在某论坛上说过。
例子6:intern 测试2
这个例子就是将例子5的2和3行代码调换了下顺序,验证一下 intern 方法的返回值。
String str7 = new String("1") + new String("1"); String str8 = "11"; String str9 = str7.intern(); System.out.println(str7 == str8); System.out.println(str8 == str9);
核心流程如下:
1)双引号修饰的字面量 1 会在字符串常量池中创建字符串对象,这边有2个字面量1,但是只会创建1次,另一个直接复用
2)两个 new String 创建了2个字符串对象 1
3)字符串拼接通过 StringBuilder 创建出1个新的字符串对象11,并将引用赋值给 str7
3)双引号修饰的字面量 11 会在字符串常量池中创建字符串对象,并将引用赋值给str8
4)str7 调用 intern 方法,检查到字符串常量池存在字符串11,则不做任何操作,同时返回字符串常量池的引用,并赋值给 str9,也就是 str8 指向的引用
5)输出结果为 false 和 true
执行结束的内存结构如下图所示: