题目
public static void main(String[] args) {
String str1 = "a";
String str2 = "b";
String str3 = "ab";
String str4 = "a" + "b";
String str5 = new String("ab");
String str6 = str5.intern();
String str7 = str1 + str2;
String str8 = str1 + "b";
String str9 = new String("a") + new String("b");
System.out.println("str3 == str4 :" + (str3 == str4));
System.out.println("str3 == str5 :" + (str3 == str5));
System.out.println("str3 == str6 :" + (str3 == str6));
System.out.println("str3 == str7 :" + (str3 == str7));
System.out.println("str3 == str8 :" + (str3 == str8));
System.out.println("str3 == str9 :" + (str3 == str9));
}
背景知识
要想解决以上的问题,需要一点JVM的知识,我们来看几个概念
1.常量池
java文件经过编译器编译后,生成class文件。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用来存放各种字面量和符号引用。可以使用javap -v命令反编译class文件,查看class文件中的常量池。下面只是截取了反编译结果的一部分,可以看到有一个Constant pool,就是常量池。
D:\workspace\target\classes\org\example\jvm\stringtable>javap -v StringTableTest.class
Classfile /D:/workspace/target/classes/org/example/jvm/stringtable/StringTableTest.class
Last modified 2021-3-6; size 1583 bytes
MD5 checksum dcee7d9fa800d5e955616c75868e56b6
Compiled from "StringTableTest.java"
public class org.example.jvm.stringtable.StringTableTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #15.#43 // java/lang/Object."<init>":()V
#2 = String #44 // a
#3 = String #45 // b
#4 = String #46 // ab
#5 = Class #47 // java/lang/String
#6 = Methodref #5.#48 // java/lang/String."<init>":(Ljava/lang/String;)V
#7 = Methodref #5.#49 // java/lang/String.intern:()Ljava/lang/String;
2.方法区
方法区,是各个线程共享的一块内存区域,用于存储已经被虚拟机加载的类信息、常量、静态变量等。所谓的方法区,是一个逻辑区域,不同的虚拟机厂商有不同的实现。以HotSpot为例,jdk1.6中方法区的实现是永久代,它位于jvm内存中;jdk1.8中方法区的实现是元空间(meta space),他本身不属于jvm的内存。
3.运行时常量池
运行时常量池是方法区的一部分,class中的常量池在类加载后会存放至运行时常量池。
4.StringTable(串池)
jvm中用于存储字符串对象的一个容器,底层采用HashTable的数据结构实现。jdk1.8及以后,StringTable位于堆空间中,主要是堆中的垃圾回收比方法区较频繁,可以将不使用的字符串及时清理。那么什么样的字符串才会放进这个容器呢?总的来说有两种,第一种是运行时常量池中的字符串;第二种是String对象主动调用了intern()方法,将自己放进串池中。串池的存在,可以减少创建重复的字符串,节省内存空间。
这里需要说明的一点是,运行时常量池中的字符串,在被程序使用之前,仅仅是一个符号,并不是一个可以直接使用的String对象。当程序使用常量池的字符串时,会首先去串池中找是否存在该对象,如果存在,直接返回串池中的对象;如果不存在,创建一个字符串对象,将其放入串池,然后返回串池中的对象。
intern()方法,对于不同版本的jdk,其实现略微有所差异。jdk1.6及以前,调用该方法将字符串放进串池,如果串池没有值相同的字符串对象,则将原字符串复制一份,放进串池,并返回串池中的对象;如果有值相同的字符串对象,直接返回串池中的对象。jdk1.8略有不同,调用该方法将字符串放进串池,如果串池没有值相同的字符串对象,则将原字符串对象放进串池,并返回串池中的对象;如果有值相同的字符串对象,直接返回串池中的对象。
解题
有了上面的基础知识,我们来解答一下开始的题目。
1. String str3 = "ab"
String str3 = "ab",编译以后会变成以下两条虚拟机指令。ldc #4,就是加载常量池#4位置的常量,也就是“ab”。astore_3,是将上一步得到的值存放至局部变量表的3号槽位。在程序运行时,会首先根据#4代表的真实地址,去运行时常量池找到对应的符号为“ab”。然后,以“ab”作为key,去串池中查找对应的字符串对象。显然不存在“ab”对象,这时会创建一个“ab”对象,放入串池中,最后返回这个对象。
6: ldc #4 // String ab
8: astore_3
2. String str4 = "a" + "b"
String str4 = "a" + "b",虽然源代码是"a" + "b",但是编译器会对这种两个常量相加的场景做优化,其结果就和str3完全一样。程序运行时,会首先根据#4代表的真实地址,去运行时常量池找到对应的符号为“ab”。然后,以“ab”作为key,去串池中查找对应的字符串对象,由于上一步已经在串池中存放了值为"ab"的对象,所以此时会直接返回串池中的"ab"对象。因此,str3 == str4。
9: ldc #4 // String ab
11: astore 4
3.String str5 = new String("ab")
String str5 = new String("ab"),根据编译后的虚拟机指令可以看出,这一行代码是在堆中创建了一个String对象,所以str5 != str3。
13: new #5 // class java/lang/String
16: dup
17: ldc #4 // String ab
19: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V
22: astore 5
4.String str6 = str5.intern()
根据之前分析的intern()方法的特点,str5.intern()尝试将str5放入串池,此时发现串池已经包含了值为"ab"的对象,所以该方法的返回值为串池中的"ab"对象,所以str3 == str6。
5.String str7 = str1 + str2
String str7 = str1 + str2编译后的虚拟机指令如下,我们来逐步分析下。第一步,创建一个StingBuilder对象;第二步,调用StringBuilder的初始化方法;第三步,加载1号槽位的变量,也就是str1,并将str1作为参数,调用StringBuilder的append方法;第四步,加载2号槽位的变量str2,并将str2作为参数,调用StringBuilder的append方法;第五步,调用StringBuilder的toString方法;第六步,将上一步的结果,存入7号槽位。通过跟踪StringBuilder的toString方法,发现该方法,其实是在堆中创建了一个字符串对象。因此,str3 != str7。
31: new #8 // class java/lang/StringBuilder
34: dup
35: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
38: aload_1
39: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
42: aload_2
43: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
46: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
49: astore 7
StringBuilder的toString方法
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}
6.String str8 = str1 + "b"
这种情况和上一种情况类似,唯一不同的是,"b"是直接从常量池读取的。因此,str3 != str8。
51: new #8 // class java/lang/StringBuilder
54: dup
55: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
58: aload_1
59: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
62: ldc #3 // String b
64: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
67: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
70: astore 8
7.String str9 = new String("a") + new String("b");
这种情况和上一种情况类似,不同的是,还在堆中创建了两个字符串对象。因此,str3 != str9。
72: new #8 // class java/lang/StringBuilder
75: dup
76: invokespecial #9 // Method java/lang/StringBuilder."<init>":()V
79: new #5 // class java/lang/String
82: dup
83: ldc #2 // String a
85: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V
88: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
91: new #5 // class java/lang/String
94: dup
95: ldc #3 // String b
97: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V
100: invokevirtual #10 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
103: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
106: astore 9
分析完了,验证下结果(需要注意的是,这是在jdk1.8下运行的,其他的版本结果可能会稍有不同)
str3 == str4 :true
str3 == str5 :false
str3 == str6 :true
str3 == str7 :false
str3 == str8 :false
str3 == str9 :false
附加题
public static void main(String[] args) {
String str5 = new String("a") + new String("b");
str5.intern();
String str3 = "ab";
System.out.println("str3 == str5 :" + (str3 == str5));
}
String str5 = new String("a") + new String("b")的执行流程如下:
-
创建一个StingBuilder对象。
-
调用StringBuilder的初始化方法。
-
加载运行时常量池的字符串"a",由于此时运行常量池的"a"还只是一个符号,所以会首先创建一个"a"对象,放入串池中,然后返回。
-
在堆中创建一个字符串对象,构造参数是"a"。
-
将上一步创建的对象作为参数,调用StringBuilder的append方法
-
加载运行时常量池的字符串"b",由于此时运行常量池的"b"还只是一个符号,所以会首先创建一个"b"对象,放入串池中,然后返回。
-
在堆中创建一个字符串对象,构造参数是"b"。
-
将上一步创建的对象作为参数,调用StringBuilder的append方法
-
调用StringBuilder的toString方法,在堆中创建一个值为"ab"的字符串对象。
str5.intern()将上一步在堆中创建的"ab"对象放入了串池。str3会在运行时常量池找到符号"ab",然后根据符号"ab"去串池中查找对象,发现已经有了,就直接返回串池中的"ab"对象。因此,str3 == str5。
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
7: new #4 // class java/lang/String
10: dup
11: ldc #5 // String a
13: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V
16: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: new #4 // class java/lang/String
22: dup
23: ldc #8 // String b
25: invokespecial #6 // Method java/lang/String."<init>":(Ljava/lang/String;)V
28: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
31: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
34: astore_1
35: aload_1
36: invokevirtual #10 // Method java/lang/String.intern:()Ljava/lang/String;
39: pop
40: ldc #11 // String ab
42: astore_2
总结
- 运行常量池中的字符串是延迟加载,只有在第一次访问的时候,才会创建一个字符串对象,并将这个对象放入常量池。
- 对于字符串相加的场景,编译器在编译时会做优化。如果时两个字符串常量直接相加,编译器会直接把相加的结果赋值给变量(String str = "a" + "b" ==> String str = "ab")。其他情况,都会创建一个StringBuilder对象,然后调用append和toString方法,最后在堆中创建一个字符串对象。
- intern()方法,调用该方法将字符串放进串池,如果串池没有值相同的字符串对象,则将原字符串对象放进串池,并返回串池中的对象;如果有值相同的字符串对象,直接返回串池中的对象。