字符串的拼接在项目中使用的非常频繁,但稍不留意往往又会造成一些性能问题。最近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)。