• 彻底搞清楚 `String` 和 `字符串常量池`


    导言

    本文将根据几个实际 Case 作为切入点,展开分析影响各个 Case 运行结果的关键因素。
    之后,将具体分析每个 Case 运行逻辑,分析会结合 JVM 内存结构和字节码相关知识。
    最终,通过各个 Case 的分析,彻底了解字符串这个问题。

    Case Test

    1、 大家先看下下面的代码,并思考下运行结果,然后再对比实际运行结果。

    1. package com.zhawa;
    2. import java.util.Scanner;
    3. public class StringTest {
    4. public static void main(String[] args) {
    5. // 目标字符串
    6. String targetStr = "hello world";
    7. // Case 1: 定义字面量字符串,对比目标字符串结果
    8. String var = "hello world";
    9. System.out.println(var == targetStr);
    10. // Case 2: 定义字符串对象,对比目标字符串结果
    11. String obj = new String("hello world");
    12. System.out.println(obj == targetStr);
    13. // Case 3: 定义字面量连接,对比目标字符串结果
    14. String literalConcat = "hello" + " " + "world";
    15. System.out.println(literalConcat == targetStr);
    16. // Case 4: 定义字面量和字符串对象连接,对比目标字符串结果
    17. String world = " world";
    18. String mixConcat = "hello" + world;
    19. System.out.println(mixConcat == targetStr);
    20. // Case 5: 接收外部输入 hello world,对比目标字符串结果(模拟真实项目中 RPC 调用获得的字符串)
    21. Scanner sc = new Scanner(System.in);
    22. System.out.print("enter string: ");
    23. String inputStr = sc.nextLine();
    24. System.out.println(inputStr == targetStr);
    25. }
    26. }
    27. 复制代码

    以下是各个 Case 实际运行结果

    1. Case 1 = true
    2. Case 2 = false
    3. Case 3 = true
    4. Case 4 = false
    5. ## 控制台输入 hello world ##
    6. Case 5 = false
    7. 复制代码

    2、影响 Case 运行结果的关键因素 - 字符串常量池
    都是字符串比较,为啥会有这么大的差异呢?其实这其中影响运行结果的关键因素是字符串常量池

    下面这个截图是 Java SE 8 虚拟机规范关于字符串的定义:

    Java SE 8 虚拟机规范官方链接:docs.oracle.com/javase/spec…

    大概意思:
    字符串常量指向的是 String 类实例的引用,它来自于 class 文件常量池的 CONSTANT_String_info 结构。

    虚拟机规范还规定了,相同字符串常量必须指向同一个 String 类实例,此外,如果任意字符串调用 String.intern() 方法,其返回结果所指向的那个实例,必须和常量池指向的字符串实例完全相等。

    这句话比较拗口,大家可以根据下面的代码去理解这句话:

    1. ("a" + "b" + "c").intern() == "abc" // 这段代码运行结果必定是 true
    2. 复制代码

    那根据虚拟机规范的定义,放进常量池数据大概可以分为两类:

    • 虚拟机自己放进去的
    • 程序通过调用 String.intern 方法放进去的

    调用 String.intern() 放进去的很明确,是我们自己放进去的。虚拟机放进去的是个啥?
    这个其实可以通过查看字节码文件一探究竟。

    通过 javap 命令反编译上面 StringTest class 文件会看到以下内容:

    1. Classfile StringTest.class
    2. Last modified 2022-8-24; size 1875 bytes
    3. MD5 checksum c8dfd5dc7915e0e13ecc7a359a4c8c6a
    4. Compiled from "StringTest.java"
    5. public class com.zhawa.StringTest
    6. minor version: 0
    7. major version: 52
    8. flags: ACC_PUBLIC, ACC_SUPER
    9. Constant pool:
    10. #1 = Methodref #27.#57 // java/lang/Object."":()V
    11. #2 = String #58 // hello world
    12. #3 = Methodref #12.#59 // java/lang/String.intern:()Ljava/lang/String;
    13. #4 = Fieldref #60.#61 // java/lang/System.out:Ljava/io/PrintStream;
    14. #5 = Class #62 // java/lang/StringBuilder
    15. #6 = Methodref #5.#57 // java/lang/StringBuilder."":()V
    16. #7 = String #63 // Case 1:
    17. .... 省略后面的内容 ....
    18. 复制代码

    可以看到,虚拟机放进去的就是 Constant pool 中所有 String 类型的常量。

    那基本上就搞清楚字符串常量池大概是个啥了,接下来就开始分析各个 Case 运行原理。

    Case 分析

    Case 1

    Part 1:代码

    1. // 目标字符串
    2. String targetStr = "hello world";
    3. // Case 1: 定义字面量字符串,对比目标字符串结果
    4. String var = "hello world";
    5. System.out.println(var == targetStr);
    6. 复制代码

    Part 2:过程推演
    编译阶段

    1. "hello world" 代码在编译时会被构建成 CONSTANT_String_info 结构,同时会被加入到 Constant pool 中。

    执行阶段

    1. 将常量池中的 "hello world" 字符串引用赋值给 targetStrvar 变量。
    2. 此时,targetStrvar 变量同时指向常量池中的 "hello world",所以执行结果是 true

    Part 3:结论验证

    下面是字节码反编译后的内容,双横杠(--) 后是我的注释:

    1. ... 省略不重要的内容后 ....
    2. Constant pool:
    3. #1 = Methodref #6.#27 // java/lang/Object."":()V
    4. -- 代码中的 hello world 字面量
    5. #2 = String #28 // hello world
    6. {
    7. public static void main(java.lang.String[]);
    8. descriptor: ([Ljava/lang/String;)V
    9. flags: ACC_PUBLIC, ACC_STATIC
    10. Code:
    11. stack=3, locals=3, args_size=1
    12. -- 从常量池获得 #2(hello world) 常量,并推入栈顶
    13. 0: ldc #2 // String hello world
    14. -- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
    15. 2: astore_1
    16. -- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
    17. -- 从常量池获得 #2(hello world) 常量,并推入栈顶
    18. 3: ldc #2 // String hello world
    19. -- 将栈顶的 hello world 常量引用存到 slot2 的局部变量表中
    20. 5: astore_2
    21. -- ^^ 以上两行是 String var = "hello world"; 编译后的汇编指令
    22. 6: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
    23. 9: aload_2
    24. 10: aload_1
    25. 11: if_acmpne 18
    26. 14: iconst_1
    27. 15: goto 19
    28. 18: iconst_0
    29. 19: invokevirtual #4 // Method java/io/PrintStream.println:(Z)V
    30. 22: return
    31. -- ^^ 上面这些是 System.out.println(var == targetStr); 编译后的汇编指令
    32. }
    33. 复制代码

    结论:从字节码汇编指令执行逻辑可以得出,vartargetStr 都指向常量池中的 "hello world" 字符串,因为地址相同,所以的比较结果是 true

    Case 2

    Part 1:代码

    1. // 目标字符串
    2. String targetStr = "hello world";
    3. // Case 2: 定义字符串对象,对比目标字符串结果
    4. String obj = new String("hello world");
    5. System.out.println(obj == targetStr);
    6. 复制代码

    Part 2:过程推演
    编译阶段
    ...与 Case 1 一致...

    执行阶段

    1. 将 "hello world" 赋值给 targetStr 变量,与 Case 1 一致。
    2. 在堆中为 String 分配内存,调用 String 构造函数,同时传入常量池 "hello world" 字符串引用。
    3. String 对象将自己的 valuehash 指向常量池字符串的 valuehash
    4. 此时,targetStr 指向常量池字符串,obj 变量指向堆中字符串。两个变量指向的地址不同,所以运行结果是 false

    我看网上有很多文章图文并茂的描述了堆中字符串是指向常量池的,但又没有说是怎么指向的。
    关于字符串的指向关系可以通过 String 字符串构造函数就能看出端倪。
    下面是 String 的有参构造函数:

    1. public String(String original) {
    2. this.value = original.value;
    3. this.hash = original.hash;
    4. }
    5. 复制代码

    可以看 new 出来的字符串是把自己的 valuehash 指向常量池字符串的 valuehash

    Part 3:结论验证

    1. ... 省略不重要的内容后 ....
    2. {
    3. public static void main(java.lang.String[]);
    4. descriptor: ([Ljava/lang/String;)V
    5. flags: ACC_PUBLIC, ACC_STATIC
    6. Code:
    7. stack=3, locals=3, args_size=1
    8. -- 从常量池获得 #2(hello world) 常量,并推入栈顶
    9. 0: ldc #2 // String hello world
    10. -- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
    11. 2: astore_1
    12. -- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
    13. -- 创建 String 对象
    14. 3: new #3 // class java/lang/String
    15. -- 将 String 对象推到栈顶
    16. 6: dup
    17. -- 从常量池获得 #2(hello world) 常量,并推入栈顶
    18. 7: ldc #2 // String hello world
    19. -- 调用 String 实例化构造函数,同时把栈顶的 hello world 传给 String
    20. 9: invokespecial #4 // Method java/lang/String."":(Ljava/lang/String;)V
    21. -- 将栈顶的 hello world 常量引用存到 slot2 的局部变量表中
    22. 12: astore_2
    23. -- ^^ 以是 String obj = new String("hello world"); 编译后的汇编指令
    24. .... 省略后面的 System.out.println(obj == targetStr); 汇编指令 ....
    25. }
    26. 复制代码

    Case 3

    Part 1:代码

    1. // 目标字符串
    2. String targetStr = "hello world";
    3. // Case 3: 定义字面量连接,对比目标字符串结果
    4. String literalConcat = "hello" + " " + "world";
    5. System.out.println(literalConcat == targetStr);
    6. 复制代码

    Part 2:过程推演
    编译阶段

    1. 这个 Case 在编译阶段,编译器会对代码进行优化,会把 "hello" + " " + "world" 优化成 "hello world"。
    2. 剩下的动作就和 Case 1 一致了。

    执行阶段

    1. targetStr变量赋值逻辑与 Case 1 一致。
    2. 因为编译器会进行代码优化,所以会把优化后的 "hello world" 赋值给 literalConcat
    3. 此时targetStrliteralConcat同时指向常量池字符串引用,所以运行结果是 true

    Part 3:结论验证

    1. ... 省略不重要的内容后 ....
    2. {
    3. public static void main(java.lang.String[]);
    4. descriptor: ([Ljava/lang/String;)V
    5. flags: ACC_PUBLIC, ACC_STATIC
    6. Code:
    7. stack=3, locals=3, args_size=1
    8. -- 从常量池获得 #2(hello world) 常量,并推入栈顶
    9. 0: ldc #2 // String hello world
    10. -- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
    11. 2: astore_1
    12. -- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
    13. -- 从常量池获得 #2(hello world) 常量,并推入栈顶
    14. 3: ldc #2 // String hello world
    15. -- 将栈顶的 hello world 常量引用存到 slot2 的局部变量表中
    16. 5: astore_2
    17. -- ^^ 以上两行是 String literalConcat = "hello" + " " + "world"; 编译后的汇编指令
    18. .... 省略后面的 System.out.println(literalConcat == targetStr); 汇编指令 ....
    19. }
    20. 复制代码

    Case 4

    Part 1:代码

    1. // 目标字符串
    2. String targetStr = "hello world";
    3. // Case 4: 定义字面量和字符串对象连接,对比目标字符串结果
    4. String world = " world";
    5. String mixConcat = "hello" + world;
    6. System.out.println(mixConcat == targetStr);
    7. 复制代码

    Part 2:过程推演
    编译阶段

    1. "hello world" 加入常量池逻辑跟 Case 1 一致。除了 "hello world" 以外,"hello" 和 " world" 也会加入到常量池。
    2. 此外,mixConcat 指向的是 "hello" 字面量和 world 字符串的拼接结果,对于字符串拼接,编译器会使用 StringBuilder 进行拼接,最后将 StringBuilder.toString() 的结果赋值给 mixConcat

    执行阶段

    1. targetStr 变量赋值逻辑与 Case 1 一致。
    2. mixConcat 变量指向 "hello" 常量池字符串和 world 变量的拼接结果。
    3. 因为编译器使用的是 StringBuilder 进行拼接的,StringBuilder 所有操作都是在堆中操作的,所以 mixConcat 指向堆中的字符串。
    4. 最终,mixConcat 指向的是堆中的 "hello world" 字符串,targetStr 指向的是常量池中的 "hello world",两个变量指向的地址不同,所以运行结果是 false

    Part 3:结论验证

    1. ... 省略不重要的内容后 ....
    2. {
    3. public static void main(java.lang.String[]);
    4. descriptor: ([Ljava/lang/String;)V
    5. flags: ACC_PUBLIC, ACC_STATIC
    6. Code:
    7. stack=3, locals=4, args_size=1
    8. -- 从常量池获得 #2(hello world) 常量,并推入栈顶
    9. 0: ldc #2 // String hello world
    10. -- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
    11. 2: astore_1
    12. -- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
    13. -- 从常量池获得 #3(world) 常量,并推入栈顶
    14. 3: ldc #3 // String world
    15. -- 将栈顶的 world 常量引用存到 slot2 的局部变量表中
    16. 5: astore_2
    17. -- ^^ 以上两行是 String world = " world"; 编译后的汇编指令
    18. -- 创建 StringBuilder 对象
    19. 6: new #4 // class java/lang/StringBuilder
    20. -- 将 StringBuilder 对象推到栈顶
    21. 9: dup
    22. -- 实例化 StringBuilder 对象
    23. 10: invokespecial #5 // Method java/lang/StringBuilder."":()V
    24. -- 从常量池获得 #6(hello) 常量,并推入栈顶
    25. 13: ldc #6 // String hello
    26. -- 调用 StringBuilder.append 方法,并传入 hello 字符串引用
    27. 15: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    28. -- 加载 slot2 变量槽变量(world 变量)
    29. 18: aload_2
    30. -- 调用 StringBuilder.append 方法,并传入 world 变量引用
    31. 19: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    32. -- 调用 StringBuilder.toString 方法
    33. 22: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
    34. -- 将 StringBuilder.toString 返回的引用存到 slot3 变量槽
    35. 25: astore_3
    36. -- ^^ 以上是 String mixConcat = "hello" + world; 编译后的汇编指令
    37. .... 省略后面的 System.out.println(mixConcat == targetStr); 汇编指令 ....
    38. }
    39. 复制代码

    Case 5

    Part 1:代码

    1. // 目标字符串
    2. String targetStr = "hello world";
    3. // Case 5: 接收外部输入 hello world,对比目标字符串结果(模拟真实项目中 RPC 调用获得的字符串)
    4. Scanner sc = new Scanner(System.in);
    5. System.out.print("enter string: ");
    6. String inputStr = sc.nextLine();
    7. System.out.println(inputStr == targetStr);
    8. 复制代码

    Part 2:过程推演
    编译阶段

    1. "hello world" 加入常量池逻辑还是一样。
    2. 此外,还有 "enter string: " 也需要加入常量池,因为它是一个字面量。

    执行阶段

    1. 略过 targetStr 执行逻辑。
    2. 初始化一个 Scanner,用来接收输入。
    3. 调用 Scanner.nextLine() 获取控制台输入,此时的输入的字符串是运行时产生的,非字面量,所以会在堆中分配内存。
    4. 将控制台获得字符串赋值给 inputStr
    5. 此时,targetStr 指向的是常量池中的 "hello world",inputStr 指向堆中的字符串,所以最终运行结果是 false

    Part 3:结论验证

    1. ... 省略不重要的内容后 ....
    2. {
    3. public static void main(java.lang.String[]);
    4. descriptor: ([Ljava/lang/String;)V
    5. flags: ACC_PUBLIC, ACC_STATIC
    6. Code:
    7. stack=3, locals=4, args_size=1
    8. -- 从常量池获得 #2(hello world) 常量,并推入栈顶
    9. 0: ldc #2 // String hello world
    10. -- 将栈顶的 hello world 常量引用存到 slot1 的局部变量表中
    11. 2: astore_1
    12. -- ^^ 以上两行是 String targetStr = "hello world"; 编译后的汇编指令
    13. 3: new #3 // class java/util/Scanner
    14. 6: dup
    15. 7: getstatic #4 // Field java/lang/System.in:Ljava/io/InputStream;
    16. 10: invokespecial #5 // Method java/util/Scanner."":(Ljava/io/InputStream;)V
    17. 13: astore_2
    18. 14: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
    19. 17: ldc #7 // String enter string:
    20. 19: invokevirtual #8 // Method java/io/PrintStream.print:(Ljava/lang/String;)V
    21. 22: aload_2
    22. 23: invokevirtual #9 // Method java/util/Scanner.nextLine:()Ljava/lang/String;
    23. 26: astore_3
    24. -- ^^ 以汇编指令对应以下代码,就不一行行解释了。
    25. /**
    26. * Scanner sc = new Scanner(System.in);
    27. * System.out.print("enter string: ");
    28. * String inputStr = sc.nextLine();
    29. **/
    30. .... 省略后面的 System.out.println(inputStr == targetStr); 汇编指令 ....
    31. }
    32. 复制代码

    到这,所有 Case 就分析完啦,接下来总结下。

    总结

    1. 影响字符串比较的的关键因素是字符串常量池,在面试中经常问到的字符串比较问题,主要考察的点也是这个。
    2. 字符串常量池存储的字符串分两类,一类是虚拟机自己放进去的,另外一类是程序调用 String.intern() 方法放进去的。
      • 虚拟机自己放进去的,主要是虚拟机内部自己用的一些值(符号引用啥的)。
      • 另外一部分代是码中的字符串字面量,也就是我们在代码中写的静态字符串 "hello world"。
    3. 下面是根据上面 Case 分析得出的字符串存在常量池的几种情况。
      • 代码中定义的字符串字面量,例如:String str = "hello world";
      • 调用 String.intern() 方法,例如把运行时得到的一个城市名放进常量池:cityName.intern()
      • 编译器优化后的字面量字符串连接,例如:String str = "hello" + " " + "world";

    关于 intern 方法,Twitter 曾在 QCon 分享过使用 intern 方法优化了十几G的内存案例,感兴趣的朋友可以搜下。

  • 相关阅读:
    [C]这些指针笔试题你都学废了吗?
    【洛谷】P1828 [USACO3.2] 香甜的黄油 Sweet Butter (最短路)
    【网络篇】第十六篇——再谈端口号
    使用backdrop-filter实现elementui官网的模糊滤镜效果的和毛玻璃效果
    工作中一些计算日期的要求
    0093 二分查找算法,分治算法
    [Python]装饰器
    极客日报:腾讯应届生年薪40万起步;亚马逊对欧盟7.46亿欧元罚款提出上诉
    医护上门系统—为老人和患者提供更舒适和现代化体验
    Set 集合概述与使用
  • 原文地址:https://blog.csdn.net/m0_73311735/article/details/126541528