导出功能上线,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 个字符:
- 新建一个 char[N+M] 的数组。
- 把原来的 N 个字符 copy 过去(
Arrays.copyOf就是这一步)。 - 把新的 M 个字符 copy 过去。
- 旧的字符串对象变成垃圾,等 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 |
| StringJoiner | 24ms |
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²)。