一段拼日志的代码在热点路径上用 String.format("user=%s order=%s", uid, oid),单次 3 微秒看起来无所谓——直到 QPS 涨到 5 万,这条路径花掉了 15% 的 CPU。换成 "user=" + uid + " order=" + oid 之后降到 2%。
字符串拼接的性能问题有个特点:它不体现在"某一行慢",而体现在"整体 CPU 高"。这个特征解释了它的排查难点:profiler 里往往看不到"format"这个栈帧,因为它被分散在成千上万次调用里,每次只有几微秒。所以要用"分配火焰图"看"谁在造字符串"。
而三种写法的代价差别,取决于"编译期能不能优化"。
1. 四种写法的代价
| 写法 | 机制 |
|---|---|
+ 拼接(字面量) |
编译期折叠成一个常量 |
+ 拼接(变量) |
每次新建 StringBuilder(JDK 9+ 用 invokedynamic) |
StringBuilder 显式 |
复用同一个 builder |
String.format |
解析格式串 + 装箱 + 反射式取值 |
第一项是"零成本":"a" + "b" 在编译期就是 "ab",运行时没有拼接。第一项还有个"编译器版本"细节:JDK 9+ 把 + 编译成 invokedynamic(StringConcatFactory),性能比 JDK 8 的 StringBuilder 略好,且支持"预留精确容量"。
第四项最贵:它要解析格式串、对参数装箱、再逐个判断类型。所以"循环里调 format"是典型的浪费。
2. 循环里拼接的三个坑
| 写法 | 问题 |
|---|---|
s += item |
每次新建对象,O(n²) |
StringBuilder 在外 |
正确 |
StringBuilder 在循环内 |
等于没用 |
第一项的复杂度要说清:拼接 n 次的总复制量是 O(n²),因为第 k 次要把前 k-1 个字符复制一遍。所以"1 万次"与"10 万次"的差距是 100 倍,而不是 10 倍。
第一项在"数据量大"时性能断崖:1 万次拼接会创建 1 万个中间字符串,而它们全部要 GC。
第三项是"抄代码抄错"的常见形态:builder 声明在循环体内,于是每次迭代新建一个,效果与 += 相当。
// 反例:循环内 +=(O(n²))
String s = "";
for (var x : list) s += x; // 每次新建 String
// 正例:builder 在循环外
var sb = new StringBuilder(16 * list.size()); // 预留容量
for (var x : list) sb.append(x);
// 反例:builder 在循环内(等于没用)
for (var x : list) { var sb = new StringBuilder(); sb.append(x); s += sb; }
3. format 的成本构成
| 步骤 | 成本 |
|---|---|
| 解析格式串 | 每次都要(除非缓存) |
| 装箱(int → Integer) | 分配对象 |
| 类型判断与格式化 | 分支 + 本地化处理 |
| 输出拼接 | 分配 |
第二项的"装箱"最容易被忽略:format("%d", intValue) 会创建 Integer第二项还有个"隐式"来源:%s 对 null 会输出 "null",而对自定义对象会调 toString()——后者可能在热点路径上做序列化。
,在热点路径上就是持续的分配压力。
第四項的"本地化"在"日志"上通常是不必要的:你把千分位加进日志,反而让 grep 变难。所以"日志用 +/append、用户界面用 format"是一条实用分工。
而"本地化"是 format 的一部分:它会按 Locale 处理数字与日期,这解释了"为什么它比 + 慢这么多"——它做的事情多得多。
// 热点路径的替代方案(避免 format 的解析与装箱)
sb.append("user=").append(uid).append(" order=").append(oid);
// 或更省:把整数直接写进 char 序列(日志框架的常见做法)
// 需要格式化的场景(非热点):format 更可读,成本可接受
log.info("user={} order={}", uid, oid); // 日志框架用 {} 而非 %s,且延迟拼接
4. 日志框架的"延迟拼接"
| 写法 | 是否总拼接 |
|---|---|
log.debug("x=" + x) |
总是(即使 debug 关闭) |
log.debug("x={}", x) |
仅当级别开启 |
第一项是"最常见的一处浪费":debug 日志在生产关闭,但字符串已经拼好了。第一项的浪费在"序列化"上最夸张:log.debug("x=" + toJson(obj)) 即使不输出,序列化也已经跑完,而它可能是整个请求最贵的操作。
而"拼好再丢弃"的成本与"输出"差不多(除了 IO)。
第二项利用参数化:框架先判断级别,再决定是否格式化。所以"用 {} 而不是 +"在日志里不只是风格。
// 反例:无论级别都会拼接
log.debug("payload=" + JSON.toJSONString(req)); // 序列化也白做
// 正例:参数化 + 级别判断(框架内部做)
log.debug("payload={}", () -> JSON.toJSONString(req)); // 部分框架支持 Supplier
还有一个"JSON 序列化"的连带成本:日志里打对象时,是否序列化由"调用方式"决定。而"先序列化再传参"是最常见的写法,于是它总在跑。
// 反例:序列化总在跑
log.debug("req=" + objectMapper.writeValueAsString(req));
// 正例:只在需要时序列化(Supplier 或显式判断)
if (log.isDebugEnabled()) log.debug("req={}", objectMapper.writeValueAsString(req));
5. 字符串池与常量折叠
| 场景 | 说明 |
|---|---|
| 字面量拼接 | 编译期折叠,进常量池 |
intern() |
手动进池(有成本) |
| 运行时拼接 | 不进池 |
循环里 intern() |
危险(常量池膨胀) |
第一项是免费优化:final 常量 + 字面量拼接会被编译器折叠。
第四项在"用 intern() 省内存"的写法里出现:每个不同的字符串都进池且不会被回收(JDK 6 前),于是内存反而涨。JDK 7+ 后字符串池在堆里,但仍有"膨胀"风险。
"谁在造字符串"要用分配剖析看,而不是 CPU 剖析:
// 用 JFR 或 async-profiler 看分配热点(按类与栈)
jcmd <pid> JFR.start duration=60s filename=alloc.jfr settings=profile
// 或 async-profiler(alloc 模式)
./profiler.sh -e alloc -d 60 -f alloc.html <pid>
// 关注:java.lang.String / byte[] / StringBuilder 的分配栈
6. 排错:五种"字符串处理拖慢系统"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 整体 CPU 高但找不出热点 | 高频小拼接 | 用 profiler 看分配 |
| 频繁 Young GC | 中间字符串太多 | 用 builder |
| 日志一开就变慢 | 参数化没生效 | 用 {} + Supplier |
| 内存缓慢增长 | 常量池膨胀 | 慎用 intern() |
| 输出乱码/错位 | 格式串与参数不匹配 | 检查参数个数 |
第五项是"正确性"问题,而它很常见:format("%s %s", a) 少一个参数会抛异常第五项还有个"静默"版本:参数类型与格式符不匹配(如 %d 传了 String)会抛 IllegalFormatConversionException,而它只在"那条日志真的被输出"时才发生——于是"生产上偶发报错"。
,而 + 拼接不会——于是"改用 format"可能引入新的失败路径。
最后一个"现代替代":Java 15+ 的文本块与方法返回 formatted 让"多行模板"更可读,而它内部同样走 format,所以性能特征不变——但至少不会因为 + 而写得满屏引号。
// 文本块 + formatted(可读性优先,非热点场景)
String msg = """
用户: %s
订单: %s
金额: %.2f
""".formatted(uid, oid, amount);
7. 边界:什么情况 format 就是对的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 错误信息、启动日志 | 非热点 | format 更可读 |
| 需要本地化数字/日期 | format 自带 |
format 必要 |
| 需要参数对齐/宽度 | format 强大 |
format 必要 |
| 一次性输出(CLI) | 不在循环里 | 随意 |
最后一条与"可读性"有关的边界:拼接超过 5 个变量时,+ 会变得难读,此时应当用 format 或用 StringBuilder + 分成几行。所以"性能"之外还有一个"可维护"的判断。
判断口径一句话:这段代码在一秒内会被执行多少次。上万次,就别用 format;几十次,可读性优先。
8. 自测
- 四种写法里,哪一种"零成本"?为什么字面量拼接在编译期就消失?
- 循环里
+=为什么是 O(n²)?"builder 在循环内"为什么等于没用? format的四步成本是什么?为什么它要处理"本地化"?- 为什么"日志用
{}而不是+"不只是风格问题? intern()为什么会造成常量池膨胀?JDK 7+ 之后有什么变化?- 判断"该不该用
format"的那个问题是什么?