序文
この記事では、出発点としていくつかの実際のケースに基づいて、各ケースの実行結果に影響を与える主な要因を分析します。
その後、各ケース操作ロジックを詳細に分析し、分析を JVM メモリ構造とバイトコードの知識と組み合わせます。
最後に、各ケースの分析を通じて、文字列の問題を完全に理解します。
ケーステスト
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 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…
おおよそ
の意味: 文字列定数は、クラス ファイルの定数プールのCONSTANT_String_info
構造。
The virtual machine specification also stipulates that the same string constant must point to the same instance of the String class. さらに、任意の文字列がString.intern()
メソッド、戻り結果が指すインスタンスは、指す文字列インスタンスと正確に等しくなければなりません一定のプールで。
この文はややぎこちなく、次のコードに従ってこの文を理解できます。
("a" + "b" + "c").intern() == "abc" // 这段代码运行结果必定是 true
复制代码
仮想マシン仕様の定義によると、定数プールに配置されるデータは、次の 2 つのカテゴリに大別できます。
- 仮想マシン自体
- プログラムは 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的内存案例,感兴趣的朋友可以搜下。