本文将根据几个实际 Case 作为切入点,展开分析影响各个 Case 运行结果的关键因素。
之后,将具体分析每个 Case 运行逻辑,分析会结合 JVM 内存结构和字节码相关知识。
最终,通过各个 Case 的分析,彻底了解字符串这个问题。
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() 放进去的很明确,是我们自己放进去的。虚拟机放进去的是个啥?
这个其实可以通过查看字节码文件一探究竟。
通过 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."
" :()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."
" :()V - #7 = String #63 // Case 1:
- .... 省略后面的内容 ....
- 复制代码
可以看到,虚拟机放进去的就是 Constant pool 中所有 String 类型的常量。
那基本上就搞清楚字符串常量池大概是个啥了,接下来就开始分析各个 Case 运行原理。
Part 1:代码
- // 目标字符串
- String targetStr = "hello world";
-
- // Case 1: 定义字面量字符串,对比目标字符串结果
- String var = "hello world";
- System.out.println(var == targetStr);
- 复制代码
Part 2:过程推演
编译阶段:
CONSTANT_String_info
结构,同时会被加入到 Constant pool 中。执行阶段:
targetStr
和 var
变量。targetStr
和 var
变量同时指向常量池中的 "hello world",所以执行结果是 true
。Part 3:结论验证
下面是字节码反编译后的内容,双横杠(--) 后是我的注释:
- ... 省略不重要的内容后 ....
- Constant pool:
- #1 = Methodref #6.#27 // java/lang/Object."
" :()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。
Part 1:代码
- // 目标字符串
- String targetStr = "hello world";
-
- // Case 2: 定义字符串对象,对比目标字符串结果
- String obj = new String("hello world");
- System.out.println(obj == targetStr);
- 复制代码
Part 2:过程推演
编译阶段:
...与 Case 1 一致...
执行阶段:
targetStr
变量,与 Case 1 一致。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."
" :(Ljava/lang/String;)V - -- 将栈顶的 hello world 常量引用存到 slot2 的局部变量表中
- 12: astore_2
- -- ^^ 以是 String obj = new String("hello world"); 编译后的汇编指令
-
- .... 省略后面的 System.out.println(obj == targetStr); 汇编指令 ....
-
- }
- 复制代码
Part 1:代码
- // 目标字符串
- String targetStr = "hello world";
-
- // Case 3: 定义字面量连接,对比目标字符串结果
- String literalConcat = "hello" + " " + "world";
- System.out.println(literalConcat == targetStr);
- 复制代码
Part 2:过程推演
编译阶段:
执行阶段:
targetStr
变量赋值逻辑与 Case 1 一致。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); 汇编指令 ....
- }
- 复制代码
Part 1:代码
- // 目标字符串
- String targetStr = "hello world";
-
- // Case 4: 定义字面量和字符串对象连接,对比目标字符串结果
- String world = " world";
- String mixConcat = "hello" + world;
- System.out.println(mixConcat == targetStr);
- 复制代码
Part 2:过程推演
编译阶段:
mixConcat
指向的是 "hello" 字面量和 world 字符串的拼接结果,对于字符串拼接,编译器会使用 StringBuilder 进行拼接,最后将 StringBuilder.toString() 的结果赋值给 mixConcat
。执行阶段:
targetStr
变量赋值逻辑与 Case 1 一致。mixConcat
变量指向 "hello" 常量池字符串和 world
变量的拼接结果。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."
" :()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); 汇编指令 ....
- }
- 复制代码
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:过程推演
编译阶段:
执行阶段:
targetStr
执行逻辑。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."
" :(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 str = "hello world";
String.intern()
方法,例如把运行时得到的一个城市名放进常量池:cityName.intern()
String str = "hello" + " " + "world";
关于 intern 方法,Twitter 曾在 QCon 分享过使用 intern 方法优化了十几G的内存案例,感兴趣的朋友可以搜下。