2213 字
约 7 分钟
15
S04-A18 · 字符串格式化:`+`、`StringBuilder` 与 `format` 的代价

一段拼日志的代码在热点路径上用 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. 自测

  1. 四种写法里,哪一种"零成本"?为什么字面量拼接在编译期就消失?
  2. 循环里 += 为什么是 O(n²)?"builder 在循环内"为什么等于没用?
  3. format 的四步成本是什么?为什么它要处理"本地化"?
  4. 为什么"日志用 {} 而不是 +"不只是风格问题?
  5. intern() 为什么会造成常量池膨胀?JDK 7+ 之后有什么变化?
  6. 判断"该不该用 format"的那个问题是什么?
S04-A18 · 字符串格式化:`+`、`StringBuilder` 与 `format` 的代价
https://www.clxhxhhr.top/posts/6538/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。