从字节码角度看字符串拼接底层是如何实现的

344 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第5天,点击查看活动详情

首先我们看看这份代码,是否能够正确地说出打印结果?

public static void main(String[] args) {
    String s1 = "a";
    String s2 = "b";
    String s3 = "ab";
    String s4 = "a" + "b";
    String s5 = s1 + s2;
    String s6 = new String("ab");

    System.out.println(s3 == s4);
    System.out.println(s3 == s5);
    System.out.println(s3 == s6);
    System.out.println(s5 == s6);
}

运行结果为:

true
false
false
false

那么它的底层原理是什么呢?

我们使用反编译工具 javap 工具查看一下,javap 工具的使用如下:

javap -v class文件

首先,先了解几个字节码指令的作用:

  • ldc 从常量池中获取常量,并推入到操作数栈中
  • astore 将操作数栈中的栈顶元素存放到局部变量表中
  • new 创建一个对象实例,并压入操作数栈
  • aload 将局部变量表的值推入到操作数栈中
  • invokevirtual 调用实例方法
  • dup 复制操作数栈栈顶元素并压入栈顶
  • invokespecial 调用实例初始化方法(构造方法)、私有方法或父类方法
  • invokevirtual 调用对象实例方法

类的字节码文件中会有常量池部分存放常量,在类加载后这些常量会加载到运行时常量池中。

操作数栈是方法执行过程的一种数据结构,对变量的操作总是在操作数栈的栈顶上执行的。

局部变量表负责存放方法体内的局部变量。

我们只查看反编译后的方法主体,它的字节码是这样的,我们先看看全部内容,待会逐个分析。

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=7, args_size=1
         0: ldc           #2                  // String a
         2: astore_1
         3: ldc           #3                  // String b
         5: astore_2
         6: ldc           #4                  // String ab
         8: astore_3
         9: ldc           #4                  // String ab
        11: astore        4
        13: new           #5                  // class java/lang/StringBuilder
        16: dup
        17: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
        20: aload_1
        21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        24: aload_2
        25: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        28: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        31: astore        5
        33: new           #9                  // class java/lang/String
        36: dup
        37: ldc           #4                  // String ab
        39: invokespecial #10                 // Method java/lang/String."<init>":(Ljava/lang/String;)V
        42: astore        6
        44: getstatic     #11                 // Field java/lang/System.out:Ljava/io/PrintStream;
        47: aload_3
        48: aload         4
        50: if_acmpne     57
        53: iconst_1
        54: goto          58
        57: iconst_0
        58: invokevirtual #12                 // Method java/io/PrintStream.println:(Z)V
        61: getstatic     #11                 // Field java/lang/System.out:Ljava/io/PrintStream;
        64: aload_3
        65: aload         5
        67: if_acmpne     74
        70: iconst_1
        71: goto          75
        74: iconst_0
        75: invokevirtual #12                 // Method java/io/PrintStream.println:(Z)V
        78: getstatic     #11                 // Field java/lang/System.out:Ljava/io/PrintStream;
        81: aload_3
        82: aload         6
        84: if_acmpne     91
        87: iconst_1
        88: goto          92
        91: iconst_0
        92: invokevirtual #12                 // Method java/io/PrintStream.println:(Z)V
        95: getstatic     #11                 // Field java/lang/System.out:Ljava/io/PrintStream;
        98: aload         5
       100: aload         6
       102: if_acmpne     109
       105: iconst_1
       106: goto          110
       109: iconst_0
       110: invokevirtual #12                 // Method java/io/PrintStream.println:(Z)V
       113: return

现在,我们逐个分析。

1String s1 = "a";
【2String s2 = "b";
【3String s3 = "ab";
【4String s4 = "a" + "b";
【5String s5 = s1 + s2;
【6String s6 = new String("ab");

对于【1】【2】【3】三条语句,JVM 会从常量池中获取实例并赋值给字符串变量。

0: ldc           #2                  // String a	井号+数字是常量池对应的索引,这里对应字符串 "a"
2: astore_1
3: ldc           #3                  // String b
5: astore_2
6: ldc           #4                  // String ab
8: astore_3

对于【4】语句,JVM 会优化为 String s4 = "ab",因此,他实际上和 s3 字符串引用的是同一个常量池对象。

9: ldc           #4                  // String ab
11: astore        4

对于【5】语句,JVM 会 new 一个 StringBuilder 对象,并使用其方法 append 拼接字符串,最后调用 toString() 方法赋值给变量,字符串实例存在于堆内存中。

# 【创建一个对象实例】
13: new           #5                  // class java/lang/StringBuilder		
# 【复制一份对象实例并压入栈顶】
16: dup					
# 【执行初始化,调用构造方法】
17: invokespecial #6                  // Method java/lang/StringBuilder."<init>":()V
# 【获取局部变量表 1 号位置的变量,即 "a",压入到操作数栈中】
20: aload_1
# 【调用 append 方法,将 "a" 字符串拼接到 StringBuilder 对象上】
21: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
# 【获取局部变量表 2 号位置的变量,即 "b",压入到操作数栈中】
24: aload_2
# 【调用 append 方法,将 "b" 字符串拼接到 StringBuilder 对象上】
25: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
# 【调用 toString 方法,获得 "ab" 的对象实例,存储到局部变量表上】
28: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
31: astore        5

对于【6】语句,new 关键字会在堆内存中创建一个新的实例,因此和上面的其他变量引用的都不会是同一个对象。

33: new           #9                  // class java/lang/String
36: dup
37: ldc           #4                  // String ab
39: invokespecial #10                 // Method java/lang/String."<init>":(Ljava/lang/String;)V
42: astore        6

总结

字符串的声明,如果是使用常量声明,那么该字符串实例存在于字符串池中,是一个常量。如果后面有相同的声明方式,那么会引用同一个常量。

如果使用new关键字,那么会在堆内存中创建一个对象实例。

字符串的拼接,我们可以分两种情况:

  1. 如果是常量拼接,那么 JVM 会优化掉 + 操作符,使用字符串池中的对象,如果没有则在字符串池中创建
  2. 如果是变量拼接,那么 JVM 会创建一个 StringBuilder 实例,调用 append 方法拼接,然后调用 toString() 获得一个堆内存的 String 实例。例如 String str = s1 + s2; 或者 String str = "a" + s2;