String、SpringBuilder和StringBuffer的区别,性能对比 | 底层原理 | 字节码

目录

先看结果

测试结果

字节码层面

JDK8

性能差异原因

JDK的优化

JDK14、17 writeByString字节码:

减少对象创建的方式

示例

字节码分析

总结


众所周知,涉及大量的字符串拼接操作时,要用StringBuilder(或线程安全的StringBuffer)的append方法,取代String的拼接操作。正好有空,顺着好奇心,来一探究竟

先看结果

JVM内存分配:2048M

测试方法:从大小相同的list中,分别通过String和StringBuilder的方式,构建一个大的字符串,并写入文件。比较各自耗时。

写入文件:

public void writeToFile(String content) {
    String path = "D:\\test\\test.txt";
    Path filePath = Paths.get(path);
    Path parentDir = filePath.getParent();
    if (parentDir != null) {
        try {
            Files.createDirectories(parentDir);
        } catch (IOException e) {
            System.err.println("Failed to create directories: " + e.getMessage());
            return;
        }
    }
    try {
        Files.write(filePath, content.getBytes());
        System.out.println("Write to file success");
    } catch (IOException e) {
        System.err.println("Failed to write to file: " + e.getMessage());
    }
}

获取数据集合:

public List<TestBean> getList() {
    return getList(20000);
}
public List<TestBean> getList(int size) {
    List<TestBean> list = new ArrayList<>();
    for (int i = 0; i < size; i++) {
        TestBean testBean = new TestBean();
        testBean.setName("name" + i);
        testBean.setAge(i);
        testBean.setAddress("address" + i);
        list.add(testBean);
    }
    return list;
}

两种字符串拼接方法:

public void writeByStringBuilder() {
    List<TestBean> list = getList();
    StringBuilder sb = new StringBuilder();
    for (TestBean testBean : list) {
        sb.append(testBean.getName()).append(",")
                .append(testBean.getAge()).append(",")
                .append(testBean.getAddress()).append("\n");
    }
    writeToFile(sb.toString());
}
public void writeByString() {
    List<TestBean> list = getList();
    String s = "";
    for (TestBean testBean : list) {
        s += testBean.getName() + ","
                + testBean.getAge() + ","
                + testBean.getAddress() + "\n";
    }
    writeToFile(s);
}

测试结果

JDK版本String耗时StringBuilder耗时
85167ms51ms
143134ms49ms
171361ms51ms

从测试结果来看,无论是JDK8、14还是17,StringBuilder的方式都要远远快于String,但随着JDK的升级,String方式的性能也在不断提升。

字节码层面

JDK8

StringBuilder方法字节码:

 0 aload_0
 1 invokevirtual #8 <test/T10.getList : ()Ljava/util/List;>
 4 astore_1
 5 new #2 <java/lang/StringBuilder>
 8 dup
 9 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
12 astore_2
13 aload_1
14 invokeinterface #9 <java/util/List.iterator : ()Ljava/util/Iterator;> count 1
19 astore_3
20 aload_3
21 invokeinterface #10 <java/util/Iterator.hasNext : ()Z> count 1
26 ifeq 84 (+58)
29 aload_3
30 invokeinterface #11 <java/util/Iterator.next : ()Ljava/lang/Object;> count 1
35 checkcast #12 <test/TestBean>
38 astore 4
40 aload_2
41 aload 4
43 invokevirtual #13 <test/TestBean.getName : ()Ljava/lang/String;>
46 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
49 ldc #14 <,>
51 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
54 aload 4
56 invokevirtual #15 <test/TestBean.getAge : ()I>
59 invokevirtual #16 <java/lang/StringBuilder.append : (I)Ljava/lang/StringBuilder;>
62 ldc #14 <,>
64 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
67 aload 4
69 invokevirtual #17 <test/TestBean.getAddress : ()Ljava/lang/String;>
72 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
75 ldc #18 <>
77 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
80 pop
81 goto 20 (-61)
84 aload_0
85 aload_2
86 invokevirtual #19 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
89 invokevirtual #20 <test/T10.writeToFile : (Ljava/lang/String;)V>
92 return

