字符串拼接-被忽视的代码性能陷阱
本文主旨
通过JMH(一个牛逼的基准测试工具),从0开始使用,探索关于字符串拼接几种方式的性能问题。
一、使用 JMH 探索字符串拼接
第一步:生成一个新的JMH工程的maven命令如下:
mvn archetype:generate -DinteractiveMode=false
-DarchetypeGroupId=org.openjdk.jmh
-DarchetypeArtifactId=jmh-java-benchmark-archetype
-DgroupId=com.bin.jmh
-DartifactId=jmh
-Dversion=1.0.0-SNAPSHOT
生成的工程如下:
第二步:添加几种方式的字符串拼接测试代码
方式一、String
@Warmup(iterations = 5, time = 1) //预热 迭代5次,每次一秒
@Measurement(iterations = 5, time = 1) //正式测,迭代5次,每次一秒
@Fork(1) //几个线程执行
@BenchmarkMode(Mode.Throughput) //基准测试的模式 吞吐量
@OutputTimeUnit(TimeUnit.SECONDS) //时间单位为秒
public class MyBenchmark {
@Benchmark
public String testMethod() {
String targetString = "";
for (int i = 0; i < 10000; i++) {
targetString += "helllo";
}
return targetString;
}
}
第三步:打包、运行
- mvn clean install
- java -jar target/benchmarks.jar
对应的输出结果如下:
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Benchmark mode: Throughput, ops/time
# Benchmark: com.afei.jmh.MyBenchmark.testMethod
# Run progress: 0.00% complete, ETA 00:00:10
# Fork: 1 of 1
# Warmup Iteration 1: 3.718 ops/s
# Warmup Iteration 2: 4.691 ops/s
# Warmup Iteration 3: 22.840 ops/s
# Warmup Iteration 4: 26.564 ops/s
# Warmup Iteration 5: 25.708 ops/s
Iteration 1: 25.399 ops/s
Iteration 2: 26.057 ops/s
Iteration 3: 26.828 ops/s
Iteration 4: 25.474 ops/s
Iteration 5: 24.801 ops/s
Result "testMethod":
25.712 ±(99.9%) 2.951 ops/s [Average]
(min, avg, max) = (24.801, 25.712, 26.828), stdev = 0.766
CI (99.9%): [22.761, 28.663] (assumes normal distribution)
# Run complete. Total time: 00:00:10
Benchmark Mode Cnt Score Error Units
MyBenchmark.testMethod thrpt 5 25.712 ± 2.951 ops/s
我们只需要关注"Score"这一栏,它表示的是每秒执行的基准测试方法的次数。
对于String字符串,拼接1万次。一秒钟可以执行25.712次,Error 表示上下浮动,可以忽略只关心整数部分即可。
方式二、StringBuilder
替换测试代码,使用StringBuffer进行字符串拼接
@Benchmark
public String testMethod() {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 10000; i++) {
builder.append("hello");
}
return builder.toString();
}
打包、执行,结果如下:
Benchmark Mode Cnt Score Error Units
MyBenchmark.testMethod thrpt 5 13209.264 ± 1136.107 ops/s
我们震惊的发现,同样的逻辑,使用StringBuilder每秒可以执行13209次。
方式三、StringBuffer
替换为StringBuffer测试
@Benchmark
public String testMethod() {
StringBuffer buffer = new StringBuffer();
for (int i = 0; i < 10000; i++) {
buffer.append("hello");
}
return buffer.toString();
}
结果如下:
Benchmark Mode Cnt Score Error Units
MyBenchmark.testMethod thrpt 5 13621.655 ± 3155.081 ops/s
同样的逻辑,使用StringBuffer每秒可以执行13621次。
基准测试结论:使用 StringBuilder 的字符串拼接操作是String操作的 528 倍;使用 StringBuffer 的字符串拼接操作,是String的544倍。因此,频繁的字符串拼接不建议使用String,比如:打印日志,使用String会导致频繁的full gc。
二、思考
1、 String 的字符串连接操作为什么慢呢?
这是因为String是不可变类,每一次字符串拼接(targetString += “hello”),都需要创建一个新的 String 对象,这种模式对CPU 和内存消耗都比较大。
2、 StringBuilder 和 StringBuffer 的为什么快?
因为 StringBuilder 和 StringBuffer 是可变类,内部预先分配了一定的内存。字符串操作时,只有当预分配内存不足,才会扩展内存,大幅度减少了内存分配、拷贝和释放的频率。
3、 StringBuilder 和 StringBuffer 的性能对比?
StringBuffer 的字符串操作是多线程安全的, StringBuilder 不是。多线程环境下性能同步需要消耗额外的性能,因此在多线程环境下,StringBuilder 会比StringBuffer更快。
因为本次基准测试是在单线程环境下,因此震惊的发现,StringBuffer和StringBuilder性能差不多,甚至StringBuffer还会略快一点点。
三、源码分析
1、StringBuilder和StringBuffer的实现
分别查看StringBuilder和StringBuffer的append()方法。
1)StringBuilder#append();
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
2)StringBuffer#append();
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
内部都是调用父类的append方法。而它们继承同一个父类AbstractStringBuilder。
跟进父类,执行同一个拼接方法。
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;
return this;
}
因此,StringBuffer与StringBuilder的代码逻辑基本完全相同,唯一的差别在于StringBuffer给每一个方法都添加了synchronized同步关键字。
2、构造函数
StringBuffer 、StringBuilder 构造函数,内部都是调用父类构造器。
public StringBuffer() {
super(16);
}
public StringBuilder() {
super(16);
}
父类构造器初始化,默认返回一个16个字符容量的数组。
AbstractStringBuilder(int capacity) {
value = new char[capacity];
}