彻底搞清楚 `String` 和 `字符串常量池`
导言
本文将根据几个实际 Case 作为切入点,展开分析影响各个 Case 运行结果的关键因素。 之后,将具体分析每个 Case 运行逻辑,分析会结合 JVM 内存结构和字节码相关知识。 最终,通过各个 Case 的分析,彻底了解字符串这个问题。
Case Test
1、 大家先看下下面的代码,并思考下运行结果,然后再对比实际运行结果。
package com.zhawa;
import java.util.Scanner;
public class StringTest {
public static void main(String[] args) {
// 目标字符串
String targetStr = "hello world";
// Case 1: 定义字面量字符串,对比目标字符串结果
String var = "hello world";
System.out.println(var == targetStr);
// Case 2: 定义字符串对象,对比目标字符串结果
String obj = new String("hello world");
System.out.println(obj == targetStr);
// Case 3: 定义字面量连接,对比目标字符串结果
String literalConcat = "hello" + " " + "world";
System.out.println(literalConcat == targetStr);
// Case 4: 定义字面量和字符串对象连接,对比目标字符串结果
String world = " world";
String mixConcat = "hello" + world;
System.out.println(mixConcat == targetStr);
// Case 5: 接收外部输入 hello world,对比目标字符串结果(模拟真实项目中 RPC 调用获得的字符串)
Scanner sc = new Scanner(System.in);
System.out.print("enter string: ");
String inputStr = sc.nextLine();
System.out.println(inputStr == targetStr);
}
}
以下是各个 Case 实际运行结果
Case 1 = true
Case 2 = false
Case 3 = true
Case 4 = false
## 控制台输入 hello world ##
Case 5 = false
2、影响 Case 运行结果的关键因素 - 字符串常量池
都是字符串比较,为啥会有这么大的差异呢?其实这其中影响运行结果的关键因素是字符串常量池
。
下面这个截图是 Java SE 8 虚拟机规范关于字符串的定义:
Java SE 8 虚拟机规范官方链接:docs.oracle.com/javase/spec…
大概意思:
字符串常量指向的是 String 类实例的引用,它来自于 class 文件常量池的 CONSTANT_String_info
结构。
虚拟机规范还规定了,相同字符串常量必须指向同一个 String 类实例,此外,如果任意字符串调用 String.intern()
方法,其返回结果所指向的那个实例,必须和常量池指向的字符串实例完全相等。
这句话比较拗口,大家可以根据下面的代码去理解这句话:
("a" + "b" + "c").intern() == "abc" // 这段代码运行结果必定是 true
那根据虚拟机规范的定义,放进常量池数据大概可以分为两类:
- 虚拟机自己放进去的
- 程序通过调用 String.intern 方法放进去的
调用 String.intern() 放进去的很明确,是我们自己放进去的。虚拟机放进去的是个啥? 这个其实可以通过查看字节码文件一探究竟。
通过 javap
命令反编译上面 StringTest class 文件会看到以下内容:
Classfile StringTest.class
Last modified 2022-8-24; size 1875 bytes
MD5 checksum c8dfd5dc7915e0e13ecc7a359a4c8c6a
Compiled from "StringTest.java"
public class com.zhawa.StringTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #27.#57 // java/lang/Object."<init>":()V
#2 = String #58 // hello world
#3 = Methodref #12.#59 // java/lang/String.intern:()Ljava/lang/String;
#4 = Fieldref #60.#61 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Class #62 // java/lang/StringBuilder
#6 = Methodref #5.#57 // java/lang/StringBuilder."<init>":()V
#7 = String #63 // Case 1:
.... 省略后面的内容 ....
可以看到,虚拟机放进去的就是 Constant pool 中所有 String 类型的常量。
那基本上就搞清楚字符串常量池大概是个啥了,接下来就开始分析各个 Case 运行原理。
Case 分析
Case 1
Part 1:代码
// 目标字符串
String targetStr = "hello world";
// Case 1: 定义字面量字符串,对比目标字符串结果
String var = "hello world";
System.out.println(var == targetStr);
Part 2:过程推演 编译阶段:
- "hello world" 代码在编译时会被构建成
CONSTANT_String_info
结构,同时会被加入到 Constant pool 中。
执行阶段:
- 将常量池中的 "hello world" 字符串引用赋值给
targetStr
和var
变量。 - 此时,
targetStr
和var
变量同时指向常量池中的 "hello world",所以执行结果是true
。
Part 3:结论验证
下面是字节码反编译后的内容,双横杠(--) 后是我的注释:
... 省略不重要的内容后 ....
Constant pool:
#1 = Methodref #6.#27 // java/lang/Object."<init>":()V
-- 代码中的 hello world 字面量
#2 = String #28 // hello world
{
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
-- 从常量池获得 #2(hello world) 常量,并推入栈顶
0: ldc #2 // String hello world
-- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
2: astore_1
-- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
-- 从常量池获得 #2(hello world) 常量,并推入栈顶
3: ldc #2 // String hello world
-- 将栈顶的 hello world 常量引用存到 slot2 的局部变量表中
5: astore_2
-- ^^ 以上两行是 String var = "hello world"; 编译后的汇编指令
6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
9: aload_2
10: aload_1
11: if_acmpne 18
14: iconst_1
15: goto 19
18: iconst_0
19: invokevirtual #4 // Method java/io/PrintStream.println:(Z)V
22: return
-- ^^ 上面这些是 System.out.println(var == targetStr); 编译后的汇编指令
}
结论:从字节码汇编指令执行逻辑可以得出,var
和 targetStr
都指向常量池中的 "hello world" 字符串,因为地址相同,所以的比较结果是 true。
Case 2
Part 1:代码
// 目标字符串
String targetStr = "hello world";
// Case 2: 定义字符串对象,对比目标字符串结果
String obj = new String("hello world");
System.out.println(obj == targetStr);
Part 2:过程推演 编译阶段: ...与 Case 1 一致...
执行阶段:
- 将 "hello world" 赋值给
targetStr
变量,与 Case 1 一致。 - 在堆中为 String 分配内存,调用 String 构造函数,同时传入常量池 "hello world" 字符串引用。
- String 对象将自己的
value
和hash
指向常量池字符串的value
和hash
。 - 此时,
targetStr
指向常量池字符串,obj
变量指向堆中字符串。两个变量指向的地址不同,所以运行结果是 false。
我看网上有很多文章图文并茂的描述了堆中字符串是指向常量池的,但又没有说是怎么指向的。 关于字符串的指向关系可以通过 String 字符串构造函数就能看出端倪。 下面是 String 的有参构造函数:
public String(String original) {
this.value = original.value;
this.hash = original.hash;
}
可以看 new 出来的字符串是把自己的 value
和 hash
指向常量池字符串的 value
和 hash
。
Part 3:结论验证
... 省略不重要的内容后 ....
{
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
-- 从常量池获得 #2(hello world) 常量,并推入栈顶
0: ldc #2 // String hello world
-- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
2: astore_1
-- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
-- 创建 String 对象
3: new #3 // class java/lang/String
-- 将 String 对象推到栈顶
6: dup
-- 从常量池获得 #2(hello world) 常量,并推入栈顶
7: ldc #2 // String hello world
-- 调用 String 实例化构造函数,同时把栈顶的 hello world 传给 String
9: invokespecial #4 // Method java/lang/String."<init>":(Ljava/lang/String;)V
-- 将栈顶的 hello world 常量引用存到 slot2 的局部变量表中
12: astore_2
-- ^^ 以是 String obj = new String("hello world"); 编译后的汇编指令
.... 省略后面的 System.out.println(obj == targetStr); 汇编指令 ....
}
Case 3
Part 1:代码
// 目标字符串
String targetStr = "hello world";
// Case 3: 定义字面量连接,对比目标字符串结果
String literalConcat = "hello" + " " + "world";
System.out.println(literalConcat == targetStr);
Part 2:过程推演 编译阶段:
- 这个 Case 在编译阶段,编译器会对代码进行优化,会把 "hello" + " " + "world" 优化成 "hello world"。
- 剩下的动作就和 Case 1 一致了。
执行阶段:
targetStr
变量赋值逻辑与 Case 1 一致。- 因为编译器会进行代码优化,所以会把优化后的 "hello world" 赋值给
literalConcat
。 - 此时
targetStr
和literalConcat
同时指向常量池字符串引用,所以运行结果是 true。
Part 3:结论验证
... 省略不重要的内容后 ....
{
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
-- 从常量池获得 #2(hello world) 常量,并推入栈顶
0: ldc #2 // String hello world
-- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
2: astore_1
-- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
-- 从常量池获得 #2(hello world) 常量,并推入栈顶
3: ldc #2 // String hello world
-- 将栈顶的 hello world 常量引用存到 slot2 的局部变量表中
5: astore_2
-- ^^ 以上两行是 String literalConcat = "hello" + " " + "world"; 编译后的汇编指令
.... 省略后面的 System.out.println(literalConcat == targetStr); 汇编指令 ....
}
Case 4
Part 1:代码
// 目标字符串
String targetStr = "hello world";
// Case 4: 定义字面量和字符串对象连接,对比目标字符串结果
String world = " world";
String mixConcat = "hello" + world;
System.out.println(mixConcat == targetStr);
Part 2:过程推演 编译阶段:
- "hello world" 加入常量池逻辑跟 Case 1 一致。除了 "hello world" 以外,"hello" 和 " world" 也会加入到常量池。
- 此外,
mixConcat
指向的是 "hello" 字面量和 world 字符串的拼接结果,对于字符串拼接,编译器会使用 StringBuilder 进行拼接,最后将 StringBuilder.toString() 的结果赋值给mixConcat
。
执行阶段:
targetStr
变量赋值逻辑与 Case 1 一致。mixConcat
变量指向 "hello" 常量池字符串和world
变量的拼接结果。- 因为编译器使用的是 StringBuilder 进行拼接的,StringBuilder 所有操作都是在堆中操作的,所以
mixConcat
指向堆中的字符串。 - 最终,
mixConcat
指向的是堆中的 "hello world" 字符串,targetStr
指向的是常量池中的 "hello world",两个变量指向的地址不同,所以运行结果是 false。
Part 3:结论验证
... 省略不重要的内容后 ....
{
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=1
-- 从常量池获得 #2(hello world) 常量,并推入栈顶
0: ldc #2 // String hello world
-- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
2: astore_1
-- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
-- 从常量池获得 #3(world) 常量,并推入栈顶
3: ldc #3 // String world
-- 将栈顶的 world 常量引用存到 slot2 的局部变量表中
5: astore_2
-- ^^ 以上两行是 String world = " world"; 编译后的汇编指令
-- 创建 StringBuilder 对象
6: new #4 // class java/lang/StringBuilder
-- 将 StringBuilder 对象推到栈顶
9: dup
-- 实例化 StringBuilder 对象
10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V
-- 从常量池获得 #6(hello) 常量,并推入栈顶
13: ldc #6 // String hello
-- 调用 StringBuilder.append 方法,并传入 hello 字符串引用
15: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
-- 加载 slot2 变量槽变量(world 变量)
18: aload_2
-- 调用 StringBuilder.append 方法,并传入 world 变量引用
19: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
-- 调用 StringBuilder.toString 方法
22: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
-- 将 StringBuilder.toString 返回的引用存到 slot3 变量槽
25: astore_3
-- ^^ 以上是 String mixConcat = "hello" + world; 编译后的汇编指令
.... 省略后面的 System.out.println(mixConcat == targetStr); 汇编指令 ....
}
Case 5
Part 1:代码
// 目标字符串
String targetStr = "hello world";
// Case 5: 接收外部输入 hello world,对比目标字符串结果(模拟真实项目中 RPC 调用获得的字符串)
Scanner sc = new Scanner(System.in);
System.out.print("enter string: ");
String inputStr = sc.nextLine();
System.out.println(inputStr == targetStr);
Part 2:过程推演 编译阶段:
- "hello world" 加入常量池逻辑还是一样。
- 此外,还有 "enter string: " 也需要加入常量池,因为它是一个字面量。
执行阶段:
- 略过
targetStr
执行逻辑。 - 初始化一个 Scanner,用来接收输入。
- 调用 Scanner.nextLine() 获取控制台输入,此时的输入的字符串是运行时产生的,非字面量,所以会在堆中分配内存。
- 将控制台获得字符串赋值给
inputStr
。 - 此时,
targetStr
指向的是常量池中的 "hello world",inputStr
指向堆中的字符串,所以最终运行结果是 false。
Part 3:结论验证
... 省略不重要的内容后 ....
{
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=4, args_size=1
-- 从常量池获得 #2(hello world) 常量,并推入栈顶
0: ldc #2 // String hello world
-- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
2: astore_1
-- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
3: new #3 // class java/util/Scanner
6: dup
7: getstatic #4 // Field java/lang/System.in:Ljava/io/InputStream;
10: invokespecial #5 // Method java/util/Scanner."<init>":(Ljava/io/InputStream;)V
13: astore_2
14: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
17: ldc #7 // String enter string:
19: invokevirtual #8 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
22: aload_2
23: invokevirtual #9 // Method java/util/Scanner.nextLine:()Ljava/lang/String;
26: astore_3
-- ^^ 以汇编指令对应以下代码,就不一行行解释了。
/**
* Scanner sc = new Scanner(System.in);
* System.out.print("enter string: ");
* String inputStr = sc.nextLine();
**/
.... 省略后面的 System.out.println(inputStr == targetStr); 汇编指令 ....
}
到这,所有 Case 就分析完啦,接下来总结下。
总结
- 影响字符串比较的的关键因素是
字符串常量池
,在面试中经常问到的字符串比较问题,主要考察的点也是这个。 - 字符串常量池存储的字符串分两类,一类是虚拟机自己放进去的,另外一类是程序调用 String.intern() 方法放进去的。
- 虚拟机自己放进去的,主要是虚拟机内部自己用的一些值(符号引用啥的)。
- 另外一部分代是码中的字符串字面量,也就是我们在代码中写的静态字符串 "hello world"。
- 下面是根据上面 Case 分析得出的字符串存在常量池的几种情况。
- 代码中定义的字符串字面量,例如:
String str = "hello world";
- 调用
String.intern()
方法,例如把运行时得到的一个城市名放进常量池:cityName.intern()
- 编译器优化后的字面量字符串连接,例如:
String str = "hello" + " " + "world";
- 代码中定义的字符串字面量,例如:
关于 intern 方法,Twitter 曾在 QCon 分享过使用 intern 方法优化了十几G的内存案例,感兴趣的朋友可以搜下。
转载自:https://juejin.cn/post/7135700218065977352