今天看了个因字符串拼接导致的 CPU 高的问题,首先是监控 Agent 持续对一个实例进行告警,并抓取到了 CPU 高的相关 Java 线程:
1 | lwpId: 17, CPU usage: 52.9 |
乍一看好像没有问题,即正常的字符串拼接而已,且使用了 StringBuilder
,但是监控持续告警,每次采集的高 CPU 线程都指向了同一段业务代码。于是我摘掉了此实例的流量并抓取了一个堆转储,看到需要拼接为一个字符串的 List<String>
中共有 210 万个字符串,是因为数量太多导致需要执行很久吗?于是我查看了 StringUtil.listToString
中的源码:
1 | package me.tianshuang.tool; |
其中的 +
引起了我的注意,但是印象中 +
会被编译器优化为 StringBuilder
的 append
实现,从 Agent 抓取到的栈帧来看也确实进行了优化,我们可以通过查看反汇编的代码来验证,首先我们执行 javac me/tianshuang/tool/StringUtil.java
对以上代码进行编译,然后执行 javap -c -l me.tianshuang.tool.StringUtil
打印出反汇编的代码,即组成 Java 字节码的指令:
1 | Compiled from "StringUtil.java" |
可以看出,确实是被优化为了 StringBuilder
实现。但是仔细观察以上代码不难发现共有两处创建了 StringBuilder
实例,其中一处位于 for 循环外侧,另一处位于 for 循环内侧,即 for 循环内外并未共用一个 StringBuilder
实例,而是 for 循环外侧创建了一个 StringBuilder
实例用于拼接 list.get(0)
与 ""
,拼接完成后调用了 StringBuilder
实例的 toString
方法并赋值给变量 result
,for 循环内侧每次执行时创建了一个 StringBuilder
实例用于拼接 result
与 separator
与 list.get(i)
,且拼接完成后调用了 StringBuilder
实例的 toString
方法并赋值给变量 result
。不难发现,此实现需创建 StringBuilder
实例的个数与 for 循环需执行的次数成正比,且 for 循环内侧当循环次数越大时首次 append
的字符串长度也越大,导致需要拷贝的字符也越多,即时间复杂度为 O(n2),可参考 《程序员面试金典(第6版)》 中的 9.3.1 StringBuilder 一节,亦可参考 《Effective Java中文版(第3版)》 中的第 63 条:了解字符串连接的性能。从内存层面上看,因为创建了大量的 StringBuilder
实例,会增大 GC 的压力,但是不存在内存泄漏的问题。
我们可以编写的简单的测试代码来进行验证,通过生成 10 万个 UUID 字符串并使用以上代码进行拼接:
1 | package me.tianshuang.tool; |
以上代码,在本地测试时耗时 135390ms,我们将以上代码调整为通过我们分析反汇编的代码实现并再次测试:
1 | package me.tianshuang.tool; |
以上代码耗时 142860ms,可以看出耗时没有明显差异。我们再将 listToString
实现优化为基于一个 StringBuilder
实例的实现并再次进行测试:
1 | package me.tianshuang.tool; |
时间降低至 32ms。当然,还可以通过空间预分配等措施继续优化,亦可参考 Apache Commons 中 StringUtils
中的拼接实现。
Reference
Using += for strings in a loop, is it bad practice? - Stack Overflow
Java Compiler Optimization for String Concatenation | by Vaibhav Singh | Javarevisited | Medium
JEP 280: Indify String Concatenation