美文网首页
同事如此使用StringBuilder,我给他提了一个Bug

同事如此使用StringBuilder,我给他提了一个Bug

作者: 代码小当家 | 来源:发表于2020-12-18 20:45 被阅读0次

    字符串的拼接在项目中使用的非常频繁,但稍不留意往往又会造成一些性能问题。

    字符串的拼接在项目中使用的非常频繁,但稍不留意往往又会造成一些性能问题。最近 Review 代码时发现同事写了如下的代码,于是给他提了一个 bug。

    @Test
    public void testForAdd() {
    String result = "NO_";
    for (int i = 0; i < 10; i++) {
    result += i;
    }
    System.out.println(result);
    }
    本文就带大家从表象到底层的来聊聊,为什么这种写法会有性能问题。

    IDE 的提示 如果你使用的 IDE 安装了代码检查的插件,会很轻易的看到上面代码中的 “+=” 操作会有黄色的背景,这是插件在提示,此处使用有问题。

    下面来看一下关于 “+=”,IDEA 给出的提示详情:

    String concatenation ‘+=’ in loop
    Inspection info: Reports String concatenation in loops. As every String concatenation copies the whole String, usually it is preferable to replace it with explicit calls to StringBuilder.append() or StringBuffer.append().
    这段提示简单翻译过来就是:循环中,字符串拼接使用了 “+=”。检验信息:报告循环中的字符串拼接。每次 String 的拼接都会复制整个 String。通常建议将其替换为 StringBuilder.append() 或 StringBuffer.append()。

    提示信息中给出了原因,并且给出了解决方案的建议。但事实真的如提示中这么简单吗?Java8 以后使用 String 拼接 JVM 编译时不是已经默认优化构建成 StringBuilder 了吗,怎么还有问题?下面我们就来深入分析一下。

    字节码的反编译 对上面的代码,我们通过字节码反编译一下,看看 JVM 在此过程中是否帮我们进行了优化,是否涉及到整个 String 的复制。

    使用 javap -c 命令来查看字节码内容:

    public void testForAdd();
    Code:
    //从常量池引用#2并推向栈顶,操作了String初始化的变量“NO_”
    0: ldc #2 // String NO_
    2: astore_1
    3: iconst_0
    4: istore_2
    5: iload_2
    6: bipush 10
    //如果栈顶两个值大于等于0(此时0-10)则跳转36(code),这里开始进入for循环处理
    8: if_icmpge 36
    //创建StringBuilder对象,其引用进栈
    11: new #3 // class java/lang/StringBuilder
    14: dup
    //调用StringBuilder的构造方法
    15: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
    18: aload_1
    19: invokevirtual #5 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
    22: iload_2
    //调用append方法
    23: invokevirtual #6 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
    //调用toString方法,并将产生的String存入栈顶
    26: invokevirtual #7 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
    29: astore_1
    30: iinc 2, 1
    33: goto 5
    36: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
    39: aload_1
    40: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    43: return
    上述反编译的字节码操作中已经将关键部分标注出来了。编号 0 处会加载定义的 “NO_” 字符串,编号 8 处开始进行循环的判断,符合条件(0-10)的部分便会执行后续的循环体中的内容。在循环体内,编号 11 创建 StringBuilder 对象,编号 15 调用 StringBuilder 的构造方法,编号 23 调用 append 方法,编号 26 调用 toString 方法。

    经过上述的步骤我们能够发现什么?JVM 在编译时的确帮我们进行了优化,将 for 循环中的字符串拼接转化成了 StringBuilder,并通过 appen 方法和 toString 方法进行处理。这样有问题吗?JVM 已经优化了啊!

    但是,关键问题来了:每次 for 循环都会新创建一个 StringBuilder,都会进行 append 和 toString 操作,然后销毁。这就变得可怕了,这与每次都创建 String 对象并复制有过之而无不及。

    经过上述分析之后,上面的代码的效果相当于如下代码:

    @Test
    public void testForAdd1() {
    String result = "NO_";
    for (int i = 0; i < 10; i++) {
    result = new StringBuilder(result).append(i).toString();
    }
    System.out.println(result);
    }
    这样来看是不是更直观了?至此,想必大家已经明白为什么给那位同事提 bug 了吧。

    方案改进 那么,针对上面的问题,代码该如何进行改进呢?直接上代码:

    @Test
    public void testForAppend() {
    StringBuilder result = new StringBuilder("NO_");
    for (int i = 0; i < 10; i++) {
    result.append(i);
    }
    System.out.println(result);
    }
    将 StringBuilder 对象的创建放在外面,for 循环中直接调用 append 即可。再来看一下这段代码的字节码操作:

    public void testForAppend();
    Code:
    0: new #3 // class java/lang/StringBuilder
    3: dup
    4: ldc #2 // String NO_
    6: invokespecial #10 // Method java/lang/StringBuilder."<init>":(Ljava/lang/String;)V
    9: astore_1
    10: iconst_0
    11: istore_2
    12: iload_2
    13: bipush 10
    15: if_icmpge 30
    18: aload_1
    19: iload_2
    20: invokevirtual #6 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
    23: pop
    24: iinc 2, 1
    27: goto 12
    30: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
    33: aload_1
    34: invokevirtual #11 // Method java/io/PrintStream.println:(Ljava/lang/Object;)V
    37: return
    对照最开始的字节码内容,看看是不是简化了很多,问题完美解决。

    for 循环内的场景 上面介绍的使用场景主要针对通过 for 循环来获得一个整字符串,但某些业务场景中可能拼接字符串本身只在 for 循环当中,并不会在 for 循环外部处理,比如:

    @Test
    public void testInfoForAppend() {
    for (int i = 0; i < 10; i++) {
    String result = "NO_" + i;
    System.out.println(result);
    }
    }
    上述代码中 for 循环内部的字符串拼接还可能会更复杂,我们已经知道 JVM 会优化成上面提到的 StringBuilder 进行处理。同时,每次都会创建 StringBuilder 对象,那么针对这种情况,只能听之任之吗?

    其实,还可以考虑另外一个思路,那就是在 for 循环外部创建一个 StringBuilder,然后在内部使用完之后进行清空处理。有两种方式可以实现清空:delete 方法删除和 setLength 方法。

    直接上两种方法的示例代码:

    @Test
    public void testDelete() {
    StringBuilder result = new StringBuilder();
    for (int i = 0; i < 10; i++) {
    result.delete(0,result.length());
    result.append(i);
    System.out.println(result);
    }
    }

    @Test
    public void testSetLength() {
    StringBuilder result = new StringBuilder();
    for (int i = 0; i < 10; i++) {
    result.setLength(0);
    result.append(i);
    System.out.println(result);
    }
    }
    关于上述示例的验证和底层操作,感兴趣的朋友可以继续深挖一下,这里只说结论。经过试验,这两种方法的性能都要比默认的处理方式要好很多。同时 delete 操作的方式略微优于 setLength 的方式,推荐使用 delete 的方式。

    小结 通过 IDE 的一个提示信息,我们进行底层原理深挖及实现的验证,竟然发现这么多可提升的空间和隐藏知识点,是不是很有成就感?最后,我们再来稍微总结一下 String 和 StringBuilder 涉及到的知识点(基于 Java8 及以上版本):

    没有循环的字符串拼接,直接使用 + 就可以了,JVM 会帮我们进行优化。 并发场景进行字符串拼接,使用 StringBuffer 代替 StringBuilder,StringBuffer 是线程安全的。 循环内 JVM 的优化存在一定的缺陷,可在循环体外构建 StringBuilder,循环体内进行 append 操作。 对于纯循环体内使用的字符串拼接,可在循环体外构建 StringBuilder,使用完进行清除操作(delete 或 setLength)。

    相关文章

      网友评论

          本文标题:同事如此使用StringBuilder,我给他提了一个Bug

          本文链接:https://www.haomeiwen.com/subject/ajmfnktx.html