上周压测时,我们的订单服务在QPS刚到200时就频繁Full GC,年轻代瞬间爆满。你猜罪魁祸首是谁? 就是那段看似无辜的日志拼接代码------"用户"+userId+"订单"+orderId+"状态"+status,它偷偷在循环里生成了数GB的垃圾对象。
1. 当拼接遇上循环:一场内存的屠杀
场景:一个批量处理10万条数据的异步任务,每条记录需要拼接包含5个变量的日志(类似上述代码)。原本以为只是简单的字符串操作,但在jstat -gc监控下,发现短短10秒内产生了近200万次Young GC。
- 根因 *:Java的
+运算符在循环中会创造大量中间对象。比如"a" + "b" + "c"实际会先生成"ab"临时对象,再生成"abc",这两个中间对象在循环中会快速填满Eden区。
java
// 错误写法:每次循环产生3个对象(两个中间,一个最终)
for (Order order : orders) {
log.info("订单" + order.getId() + "金额" + order.getAmount());
}
// 正确写法:StringBuilder复用(关键在循环外实例化)
StringBuilder builder = new StringBuilder(128); // 预设缓冲区大小
for (Order order : orders) {
builder.setLength(0); // 清空而不重新分配
builder.append("订单").append(order.getId())
.append("金额").append(order.getAmount());
log.info(builder.toString());
}
压测对比:错误写法处理10万条数据产生约300MB垃圾,正确写法仅30MB------相差10倍。
2. 你以为的"优化"可能更糟
-
坑点1:误用StringBuffer * "线程安全"的
StringBuffer在单线程场景比StringBuilder慢15%~20%,我曾见过有人在工具类里写static StringBuffer,以为能"提前优化"------实际上成了性能瓶颈。 -
坑点2:滥用String.format()*
java
// 隐蔽的内存杀手:内部会new Object[]和Formatter
String msg = String.format("用户%s订单%d", name, id);
在密集调用时,其内部创建的Object[]数组和Formatter对象会让GC苦不堪言。仅当需要复杂格式化时使用它。
- 坑点3:JDK版本陷阱 * Java 8u20后对
+做了优化(秘密转为invokedynamic调用),但三个限制:
- 仅适用于编译期确定的字面量拼接 (如
"a" + "b") - 循环内依然无效
- 混合类型(如字符串+数字)会退化到传统方式
3. 终极方案:按场景选策略
情景1:已知最终长度的单次拼接
java
// 直接预分配大小(减少扩容拷贝)
StringBuilder builder = new StringBuilder(estimatedLength);
builder.append(a).append(b);
情景2:集合转字符串
java
// 用StringJoiner或Collectors.joining()(内部用StringBuilder)
String joined = list.stream().collect(Collectors.joining(","));
情景3:日志模板
改用参数化日志:
java
// 延迟拼接:仅当需要输出时才拼接
log.debug("订单{}金额{}", order.getId(), order.getAmount());
SLF4J/Log4j2会先判断日志级别,避免无谓的拼接消耗。
4. 避坑清单:记住这5条
- 🔸 循环内拼接永远用
StringBuilder(且尽量复用) - 🔸 预估大小能避免扩容时的数组拷贝(初始容量很关键)
- 🔸 日志必须用参数化写法,别让debug日志成为性能黑洞
- 🔸 Java 8的
+优化有限,别指望编译器替你擦屁股 - 🔸 拼接SQL时更危险(还有SQL注入风险),用
PreparedStatement代替
最后一道思考题
StringBuilder的默认大小是16字节。假设你要拼接100个平均长度50字节的字符串,不预设容量会导致多少次数组扩容?(答案:5次------每次扩容约1.5倍,16→24→36→54→81→122)
下次看到循环里的+,你会不会后背一凉?你们团队有没有其他字符串优化的骚操作? 评论区见。