1. 简介
在本篇文章中,我们将聚焦于 Java String API 的性能表现。
我们会深入分析 String 的创建、转换和修改操作,比较各种实现方式的效率差异。
当然,我们给出的建议未必适用于所有场景。但在对执行时间有严格要求的应用中,这些优化技巧能显著提升性能。
2. 构造新的 String 对象
在 Java 中,String 是不可变的。每次构造或拼接 String 时,都会生成一个新的对象 —— 如果是在循环中进行,这个过程会变得非常耗时。
2.1. 使用构造函数
在大多数情况下,除非你知道自己在做什么,否则应避免使用构造函数来创建 String。
我们先来看一个在循环中使用 new String()
构造函数和直接赋值 =
的例子:
为了做基准测试,我们使用 JMH(Java Microbenchmark Harness)工具。
测试配置如下:
@BenchmarkMode(Mode.SingleShotTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Measurement(batchSize = 10000, iterations = 10)
@Warmup(batchSize = 10000, iterations = 10)
public class StringPerformance {
}
这里我们使用了 SingleShotTime
模式,即只运行一次方法。由于我们想测量循环中 String 操作的性能,因此使用 @Measurement
注解来控制测试行为。
⚠️ 注意:在测试中直接对循环进行基准测试,可能会因为 JVM 的优化而影响结果的准确性。
因此,我们只测试单次操作,让 JMH 来处理循环。简单来说,JMH 会根据 batchSize
参数自动进行多次迭代。
现在,我们添加第一个微基准测试:
@Benchmark
public String benchmarkStringConstructor() {
return new String("baeldung");
}
@Benchmark
public String benchmarkStringLiteral() {
return "baeldung";
}
在第一个测试中,每次迭代都会创建一个新对象;而在第二个测试中,对象只会创建一次,后续的迭代会从字符串常量池中复用同一个对象。
我们将迭代次数设为 1,000,000,运行测试后结果如下:
Benchmark Mode Cnt Score Error Units
benchmarkStringConstructor ss 10 16.089 ± 3.355 ms/op
benchmarkStringLiteral ss 10 9.523 ± 3.331 ms/op
从 Score 的数值可以看出,差异是显著的。
2.2. 使用 +
操作符
来看一个动态拼接字符串的例子:
@State(Scope.Thread)
public static class StringPerformanceHints {
String result = "";
String baeldung = "baeldung";
}
@Benchmark
public String benchmarkStringDynamicConcat() {
return result + baeldung;
}
我们关注的是平均执行时间,输出单位为毫秒:
Benchmark 1000 10,000
benchmarkStringDynamicConcat 47.331 4370.411
从结果可以看出,向 state.result
添加 1000 项耗时 47.331 毫秒。当迭代次数增加 10 倍时,耗时飙升至 4370.441 毫秒。
✅ 总结:执行时间呈二次方增长。因此,在循环中动态拼接字符串的时间复杂度为 **O(n²)**。
2.3. String.concat()
方法
另一种拼接方式是使用 concat()
方法:
@Benchmark
public String benchmarkStringConcat() {
return result.concat(baeldung);
}
输出单位为毫秒,迭代次数为 100,000,结果如下:
Benchmark Mode Cnt Score Error Units
benchmarkStringConcat ss 10 3403.146 ± 852.520 ms/op
2.4. String.format()
方法
另一种创建字符串的方式是使用 String.format()
方法。其内部使用正则表达式解析输入参数。
我们编写 JMH 测试:
String formatString = "hello %s, nice to meet you";
@Benchmark
public String benchmarkStringFormat_s() {
return String.format(formatString, baeldung);
}
运行后结果如下:
Number of Iterations 10,000 100,000 1,000,000
benchmarkStringFormat_s 17.181 140.456 1636.279 ms/op
虽然 String.format()
写法更清晰易读,但性能上并不占优势。
2.5. StringBuilder
与 StringBuffer
关于 StringBuilder
和 StringBuffer
的区别,我们已有 详细文章。这里只关注性能表现。
StringBuilder
使用可变数组,通过索引记录最后一个使用的字符位置。当数组满时,容量翻倍并复制所有字符。
由于扩容并不频繁,**每次 append()
操作可以看作是 O(1)**,整体复杂度为 **O(n)**。
我们修改并运行动态拼接测试,结果如下:
Benchmark Mode Cnt Score Error Units
benchmarkStringBuffer ss 10 1.409 ± 1.665 ms/op
benchmarkStringBuilder ss 10 1.200 ± 0.648 ms/op
虽然差异不大,但可以看到 StringBuilder
略快一些。
幸运的是,在简单场景下,我们甚至不需要使用 StringBuilder
。有时候,**静态拼接使用 +
就能自动优化为 StringBuilder.append()
**。
这能显著提升性能。
3. 工具方法性能对比
3.1. StringUtils.replace()
vs String.replace()
有趣的是,Apache Commons 的 StringUtils.replace()
方法比 String.replace()
性能更好。原因在于实现方式不同。
String.replace()
使用正则表达式匹配,而 StringUtils.replace()
内部大量使用 indexOf()
,因此更快。
我们编写基准测试:
@Benchmark
public String benchmarkStringReplace() {
return longString.replace("average", " average !!!");
}
@Benchmark
public String benchmarkStringUtilsReplace() {
return StringUtils.replace(longString, "average", " average !!!");
}
将 batchSize
设为 100,000 后,结果如下:
Benchmark Mode Cnt Score Error Units
benchmarkStringReplace ss 10 6.233 ± 2.922 ms/op
benchmarkStringUtilsReplace ss 10 5.355 ± 2.497 ms/op
虽然差异不大,但 StringUtils.replace()
表现更好。不过,具体数值可能受字符串长度、JDK 版本等因素影响。
在 JDK 9+ 中,两者性能趋于一致。但在 JDK 8 中,差异显著:
Benchmark Mode Cnt Score Error Units
benchmarkStringReplace ss 10 48.061 ± 17.157 ms/op
benchmarkStringUtilsReplace ss 10 14.478 ± 5.752 ms/op
此时性能差距非常明显,印证了前面的分析。
3.2. 字符串分割方法
在 Java 中分割字符串时,常用的有以下几种方法:
String.split(regex)
StringTokenizer
- Guava 的
Splitter
String.indexOf()
我们编写基准测试:
String emptyString = " ";
String.split()
:
@Benchmark
public String [] benchmarkStringSplit() {
return longString.split(emptyString);
}
Pattern.split()
:
@Benchmark
public String [] benchmarkStringSplitPattern() {
return spacePattern.split(longString, 0);
}
StringTokenizer
:
List stringTokenizer = new ArrayList<>();
@Benchmark
public List benchmarkStringTokenizer() {
StringTokenizer st = new StringTokenizer(longString);
while (st.hasMoreTokens()) {
stringTokenizer.add(st.nextToken());
}
return stringTokenizer;
}
String.indexOf()
:
List stringSplit = new ArrayList<>();
@Benchmark
public List benchmarkStringIndexOf() {
int pos = 0, end;
while ((end = longString.indexOf(' ', pos)) >= 0) {
stringSplit.add(longString.substring(pos, end));
pos = end + 1;
}
stringSplit.add(longString.substring(pos));
return stringSplit;
}
Guava 的 Splitter
:
@Benchmark
public List<String> benchmarkGuavaSplitter() {
return Splitter.on(" ").trimResults()
.omitEmptyStrings()
.splitToList(longString);
}
运行后结果如下(batchSize = 100,000
):
Benchmark Mode Cnt Score Error Units
benchmarkGuavaSplitter ss 10 4.008 ± 1.836 ms/op
benchmarkStringIndexOf ss 10 1.144 ± 0.322 ms/op
benchmarkStringSplit ss 10 1.983 ± 1.075 ms/op
benchmarkStringSplitPattern ss 10 14.891 ± 5.678 ms/op
benchmarkStringTokenizer ss 10 2.277 ± 0.448 ms/op
可以看到,使用 Pattern
的方法性能最差。而 indexOf()
和 split()
性能最佳。
3.3. 转换为 String
我们测试几种将数字转换为字符串的方法:
int sampleNumber = 100;
@Benchmark
public String benchmarkIntegerToString() {
return Integer.toString(sampleNumber);
}
String.valueOf()
:
@Benchmark
public String benchmarkStringValueOf() {
return String.valueOf(sampleNumber);
}
[some integer value] + ""
:
@Benchmark
public String benchmarkStringConvertPlus() {
return sampleNumber + "";
}
String.format()
:
String formatDigit = "%d";
@Benchmark
public String benchmarkStringFormat_d() {
return String.format(formatDigit, sampleNumber);
}
运行后结果如下(batchSize = 10,000
):
Benchmark Mode Cnt Score Error Units
benchmarkIntegerToString ss 10 0.953 ± 0.707 ms/op
benchmarkStringConvertPlus ss 10 1.464 ± 1.670 ms/op
benchmarkStringFormat_d ss 10 15.656 ± 8.896 ms/op
benchmarkStringValueOf ss 10 2.847 ± 11.153 ms/op
从结果可以看出,**Integer.toString()
性能最好,而 String.format()
最差**。
3.4. 字符串比较
我们测试几种字符串比较方法:
@Benchmark
public boolean benchmarkStringEquals() {
return longString.equals(baeldung);
}
String.equalsIgnoreCase()
:
@Benchmark
public boolean benchmarkStringEqualsIgnoreCase() {
return longString.equalsIgnoreCase(baeldung);
}
String.matches()
:
@Benchmark
public boolean benchmarkStringMatches() {
return longString.matches(baeldung);
}
String.compareTo()
:
@Benchmark
public int benchmarkStringCompareTo() {
return longString.compareTo(baeldung);
}
运行后结果如下:
Benchmark Mode Cnt Score Error Units
benchmarkStringCompareTo ss 10 2.561 ± 0.899 ms/op
benchmarkStringEquals ss 10 1.712 ± 0.839 ms/op
benchmarkStringEqualsIgnoreCase ss 10 2.081 ± 1.221 ms/op
benchmarkStringMatches ss 10 118.364 ± 43.203 ms/op
可以看到,**matches()
因使用正则表达式,性能最差**。而 equals()
和 equalsIgnoreCase()
表现最好。
3.5. String.matches()
vs 预编译 Pattern
String.matches()
每次都会编译正则表达式:
@Benchmark
public boolean benchmarkStringMatches() {
return longString.matches(baeldung);
}
预编译方式复用 Pattern
:
Pattern longPattern = Pattern.compile(longString);
@Benchmark
public boolean benchmarkPrecompiledMatches() {
return longPattern.matcher(baeldung).matches();
}
结果如下:
Benchmark Mode Cnt Score Error Units
benchmarkPrecompiledMatches ss 10 29.594 ± 12.784 ms/op
benchmarkStringMatches ss 10 106.821 ± 46.963 ms/op
✅ 预编译的正则表达式性能提升了约 3 倍。
3.6. 判断字符串长度
我们测试两种判断字符串是否为空的方法:
@Benchmark
public boolean benchmarkStringIsEmpty() {
return longString.isEmpty();
}
@Benchmark
public boolean benchmarkStringLengthZero() {
return emptyString.length() == 0;
}
先测试非空字符串:
Benchmark Mode Cnt Score Error Units
benchmarkStringIsEmpty ss 10 0.295 ± 0.277 ms/op
benchmarkStringLengthZero ss 10 0.472 ± 0.840 ms/op
再测试空字符串:
Benchmark Mode Cnt Score Error Units
benchmarkStringIsEmpty ss 10 0.245 ± 0.362 ms/op
benchmarkStringLengthZero ss 10 0.351 ± 0.473 ms/op
✅ isEmpty()
性能略优于 length() == 0
。
4. 字符串去重
自 JDK 8 起,Java 支持字符串去重功能,用于减少内存占用。简单来说,该功能会识别内容相同的字符串,仅保留一份副本。
目前有两种方式:
- 手动调用
String.intern()
- 启用 JVM 字符串去重
4.1. String.intern()
String.intern()
可以将字符串放入全局字符串池中,JVM 会复用相同内容的引用。
虽然性能上有优势,但也有缺点:
- 需要调整
-XX:StringTableSize
参数,且 JVM 重启后才能生效 - 手动调用
intern()
耗时较高,复杂度为 O(n) - 频繁调用可能导致内存问题
基准测试:
@Benchmark
public String benchmarkStringIntern() {
return baeldung.intern();
}
结果如下:
Benchmark 1000 10,000 100,000 1,000,000
benchmarkStringIntern 0.433 2.243 19.996 204.373
随着迭代次数增加,耗时显著上升。
4.2. 自动去重
该功能是 G1GC 的一部分,Java 18 中也支持 ZGC、Serial GC 和 Parallel GC。默认关闭,需通过以下参数开启:
-XX:+UseG1GC -XX:+UseStringDeduplication
⚠️ 注意:启用后并不保证一定发生去重,也不会处理年轻代字符串。可通过 -XX:StringDeduplicationAgeThreshold=3
控制最小年龄。
5. 总结
本文总结了一些在日常开发中优化 String 性能的建议:
- ✅ 字符串拼接推荐使用
StringBuilder
。对于小字符串,+
操作性能也接近 - ✅ 类型转字符串时,
[type].toString()
更快,但String.valueOf()
通用性更强 - ✅ 字符串比较首选
String.equals()
- ✅ 字符串去重适用于大型多线程应用,但避免滥用
intern()
- ✅ 字符串分割时,
indexOf()
性能最佳 - ✅ 正则匹配时,预编译
Pattern
提升性能 - ✅
String.isEmpty()
比length() == 0
更快
⚠️ 本文提供的性能数据仅作为参考,实际应用中应结合具体环境进行测试。