您的位置 首页 java

Java:彻底弄明白`String` 和 `字符串常量池`

Case Test

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:彻底弄明白`String` 和 `字符串常量池`

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

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

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

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

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

  • 虚拟机自己放进去的
  • 程序通过调用 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 :过程推演
编译阶段

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

执行阶段

  1. 将常量池中的 “hello world” 字符串引用赋值给 targetStr 和 var 变量。
  2. 此时,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 一致…

执行阶段

  1. 将 “hello world” 赋值给 targetStr 变量,与 Case 1 一致。
  2. 在堆中为 String 分配内存,调用 String 构造函数,同时传入常量池 “hello world” 字符串引用。
  3. String 对象将自己的 value 和 hash 指向常量池字符串的 value 和 hash。
  4. 此时,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 :过程推演
编译阶段

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

执行阶段

  1. targetStr变量赋值逻辑与 Case 1 一致。
  2. 因为编译器会进行代码优化,所以会把优化后的 “hello world” 赋值给 literalConcat。
  3. 此时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 :过程推演
编译阶段

  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”;

文章来源:智云一二三科技

文章标题:Java:彻底弄明白`String` 和 `字符串常量池`

文章地址:https://www.zhihuclub.com/176552.shtml

关于作者: 智云科技

热门文章

网站地图