String方法:

 0 aload_0
 1 invokevirtual #8 <test/T10.getList : ()Ljava/util/List;>
 4 astore_1
 5 ldc #21
 7 astore_2
 8 aload_1
 9 invokeinterface #9 <java/util/List.iterator : ()Ljava/util/Iterator;> count 1
14 astore_3
15 aload_3
16 invokeinterface #10 <java/util/Iterator.hasNext : ()Z> count 1
21 ifeq 92 (+71)
24 aload_3
25 invokeinterface #11 <java/util/Iterator.next : ()Ljava/lang/Object;> count 1
30 checkcast #12 <test/TestBean>
33 astore 4
35 new #2 <java/lang/StringBuilder>
38 dup
39 invokespecial #3 <java/lang/StringBuilder.<init> : ()V>
42 aload_2
43 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
46 aload 4
48 invokevirtual #13 <test/TestBean.getName : ()Ljava/lang/String;>
51 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
54 ldc #14 <,>
56 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
59 aload 4
61 invokevirtual #15 <test/TestBean.getAge : ()I>
64 invokevirtual #16 <java/lang/StringBuilder.append : (I)Ljava/lang/StringBuilder;>
67 ldc #14 <,>
69 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
72 aload 4
74 invokevirtual #17 <test/TestBean.getAddress : ()Ljava/lang/String;>
77 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
80 ldc #18 <>
82 invokevirtual #7 <java/lang/StringBuilder.append : (Ljava/lang/String;)Ljava/lang/StringBuilder;>
85 invokevirtual #19 <java/lang/StringBuilder.toString : ()Ljava/lang/String;>
88 astore_2
89 goto 15 (-74)
92 aload_0
93 aload_2
94 invokevirtual #20 <test/T10.writeToFile : (Ljava/lang/String;)V>
97 return

分析字节码文件可以发现:

StringBuilder的方式,创建了一个StringBuilder和一个String对象,中间操作都是在StringBuilder的基础上进行追加;

而String的方式,每次循环都会创建一个StringBuilder和一个String对象,测试用例中,循环20000次,即至少创建40000个对象;

性能差异原因

  1. 对象创建:第二个方法在每次迭代中都会创建一个新的 StringBuilder 对象,这会导致更多的对象分配和垃圾收集压力。第一个方法只创建一次 StringBuilder 对象,然后在其上连续进行追加操作。
  2. 字符串转换:第二个方法在每次迭代结束后都将 StringBuilder 转换成 String,这意味着每迭代一次就会创建一个新的 String 对象。第一个方法只在循环结束后将整个 StringBuilder 转换成 String,因此创建的 String 对象数量少得多。
  3. 缓存局部性:第一个方法中,所有的操作都在同一个 StringBuilder 对象上完成,这有助于提高数据缓存的局部性。第二个方法中,由于每次迭代都涉及到新对象的创建,这可能会导致缓存效率降低。
  4. JIT优化:尽管JIT编译器可能会尝试优化第二个方法中的字符串拼接操作,但由于每次迭代都需要创建新的 StringBuilder 和 String 对象,这增加了优化的难度。

通过观察字节码文件,发现使用 StringBuilder 的方式要优于直接使用 String 方式,创建更少的对象,缓解垃圾回收的压力。

JDK的优化

上述实验,得知StringBuilder要优于String,那么同样是使用String的方式,JDK17版本要比JDK8版本快了很多,接下来分析JDK17对于String拼接的优化

JDK14、17 writeByString字节码:

 0 aload_0
 1 invokevirtual #20 <test/T10.getList : ()Ljava/util/List;>
 4 astore_1
 5 ldc #69
 7 astore_2
 8 aload_1
 9 invokeinterface #26 <java/util/List.iterator : ()Ljava/util/Iterator;> count 1
