Thoroughly understand `String` and `String constant pool`

preface

This article will analyze the key factors that affect the running results of each case based on several actual cases as the starting point.
After that, each Case operation logic will be analyzed in detail, and the analysis will be combined with the knowledge of JVM memory structure and bytecode.
Finally, through the analysis of each case, we thoroughly understand the problem of strings.

Case Test

1. Let's first look at the following code, and think about the running results, and then compare the actual running results.

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);
  }

}
复制代码

The following are the actual running results of each Case

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

2. The key factor that affects the running result of Case - the string constant pool
is all string comparison, why is there such a big difference? In fact, the key factor that affects the operation results is 字符串常量池.

The following screenshot is the definition of strings in the Java SE 8 virtual machine specification:

pic.png

Official link to the Java SE 8 Virtual Machine Specification: docs.oracle.com/javase/spec…

Approximate meaning:
The string constant points to a reference to an instance of the String class, which comes from the CONSTANT_String_infostructure .

The virtual machine specification also stipulates that the same string constant must point to the same instance of the String class. In addition, if any string calls the String.intern()method , the instance pointed to by the return result must be exactly equal to the string instance pointed to by the constant pool.

This sentence is rather awkward, and you can understand this sentence according to the following code:

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

According to the definition of the virtual machine specification, the data placed in the constant pool can be roughly divided into two categories:

  • virtual machine itself
  • The program is put in by calling the String.intern method

The call to String.intern() puts it in very clearly, and we put it in ourselves. What's in the virtual machine?
This can actually be found out by looking at the bytecode file.

通过 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:过程推演
编译阶段

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

执行阶段

  1. 将常量池中的 "hello world" 字符串引用赋值给 targetStrvar 变量。
  2. 此时,targetStrvar 变量同时指向常量池中的 "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); 编译后的汇编指令
}
复制代码

结论:从字节码汇编指令执行逻辑可以得出,vartargetStr 都指向常量池中的 "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 一致...

执行阶段

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

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

public String(String original) {
    this.value = original.value;
    this.hash = original.hash;
}
复制代码

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

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:过程推演
编译阶段

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

执行阶段

  1. targetStr变量赋值逻辑与 Case 1 一致。
  2. 因为编译器会进行代码优化,所以会把优化后的 "hello world" 赋值给 literalConcat
  3. 此时targetStrliteralConcat同时指向常量池字符串引用,所以运行结果是 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:过程推演
编译阶段

  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:结论验证

... 省略不重要的内容后 ....
{
  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:过程推演
编译阶段

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

执行阶段

  1. 略过 targetStr 执行逻辑。
  2. 初始化一个 Scanner,用来接收输入。
  3. 调用 Scanner.nextLine() 获取控制台输入,此时的输入的字符串是运行时产生的,非字面量,所以会在堆中分配内存。
  4. 将控制台获得字符串赋值给 inputStr
  5. 此时,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 就分析完啦,接下来总结下。

总结

  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的内存案例,感兴趣的朋友可以搜下。

Guess you like

Origin juejin.im/post/7135700218065977352