Administrator
发布于 2018-03-24 / 446 阅读
2

String 拼接用 + 还是 StringBuilder?一次循环拼接引发的 CPU 飙高

导出功能上线,CPU 直接干到 780%

新做的对账单导出上线第一天,监控图上 CPU 从 15% 一条直线拉到 780%(8 核机器)。同一个时间点 Full GC 频率从每小时 2 次变成每分钟 30 多次。导出的文件也就 4MB 大小,不至于。

我用 jstack 抓了几把线程,大量线程停在这行:

"http-nio-8080-exec-23" #58 daemon prio=5 os_prio=0 tid=0x00007f8c nid=0x6f21 runnable
   java.lang.Thread.State: RUNNABLE
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.lang.AbstractStringBuilder.expandCapacity(AbstractStringBuilder.java:137)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:411)
    at java.lang.StringBuilder.append(StringBuilder.java:136)
    at com.xxx.export.BillExport.buildCsv(BillExport.java:47)

第 47 行是这么写的:

String csv = "";
for (BillItem item : items) {          // items 有 12 万条
    csv += item.getOrderNo() + "," + item.getAmount() + "," + item.getDate() + "\n";
}
return csv;

为什么循环里的 += 这么慢

都知道 String 不可变,csv += xxx 不是在原字符串上追加,而是 new 一个新的 String。但"新建对象"这个说法太抽象了,我算了一下实际代价。

假设 csv 当前长度是 N,追加 M 个字符:

  1. 新建一个 char[N+M] 的数组。
  2. 把原来的 N 个字符 copy 过去(Arrays.copyOf 就是这一步)。
  3. 把新的 M 个字符 copy 过去。
  4. 旧的字符串对象变成垃圾,等 GC。

随着 N 线性增长,单次追加的拷贝量也线性增长,总拷贝量就是 O(N²)。12 万条记录,每条平均 60 字符,最终字符串 720 万字符,累计拷贝量大概是 720万 × 720万 / 2 / 60 ≈ 4.3 万亿次 char 拷贝。这个数字算出来我自己都不敢信。

更要命的是内存:中间产生的临时 String 全在堆里,年轻代根本装不下,直接往老年代走,于是 Full GC 疯狂触发。监控上看到的 780% CPU,其实大部分是 GC 线程在跑。

编译期优化到底优化了什么

这里有个我长期误解的点:我记得书上说过"编译器会把 + 优化成 StringBuilder",那循环里不也应该优化了吗?

写两个方法用 javap 对比一下就清楚了。

// A:常量拼接
public String constConcat() {
    return "a" + "b" + "c";
}

// B:循环拼接
public String loopConcat(int n) {
    String s = "";
    for (int i = 0; i < n; i++) {
        s += i;
    }
    return s;
}
$ javac Demo.java && javap -c Demo.class

  public java.lang.String constConcat();
    Code:
       0: ldc           #2    // String abc
       2: areturn

方法 A 直接被折叠成常量 "abc",一个 StringBuilder 都没生成。这是 javac 的常量折叠:编译期就能确定结果的表达式会被直接算出来。

  public java.lang.String loopConcat(int);
    Code:
       ...
      17: new           #5    // class java/lang/StringBuilder
      21: invokespecial #6    // Method java/lang/StringBuilder."<init>":()V
      24: aload_1
      25: invokevirtual #7    // Method append
      28: invokevirtual #8    // Method toString:()Ljava/lang/String;
      31: astore_1
      ...

方法 B 在循环内部每次都 new StringBuilder → append → toString。也就是说 javac 的优化粒度是一条语句s += i 这条语句被优化成了 new StringBuilder().append(s).append(i).toString(),但编译器不会聪明到把整个循环重构成一个 StringBuilder。

这就是编译期优化的边界:单条语句内的 + 会被优化,跨语句(尤其是跨循环)的不会。

改成 StringBuilder,但别忘了容量

StringBuilder sb = new StringBuilder();
for (BillItem item : items) {
    sb.append(item.getOrderNo()).append(",")
      .append(item.getAmount()).append(",")
      .append(item.getDate()).append("\n");
}
return sb.toString();

改完跑了一遍:耗时从 43 秒降到 1.8 秒,CPU 峰值降到 92%。但看 GC 日志,Young GC 次数还是有 380 多次,说明还有优化空间。

问题在 StringBuilder 的扩容机制。默认构造的容量是 16(append 一个长度 L 的字符串时是 max(16, L+16)),满了就:

void expandCapacity(int minimumCapacity) {
    int newCapacity = value.length * 2 + 2;
    ...
    value = Arrays.copyOf(value, newCapacity);
}

每次扩容都是 翻倍 + 一次数组拷贝。从 16 涨到 720 万,需要扩容约 19 次,最后一次要拷贝 360 万个字符。虽然比 O(N²) 好太多了,但还是白白产生了一批大对象。

如果一开始就能估算出最终大小,直接给容量:

// 每条记录大约 60 个字符,预估容量
StringBuilder sb = new StringBuilder(items.size() * 64);

加上预设容量之后:耗时 1.1 秒,Young GC 从 380 次降到 11 次。堆内存的峰值占用也从 1.6GB 降到 320MB 左右。

最终版本

我们最后改成了流式写入,不把整个字符串放内存里:

public void writeCsv(List<BillItem> items, OutputStream out) throws IOException {
    try (BufferedWriter writer = new BufferedWriter(
            new OutputStreamWriter(out, "UTF-8"), 8192)) {
        StringBuilder sb = new StringBuilder(256);
        for (BillItem item : items) {
            sb.setLength(0);                    // 复用,不重新 new
            sb.append(item.getOrderNo()).append(",")
              .append(item.getAmount()).append(",")
              .append(item.getDate()).append("\n");
            writer.write(sb.toString());
        }
    }
}

StringBuilder 复用 + 固定 8KB 缓冲区,12 万条导出稳定在 0.9 秒,堆内存占用峰值不到 50MB。

我的几条经验

  • 循环里拼接字符串,用 StringBuilder,别用 +=。一句话的拼接用 += 无所谓,javac 会优化。
  • StringBuilder 能估容量就估,特别是循环次数上万的场景。
  • 别在循环里 new StringBuilder,提到循环外复用(注意用 setLength(0) 清空,不是 new 新的)。
  • 如果是 StringBuilder 单线程使用,别用 StringBuffer,后者每个方法都 synchronized,实测在 12 万次 append 的场景下慢 18% 左右。

顺带测了几个替代写法

改完之后我顺手把 JDK 8 里几种拼接方式都测了一遍,场景是拼接 10 万个短字符串,结果如下:

写法耗时
循环内 +=11243ms
StringBuilder(无预设容量)19ms
StringBuilder(预设容量)11ms
StringBuffer(预设容量)13ms
String.join(",", list)27ms
StringJoiner24ms

String.join 和 JDK 8 新增的 StringJoiner 内部其实也是 StringBuilder,但它没法预设容量,所以要慢一截。不过它们在处理"用分隔符连接"这种需求时代码最清晰,不用自己判断末尾要不要多一个逗号,10 万以内的量级我都会优先用它——可读性比这十几毫秒值钱。

StringJoiner joiner = new StringJoiner(",", "[", "]");
for (String s : list) {
    joiner.add(s);
}
return joiner.toString();     // [a,b,c]

还有个细节:String.concat() 看起来像 StringBuilder 的平替,但它每次都会 new 一个 char 数组,在循环里用和 += 是一回事,同样会退化成 O(N²)。

参考