Entender a fondo `String` y `String constante pool`

prefacio

Este artículo analizará los factores clave que afectan los resultados de ejecución de cada caso basándose en varios casos reales como punto de partida.
Después de eso, la lógica de operación de cada caso se analizará en detalle y el análisis se combinará con el conocimiento de la estructura de memoria y el código de bytes de JVM.
Finalmente, a través del análisis de cada caso, entendemos a fondo el problema de las cadenas.

Prueba de caso

1. Primero veamos el siguiente código, pensemos en los resultados de ejecución y luego comparemos los resultados de ejecución reales.

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

}
复制代码

Los siguientes son los resultados reales de ejecución de cada Caso

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

2. El factor clave que afecta el resultado de ejecución de Case: el grupo de constantes de cadenas
es una comparación de todas las cadenas, ¿por qué hay una diferencia tan grande? De hecho, el factor clave que afecta los resultados de la operación es 字符串常量池.

La siguiente captura de pantalla es la definición de cadenas en la especificación de la máquina virtual Java SE 8:

pic.png

Enlace oficial a la especificación de máquina virtual Java SE 8: docs.oracle.com/javase/spec…

Significado aproximado:
la constante de cadena apunta a una referencia a una instancia de la clase String, que proviene de la CONSTANT_String_infoestructura .

La especificación de la máquina virtual también estipula que la misma constante de cadena debe apuntar a la misma instancia de la clase String. Además, si alguna cadena llama al String.intern()método , la instancia a la que apunta el resultado devuelto debe ser exactamente igual a la instancia de cadena a la que apunta por la piscina constante.

Esta oración es bastante incómoda, y puedes entenderla de acuerdo con el siguiente código:

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

De acuerdo con la definición de la especificación de la máquina virtual, los datos colocados en el grupo constante se pueden dividir aproximadamente en dos categorías:

  • máquina virtual en sí
  • El programa se instala llamando al método String.intern

La llamada a String.intern() lo pone muy claro y lo ponemos nosotros mismos. ¿En qué está la máquina virtual?
En realidad, esto se puede averiguar mirando el archivo de código de bytes.

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

Supongo que te gusta

Origin juejin.im/post/7135700218065977352
Recomendado
Clasificación