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. StringBuilderStringBuffer

关于 StringBuilderStringBuffer 的区别,我们已有 详细文章。这里只关注性能表现。

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 更快

⚠️ 本文提供的性能数据仅作为参考,实际应用中应结合具体环境进行测试


原始标题:String Performance Hints | Baeldung