14 astore_3
15 aload_3
16 invokeinterface #32 <java/util/Iterator.hasNext : ()Z> count 1
21 ifeq 60 (+39)
24 aload_3
25 invokeinterface #38 <java/util/Iterator.next : ()Ljava/lang/Object;> count 1
30 checkcast #42 <test/TestBean>
33 astore 4
35 aload_2
36 aload 4
38 invokevirtual #44 <test/TestBean.getName : ()Ljava/lang/String;>
41 aload 4
43 invokevirtual #50 <test/TestBean.getAge : ()I>
46 aload 4
48 invokevirtual #57 <test/TestBean.getAddress : ()Ljava/lang/String;>
51 invokedynamic #71 <makeConcatWithConstants, BootstrapMethods #0>
56 astore_2
57 goto 15 (-42)
60 aload_0
61 aload_2
62 invokevirtual #65 <test/T10.writeToFile : (Ljava/lang/String;)V>
65 return

1. JDK 14、17 版本的字节码并没有显式创建 StringBuilder 对象,而是通过 invokedynamic 调用 StringConcatFactory 的 makeConcatWithConstants 方法来直接创建 String 对象,减少了对象创建的数量

2. JDK 14、17 版本的字节码中,拼接操作通过 invokedynamic 调用 makeConcatWithConstants 方法完成,这种方法可以更高效地创建 String 对象,减少对象创建次数。


看来核心在于makeConcatWithConstants 方法,经过查阅资料以及询问AI:


在JDK 17中,StringConcatFactory 是一个内部类,它提供了一系列静态方法来创建字符串,这些方法旨在优化字符串拼接操作。其中最常用的是 makeConcatWithConstants 方法,它接受一系列参数并返回一个字符串。这个方法的设计是为了避免在字符串拼接过程中创建不必要的 StringBuilder 或 String 对象。

减少对象创建的方式

  1. 利用常量池:makeConcatWithConstants 方法会检查常量池中是否已经存在所需的字符串。如果存在,则直接使用已有的字符串,从而避免创建新的字符串对象。
  2. 延迟计算:当拼接操作涉及常量和变量时,makeConcatWithConstants 方法会尽量推迟变量的计算,直到真正需要它们的时候。这意味着如果某些变量未被更改,那么它们的计算结果可以直接从常量池中取出,而不是重新计算。
  3. 内联常量:如果拼接操作中涉及的常量是简单的字符串,makeConcatWithConstants 方法会尝试将这些常量内联到生成的代码中,从而避免创建额外的字符串对象。
  4. 使用字符串池:当生成的字符串是不可变的,并且在常量池中不存在时,makeConcatWithConstants 方法会将生成的字符串放入字符串池中。这意味着如果相同的字符串被多次生成,它们将共享同一个字符串池中的实例,而不是创建多个独立的字符串对象。

示例

我们来看一个具体的例子,假设有一个简单的字符串拼接操作:

String s = "Hello, " + name + "!";

这里的 name 是一个变量,而 "Hello, " 和 "!" 是常量字符串。在JDK 17中,这段代码会被编译器优化成调用 StringConcatFactory.makeConcatWithConstants 方法的指令。

字节码分析

在JDK 17中,上述代码的字节码看起来类似于这样:

invokedynamic #71 <makeConcatWithConstants, BootstrapMethods #0>

这里的 invokedynamic 指令指向 StringConcatFactory 的 makeConcatWithConstants 方法。这个方法会做以下事情:

  1. 检查常量池:它会检查常量池中是否已经有 "Hello, " 和 "!" 字符串。如果有,直接使用;如果没有,创建并将它们添加到常量池中。
  2. 生成字符串:它会根据 name 的值生成最终的字符串。如果 name 的值在多次拼接中保持不变,那么生成的字符串可以被重用。
  3. 字符串池化:最终生成的字符串会被放入字符串池中,这意味着如果同样的字符串被多次生成,它们将共享同一个实例。

总结

StringConcatFactory 的 makeConcatWithConstants 方法通过利用常量池、延迟计算、内联常量和字符串池化等技术,有效地减少了字符串拼接过程中不必要的对象创建,从而提高了性能。在JDK 17中,这种方法被广泛应用于字符串拼接操作,特别是在拼接操作包含常量字符串的情况下。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值